@@ -625,6 +625,9 @@ const EVENT_LOCKUP_VIOLATION: Symbol = symbol_short!("lock_viol");
625625const EVENT_PLATFORM_FEE_SET: Symbol = symbol_short!("fee_set");
626626const EVENT_FRZ_SET: Symbol = symbol_short!("frz_set");
627627const EVENT_FRZ_CLR: Symbol = symbol_short!("frz_clr");
628+ /// Emitted when a freeze reason is cleared but other reasons remain active.
629+ /// Data: `(holder, cleared_reason, remaining_mask)`
630+ const EVENT_FREEZE_REASON_CLEARED: Symbol = symbol_short!("frz_rc");
628631/// Emitted when an OFAC attestation triggers automatic holder freeze.
629632const EVENT_AUTO_FRZ: Symbol = symbol_short!("auto_frz");
630633
@@ -785,22 +788,46 @@ pub struct TenantId {
785788 pub namespace: Symbol,
786789}
787790
791+ /// Each variant is assigned a stable u32 discriminant for bitmask operations.
792+ ///
793+ /// # Bitmask contract (do not reorder or insert)
794+ ///
795+ /// | Variant | Bit | Mask Value |
796+ /// |----------------|------|------------|
797+ /// | Compliance | 0 | 1 |
798+ /// | LegalHold | 1 | 2 |
799+ /// | DisputeOpen | 2 | 4 |
800+ /// | SanctionsMatch | 3 | 8 |
801+ /// | Sanctions | 4 | 16 |
802+ /// | CourtOrder | 5 | 32 |
803+ /// | IssuerDispute | 6 | 64 |
804+ /// | Manual | 7 | 128 |
805+ ///
806+ /// Do NOT exceed 32 variants; bitmask operations assume u32 width.
788807#[contracttype]
789808#[derive(Clone, Debug, PartialEq, Eq, Copy)]
809+ #[repr(u32)]
790810pub enum FreezeReason {
791811 /// Broad compliance or regulatory action.
792- Compliance,
812+ Compliance = 0 ,
793813 /// Court-ordered legal hold.
794- LegalHold,
814+ LegalHold = 1 ,
795815 /// Active dispute under investigation.
796- DisputeOpen,
816+ DisputeOpen = 2 ,
797817 /// Address matched on a sanctions list.
798- SanctionsMatch,
818+ SanctionsMatch = 3 ,
799819 // Legacy variants kept for storage compatibility.
800- Sanctions,
801- CourtOrder,
802- IssuerDispute,
803- Manual,
820+ Sanctions = 4,
821+ CourtOrder = 5,
822+ IssuerDispute = 6,
823+ Manual = 7,
824+ }
825+
826+ impl FreezeReason {
827+ /// Convert this reason to its single-bit mask.
828+ pub fn to_bitmask(self) -> u32 {
829+ 1u32 << (self as u32)
830+ }
804831}
805832
806833/// Outcome of a dispute resolution (#593).
@@ -1570,7 +1597,17 @@ pub enum DataKey2 {
15701597 /// Per-category holder count for transfer restriction accounting.
15711598 CategoryHolderCount(OfferingId, Symbol),
15721599 /// Emergency freeze record for (offering_id, holder).
1600+ ///
1601+ /// **Deprecated** — new code uses `HolderFreezeMask` which stores a u32
1602+ /// bitmask for multi-reason freeze support (#607). This key remains for
1603+ /// backward-compatible reads only.
15731604 EmergencyFreeze(OfferingId, Address),
1605+ /// Holder freeze reason bitmask for (offering_id, holder).
1606+ ///
1607+ /// Stores a `u32` where each bit corresponds to an active [`FreezeReason`].
1608+ /// A value of `0` is treated as "not frozen" and the key SHOULD be removed
1609+ /// when the mask clears entirely. See [`FreezeReason::to_bitmask`].
1610+ HolderFreezeMask(OfferingId, Address),
15741611 /// Total shares issued for an offering (tracks against MaxTotalSupplyShares).
15751612 TotalSharesIssued(OfferingId),
15761613 /// Maximum total supply shares cap for an offering.
@@ -2009,13 +2046,24 @@ impl RevoraRevenueShare {
20092046
20102047 /// Check if a holder is emergency frozen for an offering.
20112048 fn is_frozen(env: &Env, offering_id: &OfferingId, holder: &Address) -> bool {
2012- env.storage()
2049+ // Read bitmask from the new key first; fall back to legacy key for
2050+ // backward-compatible reads during migration.
2051+ let mask: u32 = env
2052+ .storage()
20132053 .persistent()
2014- .get::<DataKey2, FreezeReason>(&DataKey2::EmergencyFreeze(
2015- offering_id.clone(),
2016- holder.clone(),
2017- ))
2018- .is_some()
2054+ .get(&DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()))
2055+ .unwrap_or_else(|| {
2056+ // Legacy path: single-reason freeze stored under EmergencyFreeze.
2057+ env.storage()
2058+ .persistent()
2059+ .get::<DataKey2, FreezeReason>(&DataKey2::EmergencyFreeze(
2060+ offering_id.clone(),
2061+ holder.clone(),
2062+ ))
2063+ .map(|r| r.to_bitmask())
2064+ .unwrap_or(0)
2065+ });
2066+ mask != 0
20192067 }
20202068
20212069 /// Require that a holder is not emergency frozen.
@@ -12199,8 +12247,31 @@ impl RevoraRevenueShare {
1219912247 return Err(RevoraError::NotAuthorized);
1220012248 }
1220112249
12202- let key = DataKey2::EmergencyFreeze(offering_id, holder.clone());
12203- env.storage().persistent().set(&key, &reason);
12250+ // Read existing bitmask (migrating legacy single-reason key on read).
12251+ let key = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone());
12252+ let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone());
12253+ let mut current_mask: u32 = env.storage().persistent().get(&key).unwrap_or_else(|| {
12254+ env.storage()
12255+ .persistent()
12256+ .get::<DataKey2, FreezeReason>(&legacy_key)
12257+ .map(|r| r.to_bitmask())
12258+ .unwrap_or(0)
12259+ });
12260+
12261+ let reason_bit = reason.to_bitmask();
12262+ if current_mask & reason_bit != 0 {
12263+ // Reason already active — idempotent no-op (do not emit duplicate event).
12264+ return Ok(());
12265+ }
12266+
12267+ current_mask |= reason_bit;
12268+
12269+ // Clean up legacy key if present.
12270+ if env.storage().persistent().has(&legacy_key) {
12271+ env.storage().persistent().remove(&legacy_key);
12272+ }
12273+
12274+ env.storage().persistent().set(&key, ¤t_mask);
1220412275 env.events().publish((EVENT_FRZ_SET, issuer, namespace, token), (caller, holder, reason));
1220512276 Ok(())
1220612277 }
@@ -12312,7 +12383,25 @@ impl RevoraRevenueShare {
1231212383 ///
1231312384 /// Security posture:
1231412385 /// - Requires the exact same `reason` that was used to freeze the holder.
12315- pub fn emergency_unfreeze_holder(
12386+ /// Clear a single freeze reason from a holder's bitmask (#607).
12387+ ///
12388+ /// Only the specific reason bit is cleared. If other reasons remain active
12389+ /// the holder stays frozen. When ALL reasons have been cleared (mask == 0)
12390+ /// the holder is fully unfrozen and [`EVENT_FRZ_CLR`] is emitted.
12391+ ///
12392+ /// # Authorization
12393+ /// Same as [`emergency_freeze_holder`]: caller must be the offering's current
12394+ /// issuer or the contract admin.
12395+ ///
12396+ /// # Errors
12397+ /// | Error | Condition |
12398+ /// |---|---|
12399+ /// | `ContractFrozen` | Contract is globally frozen |
12400+ /// | `OfferingNotFound` | No offering matches (issuer, namespace, token) |
12401+ /// | `NotAuthorized` | Caller is neither issuer nor admin |
12402+ /// | `HolderFrozen` | Holder has no active freeze record |
12403+ /// | `FreezeReasonMismatch` | The requested reason is not currently set |
12404+ pub fn clear_freeze_reason(
1231612405 env: Env,
1231712406 caller: Address,
1231812407 issuer: Address,
@@ -12339,18 +12428,68 @@ impl RevoraRevenueShare {
1233912428 return Err(RevoraError::NotAuthorized);
1234012429 }
1234112430
12342- let key = DataKey2::EmergencyFreeze(offering_id, holder.clone());
12343- let stored_reason: FreezeReason =
12344- env.storage().persistent().get(&key).ok_or(RevoraError::HolderFrozen)?;
12345- if stored_reason != reason {
12431+ let key = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone());
12432+ let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone());
12433+
12434+ // Read current mask; migrate legacy single-reason key on read.
12435+ let current_mask: u32 = env.storage().persistent().get(&key).unwrap_or_else(|| {
12436+ env.storage()
12437+ .persistent()
12438+ .get::<DataKey2, FreezeReason>(&legacy_key)
12439+ .map(|r| r.to_bitmask())
12440+ .unwrap_or(0)
12441+ });
12442+
12443+ if current_mask == 0 {
12444+ return Err(RevoraError::HolderFrozen);
12445+ }
12446+
12447+ let reason_bit = reason.to_bitmask();
12448+ if current_mask & reason_bit == 0 {
1234612449 return Err(RevoraError::FreezeReasonMismatch);
1234712450 }
1234812451
12349- env.storage().persistent().remove(&key);
12350- env.events().publish((EVENT_FRZ_CLR, issuer, namespace, token), (caller, holder, reason));
12452+ let new_mask = current_mask & !reason_bit;
12453+
12454+ // Clean up legacy key if present.
12455+ if env.storage().persistent().has(&legacy_key) {
12456+ env.storage().persistent().remove(&legacy_key);
12457+ }
12458+
12459+ if new_mask == 0 {
12460+ // All reasons cleared — full unfreeze.
12461+ env.storage().persistent().remove(&key);
12462+ env.events().publish(
12463+ (EVENT_FRZ_CLR, issuer, namespace, token),
12464+ (caller, holder, reason),
12465+ );
12466+ } else {
12467+ // Partial unfreeze — update mask and emit scoped event.
12468+ env.storage().persistent().set(&key, &new_mask);
12469+ env.events().publish(
12470+ (EVENT_FREEZE_REASON_CLEARED, issuer, namespace, token),
12471+ (caller, holder, reason, new_mask),
12472+ );
12473+ }
12474+
1235112475 Ok(())
1235212476 }
1235312477
12478+ pub fn emergency_unfreeze_holder(
12479+ env: Env,
12480+ caller: Address,
12481+ issuer: Address,
12482+ namespace: Symbol,
12483+ token: Address,
12484+ holder: Address,
12485+ reason: FreezeReason,
12486+ ) -> Result<(), RevoraError> {
12487+ // Delegates to clear_freeze_reason (#607 reason-scoped unfreeze).
12488+ // When only one reason is active, this results in a full unfreeze.
12489+ // When multiple reasons are active, only the specified reason is cleared.
12490+ Self::clear_freeze_reason(env, caller, issuer, namespace, token, holder, reason)
12491+ }
12492+
1235412493 /// Return true if a holder is emergency frozen for an offering.
1235512494 pub fn is_holder_frozen(
1235612495 env: Env,
@@ -12359,11 +12498,50 @@ impl RevoraRevenueShare {
1235912498 token: Address,
1236012499 holder: Address,
1236112500 ) -> bool {
12501+ let offering_id = OfferingId { issuer, namespace, token };
12502+ // Read bitmask from the new key first; fall back to legacy key.
12503+ let mask: u32 = env
12504+ .storage()
12505+ .persistent()
12506+ .get(&DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()))
12507+ .unwrap_or_else(|| {
12508+ env.storage()
12509+ .persistent()
12510+ .get::<DataKey2, FreezeReason>(&DataKey2::EmergencyFreeze(
12511+ offering_id,
12512+ holder,
12513+ ))
12514+ .map(|r| r.to_bitmask())
12515+ .unwrap_or(0)
12516+ });
12517+ mask != 0
12518+ }
12519+
12520+ /// Return the raw freeze reason bitmask for a holder on an offering.
12521+ ///
12522+ /// Returns `0` when no freeze is active. Callers can test individual
12523+ /// reasons with `(mask & reason.to_bitmask()) != 0`.
12524+ pub fn get_holder_freeze_reasons(
12525+ env: Env,
12526+ issuer: Address,
12527+ namespace: Symbol,
12528+ token: Address,
12529+ holder: Address,
12530+ ) -> u32 {
1236212531 let offering_id = OfferingId { issuer, namespace, token };
1236312532 env.storage()
1236412533 .persistent()
12365- .get::<DataKey2, FreezeReason>(&DataKey2::EmergencyFreeze(offering_id, holder))
12366- .is_some()
12534+ .get(&DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()))
12535+ .unwrap_or_else(|| {
12536+ env.storage()
12537+ .persistent()
12538+ .get::<DataKey2, FreezeReason>(&DataKey2::EmergencyFreeze(
12539+ offering_id,
12540+ holder,
12541+ ))
12542+ .map(|r| r.to_bitmask())
12543+ .unwrap_or(0)
12544+ })
1236712545 }
1236812546
1236912547 /// Get a dispute entry by its ID.
@@ -12427,12 +12605,28 @@ impl RevoraRevenueShare {
1242712605 token: token.clone(),
1242812606 };
1242912607
12430- // Freeze the holder with SanctionsMatch reason
12608+ // Freeze the holder with SanctionsMatch reason (OR into bitmask).
1243112609 if !Self::is_event_only(&env) {
12432- env.storage().persistent().set(
12433- &DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()),
12434- &FreezeReason::SanctionsMatch,
12435- );
12610+ let fk = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone());
12611+ let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone());
12612+
12613+ // Read existing mask, migrating legacy key if present.
12614+ let current_mask: u32 = env.storage().persistent().get(&fk).unwrap_or_else(|| {
12615+ env.storage()
12616+ .persistent()
12617+ .get::<DataKey2, FreezeReason>(&legacy_key)
12618+ .map(|r| r.to_bitmask())
12619+ .unwrap_or(0)
12620+ });
12621+
12622+ // Clean up legacy key if present.
12623+ if env.storage().persistent().has(&legacy_key) {
12624+ env.storage().persistent().remove(&legacy_key);
12625+ }
12626+
12627+ env.storage()
12628+ .persistent()
12629+ .set(&fk, &(current_mask | FreezeReason::SanctionsMatch.to_bitmask()));
1243612630 }
1243712631
1243812632 // Emit structured event for audit trail
0 commit comments