From 8fd0029d69e5df4dc885c8c21713c4499587ef0d Mon Sep 17 00:00:00 2001 From: laxjovial Date: Thu, 30 Jul 2026 12:46:11 +0000 Subject: [PATCH] feat: add class-level pause switches Add per-class pause switches so the issuer can freeze one share class without halting others. Class-scoped pauses reduce blast radius during compliance audits. - New ClassPaused = 62 error variant - ClassPauseState(OfferingId, ShareClass) storage key - pause_class / unpause_class entrypoints with issuer auth - is_class_paused public query - holder_any_class_paused helper (conservative: blocks when any class the holder has shares in is paused) - Gating in claim and transfer_with_attestation paths - Early fail-fast check before claim loop - Event emission on pause/unpause for indexer tracking - Comprehensive tests: scoping, idempotency, multi-class, non-existent Closes #521 --- src/lib.rs | 141 ++++++++++++++++++++++++++++++++++++++++ src/test_pause_tiers.rs | 131 +++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index a6c13b3ff..a6802827d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -211,6 +211,10 @@ pub enum RevoraError { MaxDisputesReached = 60, /// The caller holds zero shares in the offering and cannot open a dispute. DisputeZeroShare = 61, + /// One or more classes required for the operation are paused by the issuer. + /// + /// Wire value: 62. Stable since v1. + ClassPaused = 62, } pub mod vesting; @@ -1112,6 +1116,9 @@ pub enum DataKey2 { GovProposal(OfferingId, u32), /// Vote record for (offering_id, proposal_id, voter) -> bool (true=yes, false=no). VoteRecord(OfferingId, u32, Address), + /// Per-class pause flag: when true, transfers and claims involving this class are blocked. + /// Keyed by (OfferingId, ShareClass) -> bool. + ClassPauseState(OfferingId, ShareClass), } /// Maximum number of offerings returned in a single page. @@ -3359,6 +3366,124 @@ impl RevoraRevenueShare { .unwrap_or(PauseState::NotPaused) } + // ── Class-level pause switches ── + + /// Pause a specific share class within an offering. + /// + /// When a class is paused, any transfer or claim involving a holder that + /// has shares in that class is blocked. This conservative approach + /// prevents the paused class's value from leaking through aggregate + /// operations; other holders with only unpaused classes are unaffected. + /// + /// # Authorization + /// Only the offering issuer may call this. + pub fn pause_class( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + share_class: ShareClass, + ) -> Result<(), RevoraError> { + issuer.require_auth(); + + let offering_id = OfferingId { issuer, namespace, token }; + let key = DataKey2::ClassPauseState(offering_id, share_class); + env.storage().persistent().set(&key, &true); + + env.events().publish( + (symbol_short!("cls_pau"), issuer, namespace, token), + (share_class, true), + ); + + Ok(()) + } + + /// Unpause a previously paused share class. + /// + /// # Authorization + /// Only the offering issuer may call this. + pub fn unpause_class( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + share_class: ShareClass, + ) -> Result<(), RevoraError> { + issuer.require_auth(); + + let offering_id = OfferingId { issuer, namespace, token }; + let key = DataKey2::ClassPauseState(offering_id, share_class); + env.storage().persistent().set(&key, &false); + + env.events().publish( + (symbol_short!("cls_unp"), issuer, namespace, token), + (share_class, false), + ); + + Ok(()) + } + + /// Query whether a specific class is currently paused. + pub fn is_class_paused( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + share_class: ShareClass, + ) -> bool { + let offering_id = OfferingId { issuer, namespace, token }; + let key = DataKey2::ClassPauseState(offering_id, share_class); + env.storage().persistent().get::<_, bool>(&key).unwrap_or(false) + } + + /// Check whether ANY class the holder has shares in is paused. + /// + /// When true, the holder's claims and transfers are blocked + /// conservatively to prevent value from the paused class from + /// leaking through aggregate operations. Returns `false` when + /// all of the holder's classes are unpaused or the offering has + /// no multi-class configuration. + fn holder_any_class_paused( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> bool { + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + let classes = env + .storage() + .persistent() + .get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key); + + let Some(cls_vec) = classes else { + // No multi-class configuration — class pause doesn't apply + return false; + }; + + for (sc, _) in cls_vec.iter() { + let share: u32 = env + .storage() + .persistent() + .get(&DataKey2::HolderShareClass( + offering_id.clone(), + holder.clone(), + sc.clone(), + )) + .unwrap_or(0); + if share == 0 { + continue; + } + let pause_key = + DataKey2::ClassPauseState(offering_id.clone(), sc.clone()); + let is_paused: bool = + env.storage().persistent().get(&pause_key).unwrap_or(false); + if is_paused { + return true; + } + } + + false + } + /// Helper: block if the contract is in SoftPaused or HardPaused state. /// Used by reports, deposits, and all non-claim state-mutating entrypoints. fn require_not_paused(env: &Env) -> Result<(), RevoraError> { @@ -5594,6 +5719,14 @@ impl RevoraRevenueShare { token: token.clone(), }; + // Block transfers if any class on the offering is paused + if Self::holder_any_class_paused(&env, &offering_id, &from) { + return Err(RevoraError::ClassPaused); + } + if Self::holder_any_class_paused(&env, &offering_id, &to) { + return Err(RevoraError::ClassPaused); + } + if from == to { return Ok(()); } @@ -7580,6 +7713,10 @@ impl RevoraRevenueShare { return Err(RevoraError::HolderBlacklisted); } Self::require_not_frozen(&env, &offering_id, &holder)?; + // Early fail-fast: block claim when any of the holder's classes is paused + if Self::holder_any_class_paused(&env, &offering_id, &holder) { + return Err(RevoraError::ClassPaused); + } let share_bps = Self::get_holder_share( env.clone(), @@ -7636,6 +7773,10 @@ impl RevoraRevenueShare { if Self::is_frozen(&env, &offering_id, &holder) { break; } + // Block claim when all of the holder's classes are paused + if Self::holder_any_class_paused(&env, &offering_id, &holder) { + return Err(RevoraError::ClassPaused); + } let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap(); diff --git a/src/test_pause_tiers.rs b/src/test_pause_tiers.rs index 63e690170..22a7c7c25 100644 --- a/src/test_pause_tiers.rs +++ b/src/test_pause_tiers.rs @@ -553,3 +553,134 @@ fn is_paused_false_for_not_paused() { client.unpause_admin(&admin); assert!(!client.is_paused()); } + +// ── Section I: Class-level pause switches ──────────────────────────────────── + +use crate::ShareClass; + +/// Helper: create an offering with multi-class configuration. +fn setup_with_classes( + env: &Env, +) -> (RevoraRevenueShareClient<'_>, Address, Address, Address, Address) { + env.mock_all_auths(); + let client = make_client(env); + let admin = Address::generate(env); + let safety = Address::generate(env); + client.initialize(&admin, &Some(safety.clone()), &None::); + + let issuer = Address::generate(env); + let token = Address::generate(env); + + client.register_offering( + &issuer, + &Vec::new(env), + &1u32, + &symbol_short!("def"), + &token, + &10_000, + &token, + &0, + &symbol_short!(""), + &0, + ); + + (client, admin, safety, issuer, token) +} + +/// Pausing a class sets `is_class_paused` to true. +#[test] +fn pause_class_sets_flag() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + assert!(!client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); + + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + + assert!(client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); +} + +/// Unpausing a class clears the pause flag. +#[test] +fn unpause_class_clears_flag() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + assert!(client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); + + client.unpause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + + assert!(!client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); +} + +/// Pausing class A does not affect class B. +#[test] +fn pause_class_scoped_to_one_class() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + + assert!(client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); + assert!(!client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::B)); +} + +/// Pausing a non-existent class is valid (stores the flag for a future class). +#[test] +fn pause_nonexistent_class_stores_flag() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + client.pause_class( + &issuer, + &symbol_short!("def"), + &token, + &ShareClass::Custom(symbol_short!("xyz")), + ); + + assert!(client.is_class_paused( + &issuer, + &symbol_short!("def"), + &token, + &ShareClass::Custom(symbol_short!("xyz")), + )); +} + +/// Calling `pause_class` twice is idempotent. +#[test] +fn pause_class_idempotent() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + + assert!(client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); +} + +/// Calling `unpause_class` on an already-unpaused class is idempotent. +#[test] +fn unpause_class_idempotent() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + client.unpause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + client.unpause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + + assert!(!client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); +} + +/// Multi-class simultaneous pause: all paused classes report as paused. +#[test] +fn multi_class_simultaneous_pause() { + let env = Env::default(); + let (client, _admin, _safety, issuer, token) = setup_with_classes(&env); + + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::A); + client.pause_class(&issuer, &symbol_short!("def"), &token, &ShareClass::B); + + assert!(client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::A)); + assert!(client.is_class_paused(&issuer, &symbol_short!("def"), &token, &ShareClass::B)); +} +