Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4612,6 +4612,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> {
Expand Down Expand Up @@ -7088,6 +7206,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(());
}
Expand Down Expand Up @@ -10581,6 +10707,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();
Expand Down
131 changes: 131 additions & 0 deletions src/test_pause_tiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<bool>);

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));
}

Loading