From 58b5d1ad7d9942908778bde713388bc137007b08 Mon Sep 17 00:00:00 2001 From: believetimothy Date: Wed, 29 Jul 2026 18:17:09 +0000 Subject: [PATCH] feat: scoped-unfreeze by reason bitmask (#607) - 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 --- src/lib.rs | 254 +++++++++++++++++++++++++++---- src/test_freeze_matrix.rs | 270 +++++++++++++++++++++++++++++++++ tools/storage_layout_schema.rs | 9 ++ 3 files changed, 503 insertions(+), 30 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ff21f53a..261c2feb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -606,6 +606,9 @@ const EVENT_LOCKUP_VIOLATION: Symbol = symbol_short!("lock_viol"); const EVENT_PLATFORM_FEE_SET: Symbol = symbol_short!("fee_set"); const EVENT_FRZ_SET: Symbol = symbol_short!("frz_set"); const EVENT_FRZ_CLR: Symbol = symbol_short!("frz_clr"); +/// Emitted when a freeze reason is cleared but other reasons remain active. +/// Data: `(holder, cleared_reason, remaining_mask)` +const EVENT_FREEZE_REASON_CLEARED: Symbol = symbol_short!("frz_rc"); /// Emitted when an OFAC attestation triggers automatic holder freeze. const EVENT_AUTO_FRZ: Symbol = symbol_short!("auto_frz"); const BPS_DENOMINATOR: i128 = 10_000; @@ -742,22 +745,46 @@ pub struct TenantId { pub namespace: Symbol, } +/// Each variant is assigned a stable u32 discriminant for bitmask operations. +/// +/// # Bitmask contract (do not reorder or insert) +/// +/// | Variant | Bit | Mask Value | +/// |----------------|------|------------| +/// | Compliance | 0 | 1 | +/// | LegalHold | 1 | 2 | +/// | DisputeOpen | 2 | 4 | +/// | SanctionsMatch | 3 | 8 | +/// | Sanctions | 4 | 16 | +/// | CourtOrder | 5 | 32 | +/// | IssuerDispute | 6 | 64 | +/// | Manual | 7 | 128 | +/// +/// Do NOT exceed 32 variants; bitmask operations assume u32 width. #[contracttype] #[derive(Clone, Debug, PartialEq, Eq, Copy)] +#[repr(u32)] pub enum FreezeReason { /// Broad compliance or regulatory action. - Compliance, + Compliance = 0, /// Court-ordered legal hold. - LegalHold, + LegalHold = 1, /// Active dispute under investigation. - DisputeOpen, + DisputeOpen = 2, /// Address matched on a sanctions list. - SanctionsMatch, + SanctionsMatch = 3, // Legacy variants kept for storage compatibility. - Sanctions, - CourtOrder, - IssuerDispute, - Manual, + Sanctions = 4, + CourtOrder = 5, + IssuerDispute = 6, + Manual = 7, +} + +impl FreezeReason { + /// Convert this reason to its single-bit mask. + pub fn to_bitmask(self) -> u32 { + 1u32 << (self as u32) + } } /// Outcome of a dispute resolution (#593). @@ -1502,7 +1529,17 @@ pub enum DataKey2 { /// Per-category holder count for transfer restriction accounting. CategoryHolderCount(OfferingId, Symbol), /// Emergency freeze record for (offering_id, holder). + /// + /// **Deprecated** — new code uses `HolderFreezeMask` which stores a u32 + /// bitmask for multi-reason freeze support (#607). This key remains for + /// backward-compatible reads only. EmergencyFreeze(OfferingId, Address), + /// Holder freeze reason bitmask for (offering_id, holder). + /// + /// Stores a `u32` where each bit corresponds to an active [`FreezeReason`]. + /// A value of `0` is treated as "not frozen" and the key SHOULD be removed + /// when the mask clears entirely. See [`FreezeReason::to_bitmask`]. + HolderFreezeMask(OfferingId, Address), /// Total shares issued for an offering (tracks against MaxTotalSupplyShares). TotalSharesIssued(OfferingId), /// Maximum total supply shares cap for an offering. @@ -1956,13 +1993,24 @@ impl RevoraRevenueShare { /// Check if a holder is emergency frozen for an offering. fn is_frozen(env: &Env, offering_id: &OfferingId, holder: &Address) -> bool { - env.storage() + // Read bitmask from the new key first; fall back to legacy key for + // backward-compatible reads during migration. + let mask: u32 = env + .storage() .persistent() - .get::(&DataKey2::EmergencyFreeze( - offering_id.clone(), - holder.clone(), - )) - .is_some() + .get(&DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone())) + .unwrap_or_else(|| { + // Legacy path: single-reason freeze stored under EmergencyFreeze. + env.storage() + .persistent() + .get::(&DataKey2::EmergencyFreeze( + offering_id.clone(), + holder.clone(), + )) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }); + mask != 0 } /// Require that a holder is not emergency frozen. @@ -11926,8 +11974,31 @@ impl RevoraRevenueShare { return Err(RevoraError::NotAuthorized); } - let key = DataKey2::EmergencyFreeze(offering_id, holder.clone()); - env.storage().persistent().set(&key, &reason); + // Read existing bitmask (migrating legacy single-reason key on read). + let key = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()); + let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()); + let mut current_mask: u32 = env.storage().persistent().get(&key).unwrap_or_else(|| { + env.storage() + .persistent() + .get::(&legacy_key) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }); + + let reason_bit = reason.to_bitmask(); + if current_mask & reason_bit != 0 { + // Reason already active — idempotent no-op (do not emit duplicate event). + return Ok(()); + } + + current_mask |= reason_bit; + + // Clean up legacy key if present. + if env.storage().persistent().has(&legacy_key) { + env.storage().persistent().remove(&legacy_key); + } + + env.storage().persistent().set(&key, ¤t_mask); env.events().publish((EVENT_FRZ_SET, issuer, namespace, token), (caller, holder, reason)); Ok(()) } @@ -12039,7 +12110,25 @@ impl RevoraRevenueShare { /// /// Security posture: /// - Requires the exact same `reason` that was used to freeze the holder. - pub fn emergency_unfreeze_holder( + /// Clear a single freeze reason from a holder's bitmask (#607). + /// + /// Only the specific reason bit is cleared. If other reasons remain active + /// the holder stays frozen. When ALL reasons have been cleared (mask == 0) + /// the holder is fully unfrozen and [`EVENT_FRZ_CLR`] is emitted. + /// + /// # Authorization + /// Same as [`emergency_freeze_holder`]: caller must be the offering's current + /// issuer or the contract admin. + /// + /// # Errors + /// | Error | Condition | + /// |---|---| + /// | `ContractFrozen` | Contract is globally frozen | + /// | `OfferingNotFound` | No offering matches (issuer, namespace, token) | + /// | `NotAuthorized` | Caller is neither issuer nor admin | + /// | `HolderFrozen` | Holder has no active freeze record | + /// | `FreezeReasonMismatch` | The requested reason is not currently set | + pub fn clear_freeze_reason( env: Env, caller: Address, issuer: Address, @@ -12066,18 +12155,68 @@ impl RevoraRevenueShare { return Err(RevoraError::NotAuthorized); } - let key = DataKey2::EmergencyFreeze(offering_id, holder.clone()); - let stored_reason: FreezeReason = - env.storage().persistent().get(&key).ok_or(RevoraError::HolderFrozen)?; - if stored_reason != reason { + let key = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()); + let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()); + + // Read current mask; migrate legacy single-reason key on read. + let current_mask: u32 = env.storage().persistent().get(&key).unwrap_or_else(|| { + env.storage() + .persistent() + .get::(&legacy_key) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }); + + if current_mask == 0 { + return Err(RevoraError::HolderFrozen); + } + + let reason_bit = reason.to_bitmask(); + if current_mask & reason_bit == 0 { return Err(RevoraError::FreezeReasonMismatch); } - env.storage().persistent().remove(&key); - env.events().publish((EVENT_FRZ_CLR, issuer, namespace, token), (caller, holder, reason)); + let new_mask = current_mask & !reason_bit; + + // Clean up legacy key if present. + if env.storage().persistent().has(&legacy_key) { + env.storage().persistent().remove(&legacy_key); + } + + if new_mask == 0 { + // All reasons cleared — full unfreeze. + env.storage().persistent().remove(&key); + env.events().publish( + (EVENT_FRZ_CLR, issuer, namespace, token), + (caller, holder, reason), + ); + } else { + // Partial unfreeze — update mask and emit scoped event. + env.storage().persistent().set(&key, &new_mask); + env.events().publish( + (EVENT_FREEZE_REASON_CLEARED, issuer, namespace, token), + (caller, holder, reason, new_mask), + ); + } + Ok(()) } + pub fn emergency_unfreeze_holder( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + reason: FreezeReason, + ) -> Result<(), RevoraError> { + // Delegates to clear_freeze_reason (#607 reason-scoped unfreeze). + // When only one reason is active, this results in a full unfreeze. + // When multiple reasons are active, only the specified reason is cleared. + Self::clear_freeze_reason(env, caller, issuer, namespace, token, holder, reason) + } + /// Return true if a holder is emergency frozen for an offering. pub fn is_holder_frozen( env: Env, @@ -12086,11 +12225,50 @@ impl RevoraRevenueShare { token: Address, holder: Address, ) -> bool { + let offering_id = OfferingId { issuer, namespace, token }; + // Read bitmask from the new key first; fall back to legacy key. + let mask: u32 = env + .storage() + .persistent() + .get(&DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone())) + .unwrap_or_else(|| { + env.storage() + .persistent() + .get::(&DataKey2::EmergencyFreeze( + offering_id, + holder, + )) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }); + mask != 0 + } + + /// Return the raw freeze reason bitmask for a holder on an offering. + /// + /// Returns `0` when no freeze is active. Callers can test individual + /// reasons with `(mask & reason.to_bitmask()) != 0`. + pub fn get_holder_freeze_reasons( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> u32 { let offering_id = OfferingId { issuer, namespace, token }; env.storage() .persistent() - .get::(&DataKey2::EmergencyFreeze(offering_id, holder)) - .is_some() + .get(&DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone())) + .unwrap_or_else(|| { + env.storage() + .persistent() + .get::(&DataKey2::EmergencyFreeze( + offering_id, + holder, + )) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }) } /// Get a dispute entry by its ID. @@ -12154,12 +12332,28 @@ impl RevoraRevenueShare { token: token.clone(), }; - // Freeze the holder with SanctionsMatch reason + // Freeze the holder with SanctionsMatch reason (OR into bitmask). if !Self::is_event_only(&env) { - env.storage().persistent().set( - &DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()), - &FreezeReason::SanctionsMatch, - ); + let fk = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()); + let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()); + + // Read existing mask, migrating legacy key if present. + let current_mask: u32 = env.storage().persistent().get(&fk).unwrap_or_else(|| { + env.storage() + .persistent() + .get::(&legacy_key) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }); + + // Clean up legacy key if present. + if env.storage().persistent().has(&legacy_key) { + env.storage().persistent().remove(&legacy_key); + } + + env.storage() + .persistent() + .set(&fk, &(current_mask | FreezeReason::SanctionsMatch.to_bitmask())); } // Emit structured event for audit trail diff --git a/src/test_freeze_matrix.rs b/src/test_freeze_matrix.rs index 1f6f2d2b..13f2b052 100644 --- a/src/test_freeze_matrix.rs +++ b/src/test_freeze_matrix.rs @@ -1016,3 +1016,273 @@ fn ofac_attestation_uses_sanctions_match_reason() { assert!(result.is_ok()); assert!(!client.is_holder_frozen(&issuer, &ns, &token, &holder)); } + +// ─── #607 Reason-scoped unfreeze tests ───────────────────────────────────── + +/// When two freeze reasons are active, clearing only one should leave the +/// holder frozen. +#[test] +fn multi_reason_clear_one_leaves_holder_frozen() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + // Freeze with two reasons + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); + + // Verify bitmask contains both reasons + let mask = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + assert!(mask & FreezeReason::CourtOrder.to_bitmask() != 0); + assert!(mask & FreezeReason::Manual.to_bitmask() != 0); + + // Clear only CourtOrder — holder must stay frozen + client.clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); + + // Verify only Manual remains + let mask = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + assert_eq!(mask, FreezeReason::Manual.to_bitmask()); + + // Clear Manual — now fully unfrozen + client.clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + assert!(!client.is_holder_frozen(&issuer, &ns, &token, &holder)); + let mask = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + assert_eq!(mask, 0); + + // Claim should now work + let payout = client.claim(&holder, &issuer, &ns, &token, &10); + assert!(payout > 0); +} + +/// Clearing a reason that is not currently set must return +/// `FreezeReasonMismatch` and leave the state unchanged. +#[test] +fn clear_unset_reason_returns_freeze_reason_mismatch() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + // Freeze with only CourtOrder + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + let mask_before = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + + // Try to clear Manual (not set) + let result = client.try_clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + assert!(result.is_err()); + + // State must be unchanged + assert_eq!( + client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder), + mask_before + ); + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); +} + +/// `clear_freeze_reason` must emit `freeze_reason_cleared` (frz_rc) when +/// clearing a reason that does NOT result in full unfreeze. +#[test] +fn clear_freeze_reason_emits_frz_rc_event_for_partial() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + // Freeze with two reasons + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Sanctions, + ); + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::IssuerDispute, + ); + + let before = env.events().all().len(); + client.clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Sanctions, + ); + + // frz_rc must be emitted (not frz_clr since IssuerDispute still active) + let events = env.events().all(); + let frz_rc_sym = symbol_short!("frz_rc"); + let mut found_rc = false; + let mut found_clr = false; + for i in before..events.len() { + let (_, topics, _) = events.get(i).unwrap(); + let topics_vec: soroban_sdk::Vec = topics.clone().into_val(&env); + let t0: Symbol = topics_vec.get(0).unwrap().into_val(&env); + if t0 == frz_rc_sym { + found_rc = true; + } + if t0 == symbol_short!("frz_clr") { + found_clr = true; + } + } + assert!(found_rc, "expected frz_rc event for partial clear"); + assert!(!found_clr, "frz_clr must not fire while other reasons remain"); +} + +/// When the LAST freeze reason is cleared, `frz_clr` must be emitted. +#[test] +fn clear_freeze_reason_emits_frz_clr_on_full_unfreeze() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + + let before = env.events().all().len(); + client.clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + + let events = env.events().all(); + let frz_clr_sym = symbol_short!("frz_clr"); + let found = events.iter().skip(before).any(|e| { + let (_, topics, _) = e; + let topics_vec: soroban_sdk::Vec = topics.clone().into_val(&env); + topics_vec.len() >= 1 && { + let t0: Symbol = topics_vec.get(0).unwrap().into_val(&env); + t0 == frz_clr_sym + } + }); + assert!(found, "expected frz_clr event when last reason cleared"); +} + +/// Freezing with the same reason twice must be idempotent — no duplicate +/// events and the mask unchanged. +#[test] +fn freeze_same_reason_twice_is_idempotent() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Compliance, + ); + let mask_after_first = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + let events_after_first = env.events().all().len(); + + // Second call with same reason + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Compliance, + ); + let mask_after_second = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + + assert_eq!(mask_after_first, mask_after_second, "mask must not change on duplicate"); + assert_eq!( + env.events().all().len(), + events_after_first, + "no new events must be emitted for duplicate freeze" + ); + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); +} + +/// OFAC attestation combined with a manual freeze should produce a bitmask +/// with both reasons OR'd together. +#[test] +fn ofac_attestation_with_existing_freeze_combines_bitmask() { + let env = Env::default(); + let (client, _admin, issuer, token) = ofac_setup(&env); + let holder = Address::generate(&env); + let ns = symbol_short!("ofac"); + + client.set_holder_share(&issuer, &ns, &token, &holder, &1_000); + + // First, manual freeze with CourtOrder + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + + // Then OFAC attestation arrives + let attestation_hash = BytesN::from_array(&env, &[0x08u8; 32]); + client.process_ofac_attestation(&attestation_hash, &issuer, &ns, &token, &holder); + + // Both reasons must be present in the bitmask + let mask = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + assert!(mask & FreezeReason::CourtOrder.to_bitmask() != 0, "CourtOrder missing"); + assert!(mask & FreezeReason::SanctionsMatch.to_bitmask() != 0, "SanctionsMatch missing"); + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); + + // Clearing ONLY CourtOrder leaves SanctionsMatch active + client.clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); + + // Clearing SanctionsMatch fully unfreezes + client.clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::SanctionsMatch, + ); + assert!(!client.is_holder_frozen(&issuer, &ns, &token, &holder)); +} + +/// Calling `clear_freeze_reason` on a holder with no freeze record must return +/// `HolderFrozen` (the same error as trying to unfreeze an unfrozen holder). +#[test] +fn clear_freeze_reason_on_unfrozen_holder_returns_holder_frozen() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + // Holder is not frozen + assert!(!client.is_holder_frozen(&issuer, &ns, &token, &holder)); + + let result = client.try_clear_freeze_reason( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + assert!(result.is_err()); +} + +/// `get_holder_freeze_reasons` returns 0 when no freeze is active. +#[test] +fn get_holder_freeze_reasons_returns_zero_when_not_frozen() { + let (env, client, _, ns, token, issuer, holder) = setup(); + assert_eq!(client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder), 0); +} + +/// `emergency_unfreeze_holder` (deprecated) delegates to `clear_freeze_reason` +/// and works correctly for backward compatibility. +#[test] +fn emergency_unfreeze_holder_delegates_to_clear_freeze_reason() { + let (env, client, _, ns, token, issuer, holder) = setup(); + + // Freeze with two reasons + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + client.emergency_freeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::Manual, + ); + + // emergency_unfreeze_holder clears only one reason + client.emergency_unfreeze_holder( + &issuer, &issuer, &ns, &token, &holder, + &FreezeReason::CourtOrder, + ); + + // Holder still frozen because Manual remains + assert!(client.is_holder_frozen(&issuer, &ns, &token, &holder)); + let mask = client.get_holder_freeze_reasons(&issuer, &ns, &token, &holder); + assert_eq!(mask, FreezeReason::Manual.to_bitmask()); +} diff --git a/tools/storage_layout_schema.rs b/tools/storage_layout_schema.rs index b1b777da..605b9570 100644 --- a/tools/storage_layout_schema.rs +++ b/tools/storage_layout_schema.rs @@ -138,6 +138,15 @@ const CORE_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revora_reven ("DataKey2::CategoryHolderCount(OfferingId, Symbol)", "u32", "offering+category"), ("DataKey2::CheckpointThreshold(OfferingId)", "u32", "offering"), ("DataKey2::EmergencyFreeze(OfferingId, Address)", "bool", "offering+holder"), + ("DataKey2::HolderFreezeMask(OfferingId, Address)", "u32", "offering+holder"), + ("DataKey2::EmitV2Compat", "bool", "contract"), + ("DataKey2::GlobalFreezeReason", "FreezeReason", "contract"), + ("DataKey2::AdminRotationDelay", "u64", "contract"), + ("DataKey2::JurisdictionGracePeriod(OfferingId)", "u64", "offering"), + ("DataKey2::JurisdictionMigration(OfferingId, Address)", "JurisdictionMigrationState", "offering+holder"), + ("DataKey2::RedemptionRequest(OfferingId, Address)", "PendingRedemption", "offering+holder"), + ("DataKey2::RedemptionFeeConfig(OfferingId)", "RedemptionFeeConfig", "offering"), + ("DataKey2::LockupSchedule(OfferingId)", "LockupSchedule", "offering"), ("DataKey2::TotalSharesIssued(OfferingId)", "u32", "offering"), ("DataKey2::MaxTotalSupplyShares(OfferingId)", "u32", "offering"), ("DataKey2::FaucetSeedEntry(OfferingId, u32)", "Address", "offering+index"),