Skip to content

Commit 9c2e25c

Browse files
feat: scoped-unfreeze by reason bitmask (#607) (#699)
- Add #[repr(u32)] to FreezeReason with explicit discriminants and to_bitmask() helper for safe bitmask operations - Introduce DataKey2::HolderFreezeMask storing a u32 bitmask instead of a single FreezeReason, with legacy EmergencyFreeze fallback on read - emergency_freeze_holder now ORs reason bits into the mask; idempotent when the reason is already active - Add clear_freeze_reason(reason) to clear a single reason bit; only fully unfreezes (removes key, emits frz_clr) when mask == 0; emits frz_rc (freeze_reason_cleared) for partial clears - emergency_unfreeze_holder delegates to clear_freeze_reason for backward compatibility - process_ofac_attestation ORs SanctionsMatch bit with legacy migration - Add get_holder_freeze_reasons query returning the raw bitmask - Update storage layout schema with HolderFreezeMask and pre-existing missing registrations - Add 10 comprehensive tests covering multi-reason freeze/clear, idempotency, event emission, and OFAC+manual interaction Co-authored-by: thlpkee20-wq <thlpkee20@gmail.com>
1 parent 1195682 commit 9c2e25c

3 files changed

Lines changed: 503 additions & 30 deletions

File tree

src/lib.rs

Lines changed: 224 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,9 @@ const EVENT_LOCKUP_VIOLATION: Symbol = symbol_short!("lock_viol");
625625
const EVENT_PLATFORM_FEE_SET: Symbol = symbol_short!("fee_set");
626626
const EVENT_FRZ_SET: Symbol = symbol_short!("frz_set");
627627
const 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.
629632
const 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)]
790810
pub 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, &current_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

Comments
 (0)