diff --git a/src/lib.rs b/src/lib.rs index 88a4e178..aea74e70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -355,6 +355,8 @@ pub enum RevoraError { /// [`set_transfer_cooldown`]) has elapsed since the holder's last transfer. /// Wire value: 89. Stable since v1. TransferCooldownActive = 89, + /// Per-holder redemption amount would exceed the per-window cap. + RedemptionCapExceeded = 90, } pub mod tax_bucket; @@ -391,9 +393,9 @@ mod test_time_windows; // #[cfg(test)] // mod test_claim_transfer_fail; #[cfg(test)] -mod test_close_period; +mod test_accrual_reconciliation_prop; #[cfg(test)] -mod test_compute_share_decomposition_prop; +mod test_close_period; #[cfg(test)] mod test_compute_share_decomposition_prop; #[cfg(test)] @@ -408,8 +410,6 @@ mod test_quorum_check; #[cfg(test)] mod test_reg_limit_delta; #[cfg(test)] -mod test_accrual_reconciliation_prop; -#[cfg(test)] mod test_tax_year; #[cfg(test)] mod test_transfer_cooldown; @@ -547,8 +547,7 @@ const EVENT_ROYALTY_CONFIG: Symbol = symbol_short!("roy_cfg"); const EVENT_ROYALTY_PAID: Symbol = symbol_short!("roy_paid"); const EVENT_INDEXED_V2: Symbol = symbol_short!("ev_idx2"); const EVENT_INDEXED_V3: Symbol = symbol_short!("ev_idx3"); -pub const EVENT_PROOF_REJECT_DEPTH: Symbol = symbol_short!("proof_reject_depth"); -pub const MAX_PROOF_DEPTH: u32 = 32; +pub use crate::merkle_helpers::MAX_PROOF_DEPTH; const EVENT_TYPE_OFFER: Symbol = symbol_short!("offer"); /// Emitted when a period is sealed by `close_period`. const EVENT_PERIOD_CLOSED: Symbol = symbol_short!("per_clos"); @@ -1308,6 +1307,13 @@ pub struct MetaRevenueApprovalPayload { pub struct AccessWindow { pub start_timestamp: u64, pub end_timestamp: u64, + pub per_holder_redemption_cap: i128, +} + +impl AccessWindow { + pub fn new(start_timestamp: u64, end_timestamp: u64) -> Self { + AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 } + } } /// Per-holder pending redemption request. @@ -1318,6 +1324,22 @@ pub struct PendingRedemption { pub timestamp: u64, } +/// Tracks how much a holder has already redeemed within the current window. +/// The `window_start` field acts as a discriminator: when a new window is set, +/// old entries are treated as stale (amount treated as zero). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct HolderCumulativeRedeemed { + pub window_start: u64, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum CumulativeRedemptionKey { + CumulativeHolderRedeemed(OfferingId, Address), +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum WindowDataKey { @@ -1514,7 +1536,7 @@ pub struct AccrualAnchor { /// Overflow enum to keep DataKey within the Soroban XDR union variant limit. #[contracttype] #[derive(Clone)] -pub enum DataKey2 { +pub(crate) enum DataKey2 { /// Whether the snapshot has been finalized successfully. SnapshotFinalized(OfferingId, u64), /// Per-offering supply cap (max total deposited revenue). @@ -8932,7 +8954,12 @@ impl RevoraRevenueShare { } if temp_total_shares == max_shares { env.events().publish( - (EVENT_SUPPLY_CAP_SATURATED, offering_id.issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()), + ( + EVENT_SUPPLY_CAP_SATURATED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), (temp_total_shares, max_shares), ); } @@ -9788,7 +9815,7 @@ impl RevoraRevenueShare { return Err(RevoraError::OfferingNotFound); } issuer.require_auth(); - let window = AccessWindow { start_timestamp, end_timestamp }; + let window = AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 }; Self::validate_window(&window)?; let offering_id = OfferingId { issuer: issuer.clone(), @@ -9820,7 +9847,7 @@ impl RevoraRevenueShare { return Err(RevoraError::OfferingNotFound); } issuer.require_auth(); - let window = AccessWindow { start_timestamp, end_timestamp }; + let window = AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 }; Self::validate_window(&window)?; let offering_id = OfferingId { issuer: issuer.clone(), @@ -9859,6 +9886,8 @@ impl RevoraRevenueShare { /// Configure the redemption window for an offering. If unset, always open. /// Rejects the request if a stored redemption window overlaps with the new one. + /// `per_holder_cap` limits how much a single holder can redeem within this window + /// (in payment token units). 0 means no cap. pub fn set_redemption_window( env: Env, issuer: Address, @@ -9866,6 +9895,7 @@ impl RevoraRevenueShare { token: Address, start_timestamp: u64, end_timestamp: u64, + per_holder_cap: i128, ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; let current_issuer = @@ -9875,7 +9905,11 @@ impl RevoraRevenueShare { return Err(RevoraError::OfferingNotFound); } issuer.require_auth(); - let new_window = AccessWindow { start_timestamp, end_timestamp }; + let new_window = AccessWindow { + start_timestamp, + end_timestamp, + per_holder_redemption_cap: per_holder_cap, + }; Self::validate_window(&new_window)?; let offering_id = OfferingId { issuer: issuer.clone(), @@ -9896,7 +9930,7 @@ impl RevoraRevenueShare { env.storage().persistent().set(&WindowDataKey::Redemption(offering_id), &new_window); env.events().publish( (EVENT_REDEMPTION_WINDOW_SET, issuer, namespace, token), - (start_timestamp, end_timestamp), + (start_timestamp, end_timestamp, per_holder_cap), ); Ok(()) } @@ -10368,7 +10402,7 @@ impl RevoraRevenueShare { Self::require_not_frozen(&env)?; issuer.require_auth(); - let window = AccessWindow { start_timestamp, end_timestamp }; + let window = AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 }; Self::validate_window(&window)?; let offering_id = OfferingId { issuer: issuer.clone(), @@ -10978,11 +11012,7 @@ impl RevoraRevenueShare { let mut payouts: Vec = Vec::new(env); for (bounded_bps, share_bps, holder, normalized_payout) in payout_rows { let _ = bounded_bps; - payouts.push_back(DistributionEntry { - holder, - share_bps, - normalized_payout, - }); + payouts.push_back(DistributionEntry { holder, share_bps, normalized_payout }); } PreflightCloseResult { @@ -12089,6 +12119,40 @@ impl RevoraRevenueShare { (amount, 0i128, None) }; + // Check per-holder redemption cap (issue #554) + let window_key = WindowDataKey::Redemption(offering_id.clone()); + if let Some(window) = + env.storage().persistent().get::(&window_key) + { + let cap = window.per_holder_redemption_cap; + if cap > 0 { + let cumulative_key = CumulativeRedemptionKey::CumulativeHolderRedeemed( + offering_id.clone(), + holder.clone(), + ); + let mut cumulative: HolderCumulativeRedeemed = + env.storage().persistent().get(&cumulative_key).unwrap_or( + HolderCumulativeRedeemed { + window_start: window.start_timestamp, + amount: 0, + }, + ); + // If window changed, reset cumulative + if cumulative.window_start != window.start_timestamp { + cumulative = HolderCumulativeRedeemed { + window_start: window.start_timestamp, + amount: 0, + }; + } + let new_total = cumulative.amount.checked_add(net_amount).unwrap_or(i128::MAX); + if new_total > cap { + return Err(RevoraError::RedemptionCapExceeded); + } + cumulative.amount = new_total; + env.storage().persistent().set(&cumulative_key, &cumulative); + } + } + let token_client = token::Client::new(&env, &payment_token); if net_amount > 0 && token_client.try_transfer(&contract_addr, &holder, &net_amount).is_err() @@ -16081,8 +16145,6 @@ impl RevoraRevenueShare { } } -#[cfg(test)] -mod test_close_period; #[cfg(test)] mod test_deferred_priority; #[cfg(test)] @@ -16090,8 +16152,6 @@ mod test_merkle_proof_depth; #[cfg(test)] mod test_merkle_root_rotation; #[cfg(test)] -mod test_merkle_root_rotation; -#[cfg(test)] mod test_snapshot_voting_weight; #[cfg(test)] mod test_storage_layout_version; diff --git a/src/merkle_helpers.rs b/src/merkle_helpers.rs index a1364698..6c671c07 100644 --- a/src/merkle_helpers.rs +++ b/src/merkle_helpers.rs @@ -116,6 +116,11 @@ pub enum MerkleError { ProofTooDeep = 1003, } +/// Maximum number of sibling hashes accepted in a Merkle proof. +/// +/// Proofs longer than this are rejected with [`MerkleError::ProofTooDeep`]. +pub const MAX_PROOF_DEPTH: u32 = 32; + // ── Public helpers ────────────────────────────────────────────────────────── /// One entry in a canonical Merkle-leaf sequence. diff --git a/src/structured_error_tests.rs b/src/structured_error_tests.rs index cfbe4c68..3d3e312b 100644 --- a/src/structured_error_tests.rs +++ b/src/structured_error_tests.rs @@ -124,6 +124,7 @@ mod tests { ("RedemptionWindowOverlap", RevoraError::RedemptionWindowOverlap as u32), ("JurisdictionMigrationDeadlineExceeded", RevoraError::JurisdictionMigrationDeadlineExceeded as u32), ("TransferCooldownActive", RevoraError::TransferCooldownActive as u32), + ("RedemptionCapExceeded", RevoraError::RedemptionCapExceeded as u32), ]; // O(n²) uniqueness check — n is small, negligible cost. @@ -207,6 +208,7 @@ mod tests { assert_eq!(RevoraError::MissingReportForOverride as u32, 47); assert_eq!(RevoraError::JurisdictionMigrationDeadlineExceeded as u32, 76); assert_eq!(RevoraError::TransferCooldownActive as u32, 89); + assert_eq!(RevoraError::RedemptionCapExceeded as u32, 90); } // ───────────────────────────────────────────────────────────────────────── @@ -292,9 +294,10 @@ mod tests { RevoraError::FaucetCooldownActive as u32, RevoraError::JurisdictionMigrationDeadlineExceeded as u32, RevoraError::TransferCooldownActive as u32, + RevoraError::RedemptionCapExceeded as u32, ]; for v in all.iter() { - assert!(*v >= 1 && *v <= 89, "discriminant {v} out of expected range 1..=89"); + assert!(*v >= 1 && *v <= 90, "discriminant {v} out of expected range 1..=90"); } } @@ -369,6 +372,7 @@ mod tests { RevoraError::FaucetCooldownActive as u32, RevoraError::JurisdictionMigrationDeadlineExceeded as u32, RevoraError::TransferCooldownActive as u32, + RevoraError::RedemptionCapExceeded as u32, ]; for v in all.iter() { assert_ne!(*v, 0, "discriminant 0 is reserved for Ok; no error variant may use it"); diff --git a/src/tax_bucket.rs b/src/tax_bucket.rs index 073f1b62..74596efc 100644 --- a/src/tax_bucket.rs +++ b/src/tax_bucket.rs @@ -146,11 +146,11 @@ pub fn update_tax_year_accumulator( return_of_capital: i128, ) { let year_key = DataKey2::TaxYearEntry(offering_id.clone(), holder.clone(), fiscal_year); - let mut summary: TaxYearSummary = env.storage().persistent().get(&year_key).unwrap_or(TaxYearSummary { - ordinary_income: 0, - capital_gains: 0, - return_of_capital: 0, - }); + let mut summary: TaxYearSummary = env + .storage() + .persistent() + .get(&year_key) + .unwrap_or(TaxYearSummary { ordinary_income: 0, capital_gains: 0, return_of_capital: 0 }); summary.ordinary_income = summary.ordinary_income.saturating_add(ordinary_income); summary.capital_gains = summary.capital_gains.saturating_add(capital_gains); summary.return_of_capital = summary.return_of_capital.saturating_add(return_of_capital); diff --git a/src/test_close_period.rs b/src/test_close_period.rs index a7f686a4..89277e84 100644 --- a/src/test_close_period.rs +++ b/src/test_close_period.rs @@ -71,21 +71,20 @@ fn setup_offering_with_contract_id( client.register_offering( &issuer, + &Vec::from_array(&env, []), + &1u32, &symbol_short!("ns"), &offering_token, - &10_000, + &10_000u32, &payment_token, - &0, + &0i128, + &symbol_short!(""), + &0u32, ); (env, client, issuer, offering_token, payment_token, contract_id) } -fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address) { - let (env, client, issuer, token, payment_token, _) = setup_offering_with_contract_id(); - (env, client, issuer, token, payment_token) -} - proptest! { #![proptest_config(ProptestConfig { cases: 16, diff --git a/src/test_redemption.rs b/src/test_redemption.rs index b2c05c71..2683bb95 100644 --- a/src/test_redemption.rs +++ b/src/test_redemption.rs @@ -63,12 +63,13 @@ fn set_redemption_window_ok() { let (client, issuer, offering_token, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let window = client.get_redemption_window(&issuer, &symbol_short!("def"), &offering_token).unwrap(); assert_eq!(window.start_timestamp, 500); assert_eq!(window.end_timestamp, 2000); + assert_eq!(window.per_holder_redemption_cap, 0); } #[test] @@ -83,6 +84,7 @@ fn set_redemption_window_rejects_non_issuer() { &offering_token, &500, &2000, + &0, ); assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); } @@ -98,6 +100,7 @@ fn set_redemption_window_rejects_bad_range() { &offering_token, &2000, &500, + &0, ); assert_eq!(result, Err(Ok(RevoraError::LimitReached))); } @@ -119,7 +122,7 @@ fn set_redemption_window_rejects_overlap() { let (client, issuer, offering_token, ..) = setup_offering(&env); // Set first window [500, 2000) - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); // Overlap from the left: [100, 1000) collides with [500, 2000) let result = client.try_set_redemption_window( @@ -128,6 +131,7 @@ fn set_redemption_window_rejects_overlap() { &offering_token, &100, &1000, + &0, ); assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap))); @@ -138,6 +142,7 @@ fn set_redemption_window_rejects_overlap() { &offering_token, &1500, &3000, + &0, ); assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap))); @@ -148,6 +153,7 @@ fn set_redemption_window_rejects_overlap() { &offering_token, &600, &1800, + &0, ); assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap))); @@ -158,6 +164,7 @@ fn set_redemption_window_rejects_overlap() { &offering_token, &100, &3000, + &0, ); assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap))); } @@ -168,7 +175,7 @@ fn set_redemption_window_allows_contiguous_non_overlapping() { let (client, issuer, offering_token, ..) = setup_offering(&env); // Set first window [500, 2000) - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); // Non-overlapping: [2000, 3000) -- exactly adjacent (end == start) let result = client.try_set_redemption_window( @@ -177,6 +184,7 @@ fn set_redemption_window_allows_contiguous_non_overlapping() { &offering_token, &2000, &3000, + &0, ); assert_eq!(result, Ok(Ok(()))); @@ -184,6 +192,7 @@ fn set_redemption_window_allows_contiguous_non_overlapping() { let window = client.get_redemption_window(&issuer, &symbol_short!("def"), &offering_token).unwrap(); assert_eq!(window.start_timestamp, 2000); assert_eq!(window.end_timestamp, 3000); + assert_eq!(window.per_holder_redemption_cap, 0); } // ── request_redemption ──────────────────────────────────────────────────────── @@ -194,7 +203,7 @@ fn request_redemption_ok() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let result = client.try_request_redemption( &holder, @@ -212,7 +221,7 @@ fn request_redemption_outside_window() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 3000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let result = client.try_request_redemption( &holder, @@ -246,7 +255,7 @@ fn request_redemption_blacklisted() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &offering_token, &holder); let result = client.try_request_redemption( @@ -265,7 +274,7 @@ fn request_redemption_zero_shares() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let result = client.try_request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &0); @@ -278,7 +287,7 @@ fn request_redemption_exceeds_share() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let result = client.try_request_redemption( &holder, @@ -297,7 +306,7 @@ fn request_redemption_no_share() { let no_share_holder = Address::generate(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let result = client.try_request_redemption( &no_share_holder, @@ -315,7 +324,7 @@ fn request_redemption_duplicate() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); // First request succeeds client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); @@ -360,7 +369,7 @@ fn fulfill_redemption_ok() { let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); let balance_before = token::Client::new(&env, &payment_token).balance(&holder); @@ -386,7 +395,7 @@ fn fulfill_redemption_blacklisted() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); // Blacklist after request, before fulfill @@ -408,7 +417,7 @@ fn fulfill_redemption_no_request() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let result = client.try_fulfill_redemption( &issuer, @@ -426,7 +435,7 @@ fn fulfill_redemption_outside_window() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); // Advance past window close @@ -448,7 +457,7 @@ fn fulfill_redemption_zero_share() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); // Clear holder share before fulfill @@ -470,7 +479,7 @@ fn fulfill_redemption_capped_to_current_share() { let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); // Request current share (5_000) in full client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &5_000); @@ -497,7 +506,7 @@ fn fulfill_redemption_rejects_zero_amount() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); let result = @@ -511,7 +520,7 @@ fn fulfill_redemption_full_flow_then_re_request() { let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &5000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &5000, &0); // Request 2_000 of 5_000 shares client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); @@ -533,7 +542,7 @@ fn fulfill_redemption_non_issuer_rejected() { let stranger = Address::generate(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); let result = client.try_fulfill_redemption( @@ -580,7 +589,7 @@ fn redemption_events_emitted() { ); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); let before = env.events().all().len(); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); @@ -596,7 +605,7 @@ fn redemption_inside_window_after_blacklisting_rejected() { let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); // Blacklist while request is pending @@ -671,7 +680,7 @@ fn test_fulfill_redemption_routes_fee_to_treasury() { client.set_redemption_fee_bps(&issuer, &symbol_short!("def"), &offering_token, &1_000, &treasury); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); let payout_token = soroban_sdk::token::Client::new(&env, &payout_token_id); @@ -701,7 +710,7 @@ fn test_fulfill_redemption_max_fee_5000_bps() { client.set_redemption_fee_bps(&issuer, &symbol_short!("def"), &offering_token, &5_000, &treasury); set_timestamp(&env, 1000); - client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); let payout_token = soroban_sdk::token::Client::new(&env, &payout_token_id); @@ -907,4 +916,206 @@ fn test_extend_lockup_no_schedule_rejected() { &attestation, ); assert_eq!(result, Err(Ok(RevoraError::InvalidAmount))); + +// ── Per-holder redemption cap (issue #554) ────────────────────────────────── + +#[test] +fn set_redemption_window_with_cap_ok() { + let env = Env::default(); + let (client, issuer, offering_token, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + + let window = + client.get_redemption_window(&issuer, &symbol_short!("def"), &offering_token).unwrap(); + assert_eq!(window.start_timestamp, 500); + assert_eq!(window.end_timestamp, 2000); + assert_eq!(window.per_holder_redemption_cap, 100_000); +} + +#[test] +fn fulfill_redemption_within_cap() { + let env = Env::default(); + let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + let balance_before = token::Client::new(&env, &payment_token).balance(&holder); + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &50_000, + ); + assert_eq!(result, Ok(Ok(50_000))); + let balance_after = token::Client::new(&env, &payment_token).balance(&holder); + assert_eq!(balance_after - balance_before, 50_000); +} + +#[test] +fn fulfill_redemption_at_cap() { + let env = Env::default(); + let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + let balance_before = token::Client::new(&env, &payment_token).balance(&holder); + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &100_000, + ); + assert_eq!(result, Ok(Ok(100_000))); + let balance_after = token::Client::new(&env, &payment_token).balance(&holder); + assert_eq!(balance_after - balance_before, 100_000); +} + +#[test] +fn fulfill_redemption_exceeds_cap_rejected() { + let env = Env::default(); + let (client, issuer, offering_token, _, holder, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &150_000, + ); + assert_eq!(result, Err(Ok(RevoraError::RedemptionCapExceeded))); +} + +#[test] +fn fulfill_redemption_cumulative_over_cap_rejected() { + let env = Env::default(); + let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + // Request only part of holder's share so there's remaining after fulfillment + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + // First fulfillment at cap + client.fulfill_redemption(&issuer, &symbol_short!("def"), &offering_token, &holder, &100_000); + + // Holder still has remaining shares + let share_left = client.get_holder_share(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert!(share_left > 0); + + // Request remaining shares and try to fulfill — should be rejected by cumulative cap + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &share_left); + + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &50_000, + ); + assert_eq!(result, Err(Ok(RevoraError::RedemptionCapExceeded))); +} + +#[test] +fn fulfill_redemption_cap_resets_in_new_window() { + let env = Env::default(); + let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + client.fulfill_redemption(&issuer, &symbol_short!("def"), &offering_token, &holder, &100_000); + + // New window should reset the cap + set_timestamp(&env, 2500); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &2000, &5000, &100_000); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + let balance_before = token::Client::new(&env, &payment_token).balance(&holder); + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &100_000, + ); + assert_eq!(result, Ok(Ok(100_000))); + let balance_after = token::Client::new(&env, &payment_token).balance(&holder); + assert_eq!(balance_after - balance_before, 100_000); +} + +#[test] +fn fulfill_redemption_zero_cap_allows_unlimited() { + let env = Env::default(); + let (client, issuer, offering_token, payment_token, holder, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &0); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &1_000_000, + ); + assert_eq!(result, Ok(Ok(1_000_000))); +} + +#[test] +fn fulfill_redemption_cap_honors_fee_in_net_amount() { + let env = Env::default(); + let (client, issuer, offering_token, payout_token_id, holder, ..) = setup_offering(&env); + let treasury = Address::generate(&env); + + // Set 10% fee and a cap of 100_000 + client.set_redemption_fee_bps(&issuer, &symbol_short!("def"), &offering_token, &1_000, &treasury); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &100_000); + client.request_redemption(&holder, &issuer, &symbol_short!("def"), &offering_token, &2_000); + + // Gross amount = 111_111 → net ≈ 100_000 (111_111 - 11_111), should be OK at cap + let payout_token = soroban_sdk::token::Client::new(&env, &payout_token_id); + let balance_before = payout_token.balance(&holder); + let result = client.try_fulfill_redemption( + &issuer, + &symbol_short!("def"), + &offering_token, + &holder, + &111_111, + ); + assert!(result.is_ok()); + assert!(payout_token.balance(&holder) > balance_before); + + // Verify net amount respects the cap — treasury receives fee, holder receives net + let net_received = payout_token.balance(&holder) - balance_before; + assert!(net_received <= 100_000); + assert_eq!(net_received, 100_000); +} + +#[test] +fn set_redemption_window_cap_in_window_assertion() { + let env = Env::default(); + let (client, issuer, offering_token, ..) = setup_offering(&env); + + set_timestamp(&env, 1000); + client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000, &250_000); + + let window = client.get_redemption_window(&issuer, &symbol_short!("def"), &offering_token).unwrap(); + assert_eq!(window.start_timestamp, 500); + assert_eq!(window.end_timestamp, 2000); + assert_eq!(window.per_holder_redemption_cap, 250_000); } diff --git a/src/test_tax_year.rs b/src/test_tax_year.rs index ee457f6b..ca5895e2 100644 --- a/src/test_tax_year.rs +++ b/src/test_tax_year.rs @@ -52,11 +52,7 @@ fn fiscal_year_config_default_and_roundtrip() { let ns = symbol_short!("def"); // Default is January (1). - assert_eq!( - client.get_fiscal_year_start(&issuer, &ns, &token), - 1, - "default should be January", - ); + assert_eq!(client.get_fiscal_year_start(&issuer, &ns, &token), 1, "default should be January",); // Set to April (4). client.set_fiscal_year_start(&issuer, &ns, &token, &4);