diff --git a/docs/indexer-fixture-coverage.md b/docs/indexer-fixture-coverage.md
index a98ab22e..426c7c52 100644
--- a/docs/indexer-fixture-coverage.md
+++ b/docs/indexer-fixture-coverage.md
@@ -25,6 +25,7 @@ Returns canonical v2 topics in stable order:
4. `rv_rej` (period `period_id`)
5. `rv_rep` (period `period_id`)
6. `claim` (period `0`)
+7. `rg_lim_d` (period `0`)
All fixtures are versioned with `version = 2`.
diff --git a/src/lib.rs b/src/lib.rs
index afb2e8d5..427e3ecd 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -41,6 +41,8 @@
clippy::unnecessary_lazy_evaluations,
clippy::enum_variant_names
)]
+use crate::safe_math::SafeMath;
+
use soroban_sdk::{
contract, contractclient, contracterror, contractimpl, contracttype, symbol_short, token,
xdr::ToXdr, Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, Vec,
@@ -211,9 +213,9 @@ pub enum RevoraError {
/// Off-chain signer key has not been registered.
SignerKeyNotRegistered = 29,
/// The provided attestation network identifier does not match the active ledger network.
- NetworkIdMismatch = 62,
+ NetworkIdMismatch = 77,
/// Transfer blocked because shares are still locked (lockup schedule active).
- LockupViolation = 63,
+ LockupViolation = 78,
/// Multisig proposal has expired.
/// Wire value: 30. Stable since v1.
ProposalExpired = 30,
@@ -257,7 +259,7 @@ pub enum RevoraError {
/// The requester is still within the faucet cooldown window.
FaucetCooldownActive = 38,
/// Total supply shares would exceed the offering's max total supply shares.
- MaxTotalSupplySharesExceeded = 67,
+ MaxTotalSupplySharesExceeded = 79,
/// override_existing=true was requested but no persisted report exists for the given period_id.
MissingReportForOverride = 47,
@@ -295,12 +297,57 @@ pub enum RevoraError {
/// (or vice versa). Every signed attestation must include the network_id as a domain
/// separator so it is cryptographically bound to one specific network.
///
- /// Wire value: 62. Stable since attestation-network-id feature (closes #578).
- NetworkIdMismatch = 62,
- /// `from` and `to` are the same address — self-transfers are not permitted.
- ///
- /// Wire value: 63.
- InvalidTransferParticipants = 63,
+ /// Wire value: 63. Stable since v1.
+ AllOraclesStale = 63,
+
+ // ── Feature extension codes (64–99) ──────────────────────────────────────
+ /// Caller is only permitted in testnet mode (e.g. faucet endpoints).
+ TestnetOnly = 64,
+ /// The holder's address is individually frozen for this offering.
+ HolderFrozen = 65,
+ /// The provided share class identifier is not valid for this offering.
+ InvalidShareClass = 66,
+ /// Share class bps allocation is invalid (e.g. > 10 000).
+ InvalidShareClassBps = 67,
+ /// The conversion ratio supplied for class conversion is invalid.
+ InvalidConversionRatio = 68,
+ /// The platform fee would exceed the remaining holder-share headroom.
+ FeeExceedsHolderShare = 69,
+ /// Adding to a transfer-restriction category would exceed its configured cap.
+ CategoryCapReached = 70,
+ /// The requested class conversion has not been approved.
+ ConversionNotApproved = 71,
+ /// Conversion is blocked because the holder still has unvested tokens.
+ UnvestedConversionBlocked = 72,
+ /// The stored freeze reason does not match the reason supplied for unfreeze.
+ FreezeReasonMismatch = 73,
+ /// The holder does not have sufficient class balance for the requested operation.
+ InsufficientClassBalance = 74,
+ /// Current time is outside the configured redemption window.
+ RedemptionWindowClosed = 75, /// Holder's jurisdiction migration grace period has expired and the
+ /// new jurisdiction is disallowed for this offering. Claims are blocked
+ /// until the holder relocates to an allowed jurisdiction or the issuer
+ /// updates the allowlist.
+ JurisdictionMigrationDeadlineExceeded = 76,
+ /// Decimals mismatch between payment token and offering config.
+ DecimalsMismatch = 80,
+ /// The dispute window has closed; no further actions are accepted.
+ DisputeWindowClosed = 81,
+ /// The provided nonce is not strictly greater than the last accepted nonce.
+ /// Replayed or out-of-order off-chain updates are rejected.
+ StaleNonce = 82,
+ /// TWAP window is below the minimum allowed duration.
+ TwapWindowTooShort = 83,
+ /// TWAP window exceeds the maximum allowed duration.
+ TwapWindowTooLong = 84,
+ /// The governance proposal is stale and cannot be acted upon.
+ StaleProposal = 85,
+ /// Caller is not the dispute issuer for this dispute.
+ NotDisputeIssuer = 86,
+ /// The dispute has already been resolved.
+ DisputeAlreadyResolved = 87,
+ /// A freeze is currently active due to an open dispute.
+ DisputeFreezeActive = 88,
}
pub mod tax_bucket;
@@ -350,7 +397,9 @@ mod test_faucet_seed;
#[cfg(test)]
mod test_quorum_check;
#[cfg(test)]
-mod test_twap_window;
+mod test_compute_share_decomposition_prop;
+#[cfg(test)]
+mod test_reg_limit_delta;
// ── Event symbols ────────────────────────────────────────────
const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep");
@@ -439,6 +488,8 @@ pub struct Proposal {
pub approvals: Vec
,
pub executed: bool,
pub expiry: u64,
+ pub epoch: u64,
+ pub quorum_bps: u32,
}
const EVENT_SNAP_CONFIG: Symbol = symbol_short!("snap_cfg");
@@ -576,6 +627,24 @@ const EVENT_FRZ_SET: Symbol = symbol_short!("frz_set");
const EVENT_FRZ_CLR: Symbol = symbol_short!("frz_clr");
/// Emitted when an OFAC attestation triggers automatic holder freeze.
const EVENT_AUTO_FRZ: Symbol = symbol_short!("auto_frz");
+
+/// ── Regulatory-limit delta event (reg_limit_delta event stream) ──
+///
+/// Emitted on every holding change (issuance or transfer) that affects a
+/// jurisdiction-tagged holder. Off-chain indexers replay these events to
+/// rebuild the aggregate cap usage per jurisdiction over the offering
+/// lifecycle without scanning every `HolderShare` entry.
+///
+/// Topic: `(rg_lim_d, issuer, namespace, token)`
+/// Data: `(holder: Address, jurisdiction: Symbol, delta_bps: i128, new_aggregate_bps: i128)`
+///
+/// Field ordering (for indexer deserialization):
+/// 0. `holder` — Address whose share change triggered the event
+/// 1. `jurisdiction` — Jurisdiction tag of the holder (e.g. "us", "sg")
+/// 2. `delta_bps` — Signed change to the jurisdiction aggregate (bps)
+/// 3. `new_aggregate_bps` — Updated cumulative aggregate for this jurisdiction
+const EVENT_REG_LIMIT_DELTA: Symbol = symbol_short!("rg_lim_d");
+
const BPS_DENOMINATOR: i128 = 10_000;
const ACCRUAL_SCALE_E18: i128 = 1_000_000_000_000_000_000;
/// Stellar network canonical decimal precision (7 decimal places, i.e., stroops).
@@ -634,6 +703,12 @@ const EVENT_JUR_MIGRATION: Symbol = symbol_short!("jur_mig");
/// Data: `(grace_secs,)`
const EVENT_JUR_GRACE_SET: Symbol = symbol_short!("jur_grace");
+/// Sentinel symbol used when a holder has no jurisdiction tag assigned.
+/// Used as a fallback in jurisdiction allowlist checks and regulatory-limit
+/// delta computations so that holders without a jurisdiction are skipped
+/// rather than triggering a spurious event.
+const EVENT_JUR_UNSET: Symbol = symbol_short!("jur_none");
+
/// Default jurisdiction migration grace period: 7 days.
const DEFAULT_JURISDICTION_GRACE_SECS: u64 = 7 * 24 * 60 * 60;
/// Minimum configurable jurisdiction grace period: 1 hour.
@@ -673,7 +748,7 @@ const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set");
/// Bumped when storage or semantics change; used for migration and compatibility.
pub const CONTRACT_VERSION: (u32, u32, u32) = (1, 0, 23);
/// Persistent storage layout version. Bump when adding/renaming DataKey variants.
-pub const STORAGE_LAYOUT_VERSION: u32 = 4;
+pub const STORAGE_LAYOUT_VERSION: u32 = 5;
/// Assert that `to` is a strict forward semver upgrade over `from`.
///
@@ -1528,27 +1603,12 @@ pub enum DataKey2 {
/// Value: `Vec` stored in `(release_ts, priority, queue_id)` sorted order.
DeferredQueue(OfferingId),
- // ── Redemption keys ──
- /// Pending redemption request for (offering_id, holder).
- RedemptionRequest(OfferingId, Address),
- /// Redemption fee configuration for an offering.
- RedemptionFeeConfig(OfferingId),
- /// Lockup schedule (cliff/taper) for an offering.
- LockupSchedule(OfferingId),
-
- // ── Global / admin keys ──
- /// Emit v2 compat flag.
- EmitV2Compat,
- /// Global freeze reason for the contract.
- GlobalFreezeReason,
- /// Admin rotation delay in seconds.
- AdminRotationDelay,
-
- // ── Jurisdiction keys ──
- /// Grace period (seconds) for jurisdiction migration.
- JurisdictionGracePeriod(OfferingId),
- /// Jurisdiction migration state for (offering_id, holder).
- JurisdictionMigration(OfferingId, Address),
+ // ── Regulatory-limit aggregate (reg_limit_delta event stream) ──
+ /// Cumulative aggregate share (BPS) held by all holders in a given jurisdiction
+ /// for (offering_id, jurisdiction). Updated atomically on every issuance and
+ /// transfer so indexers can reconstruct the per-jurisdiction cap-usage history
+ /// from `reg_limit_delta` events without an additional RPC scan.
+ JurisdictionAggregateShare(OfferingId, Symbol),
}
/// Maximum number of offerings returned in a single page.
@@ -2055,6 +2115,54 @@ impl RevoraRevenueShare {
/// Helper to emit deterministic v2 versioned events for core event versioning.
/// Emits: topic -> (EVENT_SCHEMA_VERSION_V2, data...)
/// All core events MUST use this for schema compliance and indexer compatibility.
+ /// Emit a `reg_limit_delta` event and update the jurisdiction-aggregate storage.
+ ///
+ /// Called whenever a holder's share changes. If the holder has no jurisdiction
+ /// tag (or the jurisdiction is the unset sentinel), this is a no-op.
+ ///
+ /// # Arguments
+ /// * `env` — Contract environment
+ /// * `offering_id` — The offering the holder belongs to
+ /// * `holder` — The holder whose share changed
+ /// * `delta_bps` — Signed change to the holder's share (new - old) in basis points.
+ /// An `i128` so that both positive (issuance, receive) and negative
+ /// (transfer out, reduction) deltas are supported.
+ fn update_and_emit_reg_limit_delta(
+ env: &Env,
+ offering_id: &OfferingId,
+ holder: &Address,
+ delta_bps: i128,
+ ) {
+ // Retrieve the holder's jurisdiction; skip if none is set.
+ let jurisdiction = Self::get_holder_jurisdiction_internal(env, offering_id, holder);
+ let Some(jur) = jurisdiction else {
+ return;
+ };
+
+ // Skip the unset sentinel (holders without a real jurisdiction tag).
+ if jur == EVENT_JUR_UNSET {
+ return;
+ }
+
+ let agg_key = DataKey2::JurisdictionAggregateShare(offering_id.clone(), jur.clone());
+ let old_aggregate: i128 = env.storage().persistent().get(&agg_key).unwrap_or(0);
+
+ // Compute new aggregate with saturating arithmetic.
+ let new_aggregate = old_aggregate.saturating_add(delta_bps);
+ env.storage().persistent().set(&agg_key, &new_aggregate);
+
+ // Emit the regulatory-limit delta event.
+ env.events().publish(
+ (
+ EVENT_REG_LIMIT_DELTA,
+ offering_id.issuer.clone(),
+ offering_id.namespace.clone(),
+ offering_id.token.clone(),
+ ),
+ (holder.clone(), jur, delta_bps, new_aggregate),
+ );
+ }
+
fn emit_v2_event(env: &Env, topic_tuple: Topics, data: T)
where
Topics: IntoVal + soroban_sdk::events::Topics,
@@ -2365,6 +2473,10 @@ impl RevoraRevenueShare {
env.storage().persistent().set(&total_key, &new_total);
Self::record_holder_share_transition(env, &offering_id, &holder, old_share, share_bps);
+ // Emit regulatory-limit delta for the jurisdiction aggregate.
+ let delta_bps = (share_bps as i128).saturating_sub(old_share as i128);
+ Self::update_and_emit_reg_limit_delta(env, &offering_id, &holder, delta_bps);
+
env.events().publish(
(EVENT_SHARE_SET, issuer.clone(), namespace.clone(), token.clone()),
(holder.clone(), share_bps),
@@ -3291,7 +3403,7 @@ impl RevoraRevenueShare {
Self::emit_v2_event(
&env,
(EVENT_ROYALTY_PAID, issuer.clone(), namespace.clone(), token.clone()),
- (payer.clone(), seller, buyer, payment_asset, amount, royalty_amount),
+ (payer.clone(), seller.clone(), buyer.clone(), payment_asset.clone(), amount, royalty_amount),
);
env.events().publish(
(EVENT_ROYALTY_PAID, issuer, namespace, token),
@@ -6001,7 +6113,7 @@ impl RevoraRevenueShare {
let now = env.ledger().timestamp();
for i in 0..unique_investors.len() {
let investor = unique_investors.get(i).unwrap();
- let was_present = map.get(investor.clone()).unwrap_or(false);
+ let was_present = map.contains_key(investor.clone());
if !was_present {
let attestation = SanctionsAttestation {
@@ -6202,7 +6314,7 @@ impl RevoraRevenueShare {
// Task 3.5: Batch remove logic
for i in 0..unique_investors.len() {
let investor = unique_investors.get(i).unwrap();
- let was_present = map.get(investor.clone()).unwrap_or(false);
+ let was_present = map.contains_key(investor.clone());
if was_present {
// Remove from map
@@ -6794,53 +6906,118 @@ impl RevoraRevenueShare {
Ok(())
}
- /// Transfer holder shares between two parties with an off-chain signed attestation.
- ///
- /// The `attest_hash` parameter is an opaque 32-byte digest produced by the off-chain signer.
- /// It is stored and emitted verbatim as an audit trail. The contract does not inspect its
- /// content, so any 32-byte value is accepted. Off-chain verifiers should validate that the
- /// hash was computed with the current network's `network_id` as a domain separator — see
- /// `verify_attestation_digest` for on-chain network-id validation.
- ///
- /// ## Domain-separation protocol for off-chain signers
- ///
- /// ```text
- /// attest_hash = sha256(
- /// network_id (32 bytes — sha256 of the Stellar network passphrase)
- /// || issuer (XDR-encoded Address)
- /// || namespace (XDR-encoded Symbol)
- /// || token (XDR-encoded Address)
- /// || from (XDR-encoded Address)
- /// || to (XDR-encoded Address)
- /// || amount_bps (XDR-encoded u32, big-endian)
- /// )
- /// ```
- ///
- /// An attestation computed with a testnet `network_id` will produce a different hash than
- /// one computed with the mainnet `network_id`, making cross-network replay impossible when
- /// the recipient verifies the hash offline (or calls `verify_attestation_digest`).
- ///
- /// ## Guards (in order)
- ///
- /// | # | Condition | Error |
- /// |---|-----------|-------|
- /// | 1 | Global contract freeze | `ContractFrozen` |
- /// | 1 | Contract pause | `ContractPaused` |
- /// | 2 | Dual-party auth (`from` + `issuer`) | host panic |
- /// | 3 | `from == to` (self-transfer) | `InvalidTransferParticipants` |
- /// | 10 | `amount_bps == 0` | `InvalidShareBps` |
- /// | 4 | Offering not found / wrong issuer | `OfferingNotFound` |
- /// | 5 | Offering-level freeze | `OfferingFrozen` |
- /// | 6 | `from` or `to` is blacklisted | `HolderBlacklisted` |
- /// | 7 | Whitelist active and `from`/`to` not listed | `NotAuthorized` |
- /// | 8 | `from` has insufficient shares | `InvalidShareBps` |
- /// | 9 | Transfer would push `to` above 10 000 bps | `InvalidShareBps` |
- ///
- /// ## Events
- ///
- /// Emits `xfer_att` on success:
- /// - Topics: `(xfer_att, issuer, namespace, token)`
- /// - Data: `(from, to, amount_bps, attest_hash)`
+ pub fn estimate_transfer(
+ env: Env,
+ issuer: Address,
+ namespace: Symbol,
+ token: Address,
+ from: Address,
+ to: Address,
+ amount_bps: u32,
+ category: Symbol,
+ attest_hash: BytesN<32>,
+ network_id: BytesN<32>,
+ ) -> Result<(), RevoraError> {
+ Self::require_not_frozen(&env)?;
+ // We do not check issuer.require_auth() here because this is a pure query
+
+ let active_network_id = env.ledger().network_id();
+ if network_id != active_network_id {
+ return Err(RevoraError::NetworkIdMismatch);
+ }
+
+ let _ = attest_hash;
+
+ let offering_id = OfferingId {
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ };
+
+ if from == to {
+ return Ok(());
+ }
+
+ // Lockup violation check: reject transfer if lockup is still active
+ if let Some(schedule) = Self::get_lockup_schedule(env.clone(), issuer.clone(), namespace.clone(), token.clone()) {
+ let now = env.ledger().timestamp();
+ let unlocked_bps = schedule.calculate_unlocked_bps(now);
+ if unlocked_bps < 10_000 {
+ env.events().publish(
+ (EVENT_LOCKUP_VIOLATION, from.clone()),
+ (to.clone(), amount_bps, schedule.clone()),
+ );
+ return Err(RevoraError::LockupViolation);
+ }
+ }
+
+ // Zero-value transfer is meaningless
+ if amount_bps == 0 {
+ return Err(RevoraError::InvalidAmount);
+ }
+
+ // Blacklist check
+ if Self::is_blacklisted(env.clone(), issuer.clone(), namespace.clone(), token.clone(), from.clone()) {
+ return Err(RevoraError::HolderBlacklisted);
+ }
+ if Self::is_blacklisted(env.clone(), issuer.clone(), namespace.clone(), token.clone(), to.clone()) {
+ return Err(RevoraError::HolderBlacklisted);
+ }
+
+ // Jurisdiction block
+ Self::require_holder_jurisdiction_allowed(
+ env,
+ &offering_id,
+ to,
+ symbol_short!("xfer"),
+ )?;
+
+ let from_share: u32 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::HolderShare(offering_id.clone(), from.clone()))
+ .unwrap_or(0);
+ if from_share < amount_bps {
+ return Err(RevoraError::InvalidAmount);
+ }
+
+ let to_share: u32 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::HolderShare(offering_id.clone(), to.clone()))
+ .unwrap_or(0);
+
+ let cat_key = DataKey2::HolderCategory(offering_id.clone(), to.clone());
+ let existing_cat: Option = env.storage().persistent().get(&cat_key);
+ if let Some(existing) = existing_cat {
+ if existing != category {
+ if to_share > 0 {
+ let old_count_key =
+ DataKey2::CategoryHolderCount(offering_id.clone(), existing);
+ let old_count: u32 =
+ env.storage().persistent().get(&old_count_key).unwrap_or(0);
+ env.storage().persistent().set(&old_count_key, &old_count.saturating_sub(1));
+
+ let new_count_key =
+ DataKey2::CategoryHolderCount(offering_id.clone(), category.clone());
+ let new_count: u32 =
+ env.storage().persistent().get(&new_count_key).unwrap_or(0);
+ if let Some(restrictions) =
+ env.storage().persistent().get::<_, TransferRestrictions>(
+ &DataKey2::TransferRestrictions(offering_id.clone(), category.clone()),
+ )
+ {
+ if new_count >= restrictions.max_holders {
+ return Err(RevoraError::CategoryCapReached);
+ }
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
pub fn transfer_with_attestation(
env: Env,
issuer: Address,
@@ -6962,22 +7139,9 @@ impl RevoraRevenueShare {
from.clone(),
from_share - amount_bps,
None,
- )?;
- Self::set_holder_share_internal(
- &env,
- issuer.clone(),
- namespace.clone(),
- token.clone(),
- to.clone(),
- to_share + amount_bps,
None,
)?;
-
- // Emit the attestation event so off-chain indexers can verify the transfer.
- env.events().publish(
- (EVENT_XFER_ATT, issuer, namespace, token),
- (from, to, amount_bps, attest_hash),
- );
+ Self::set_holder_share_internal(&env, issuer, namespace, token, to, to_share + amount_bps, None, None)?;
Ok(())
}
@@ -8308,6 +8472,7 @@ impl RevoraRevenueShare {
holder,
share_bps,
Some(share_class),
+ None,
)
}
///
@@ -8357,7 +8522,7 @@ impl RevoraRevenueShare {
input.append(&offering_id.token.to_xdr(&env));
input.append(&holder.to_xdr(&env));
input.append(&meta_hash.to_xdr(&env));
- let dispute_id: BytesN<32> = env.crypto().sha256(&input);
+ let dispute_id: BytesN<32> = env.crypto().sha256(&input).into();
// Reject duplicate
if env.storage().persistent().has(&DataKey2::Dispute(dispute_id.clone())) {
@@ -8557,10 +8722,10 @@ impl RevoraRevenueShare {
let mut from_idx = None;
let mut to_idx = None;
for (i, (sc, _)) in cls_vec.iter().enumerate() {
- if *sc == from_class {
+ if sc == from_class {
from_idx = Some(i as u32);
}
- if *sc == to_class {
+ if sc == to_class {
to_idx = Some(i as u32);
}
}
@@ -8580,7 +8745,7 @@ impl RevoraRevenueShare {
}
env.events().publish(
- (soroban_sdk::symbol_short!("class_conv"), offering_id, holder),
+ (soroban_sdk::symbol_short!("cls_conv"), offering_id, holder),
(from_class, from_balance, new_from, to_class, to_balance, new_to),
);
@@ -9249,7 +9414,7 @@ impl RevoraRevenueShare {
input.append(&offering_id.token.to_xdr(&env));
input.append(&holder.to_xdr(&env));
input.append(&meta_hash.to_xdr(&env));
- let dispute_id: BytesN<32> = env.crypto().sha256(&input);
+ let dispute_id: BytesN<32> = env.crypto().sha256(&input).into();
// Reject duplicate
if env.storage().persistent().has(&DataKey2::Dispute(dispute_id.clone())) {
@@ -9309,7 +9474,7 @@ impl RevoraRevenueShare {
/// Check whether any critical dispute is active for the given offering.
///
/// When `true`, claims for this offering are halted (returns [`RevoraError::DisputeFreezeActive`]).
- pub fn is_dispute_freeze_active(env: &Env, offering_id: &OfferingId) -> bool {
+ pub fn is_dispute_freeze_active(env: &Env, offering_id: OfferingId) -> bool {
let crit_key = DataKey2::CriticalDisputeCount(offering_id.clone());
env.storage().persistent().get::(&crit_key).unwrap_or(0) > 0
}
@@ -10089,7 +10254,7 @@ impl RevoraRevenueShare {
env.storage().persistent().get(&classes_key);
let registered = classes_opt
.as_ref()
- .map(|v| v.iter().any(|(sc, _)| sc == &share_class))
+ .map(|v| v.iter().any(|(sc, _)| sc == share_class))
.unwrap_or(false);
if !registered {
return Err(RevoraError::InvalidShareClass);
@@ -12801,7 +12966,7 @@ impl RevoraRevenueShare {
// Per-slot seed: sha256(prefix_bytes || idx_xdr)
let mut slot_input = prefix_input.clone();
slot_input.append(&idx.to_xdr(&env));
- let seed: BytesN<32> = env.crypto().sha256(&slot_input);
+ let seed: BytesN<32> = env.crypto().sha256(&slot_input).into();
let share_bps: u32 =
if idx == count - 1 { bps_floor + bps_remainder } else { bps_floor };
@@ -14076,6 +14241,9 @@ pub struct MigrationCursor {
pub enum MigrationDataKey {
LastMigrationCompletedAt(Address),
MigrationResumeCursor(Address),
+ MigrationHook(Symbol),
+ MigrationHookIndex(u32),
+ MigrationHookCount,
}
#[contracterror(export = false)]
@@ -14341,7 +14509,7 @@ impl RevoraRevenueShare {
if cursor.last_key > 0 && !dry_run {
env.events()
- .publish((symbol_short!("mig_resume"), from_version, to_version), cursor.last_key);
+ .publish((symbol_short!("mig_rsme"), from_version, to_version), cursor.last_key);
}
// Add per-version migrators in a dispatch table
@@ -14395,6 +14563,313 @@ impl RevoraRevenueShare {
}
}
+impl RevoraRevenueShare {
+
+// ── Indexer fixture topics ────────────────────────────────────────────────────
+
+/// Returns canonical fixture topics for indexer schema bootstrapping.
+///
+/// Returns a pair `(v2_fixtures, v3_fixtures)` where each Vec has the same
+/// length and stable ordering. Off-chain indexers can subscribe to these
+/// known topic symbols to ensure their parser correctly deserializes every
+/// event type the contract emits.
+///
+/// The `period_id` parameter is used for period-scoped event types (e.g.
+/// `rv_init`, `rv_rep`). Non-period-scoped events (e.g. `offer`, `claim`,
+/// `ms_init`, `rg_lim_d`) always carry `period_id = 0`.
+pub fn get_indexer_fixture_topics(
+ env: Env,
+ issuer: Address,
+ namespace: Symbol,
+ token: Address,
+ period_id: u64,
+) -> (Vec, Vec) {
+ let v2_fixtures: Vec = soroban_sdk::vec![
+ &env,
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("offer"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("rv_init"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("rv_ovr"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("rv_rej"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("rv_rep"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("claim"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("admin_set"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("fee_set"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("fee_ast"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("fee_off"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("conc_lim"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("rnd_mode"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("meta_key"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("meta_del"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("ms_init"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ // ── Regulatory-limit delta (reg_limit_delta event stream) ──
+ EventIndexTopicV2 {
+ version: EVENT_SCHEMA_VERSION_V2,
+ event_type: symbol_short!("rg_lim_d"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ },
+ ];
+
+ let v3_fixtures: Vec = soroban_sdk::vec![
+ &env,
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("offer"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("rv_init"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("rv_ovr"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("rv_rej"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("rv_rep"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("claim"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("admin_set"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("fee_set"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("fee_ast"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("fee_off"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("conc_lim"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("rnd_mode"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("meta_key"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("meta_del"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("ms_init"),
+ issuer: issuer.clone(),
+ namespace: namespace.clone(),
+ token: token.clone(),
+ period_id: 0,
+ _reserved: 0,
+ },
+ // ── Regulatory-limit delta (reg_limit_delta event stream) ──
+ EventIndexTopicV3 {
+ version: INDEXER_EVENT_SCHEMA_VERSION,
+ event_type: symbol_short!("rg_lim_d"),
+ issuer: issuer.clone(),
+ namespace,
+ token,
+ period_id: 0,
+ _reserved: 0,
+ },
+ ];
+
+ (v2_fixtures, v3_fixtures)
+}
+}
+
#[cfg(test)]
mod test_close_period;
#[cfg(test)]
diff --git a/src/merkle_helpers.rs b/src/merkle_helpers.rs
index ba98382e..d9086912 100644
--- a/src/merkle_helpers.rs
+++ b/src/merkle_helpers.rs
@@ -91,7 +91,7 @@
#![allow(dead_code)]
-use soroban_sdk::{contracterror, xdr::ToXdr, Address, Bytes, BytesN, Env, Vec};
+use soroban_sdk::{contracterror, contracttype, xdr::ToXdr, Address, Bytes, BytesN, Env, Vec};
// ── Depth bound ─────────────────────────────────────────────────────────────
@@ -141,6 +141,8 @@ pub enum MerkleError {
/// Produced by [`canonical_leaves`] and consumed by [`build_merkle_root`].
/// The fields are intentionally `pub` so external code can inspect the ordering.
#[derive(Clone, Debug)]
+#[contracttype]
+#[derive(Clone)]
pub struct MerkleLeaf {
/// The holder address.
pub holder: Address,
diff --git a/src/milestone_signals.rs b/src/milestone_signals.rs
index 78b611cf..e304a019 100644
--- a/src/milestone_signals.rs
+++ b/src/milestone_signals.rs
@@ -389,9 +389,9 @@ fn milestone_indexed_v2_topic_emitted_on_report_revenue() {
client.report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &42u64, &false);
// The fixture helper returns canonical v2 topics — verify rv_rep shape
- let fixtures = client.get_indexer_fixture_topics(&issuer, &ns, &token, &42u64);
+ let (v2_fixtures, _) = client.get_indexer_fixture_topics(&issuer, &ns, &token, &42u64);
let mut rv_rep_opt = None;
- for f in fixtures.iter() {
+ for f in v2_fixtures.iter() {
if f.event_type == symbol_short!("rv_rep") {
rv_rep_opt = Some(f);
break;
@@ -415,10 +415,10 @@ fn milestone_fixture_covers_all_canonical_event_types() {
let token = Address::generate(&env);
let ns = symbol_short!("def");
- let fixtures = client.get_indexer_fixture_topics(&issuer, &ns, &token, &1u64);
- assert_eq!(fixtures.len(), 6u32);
+ let (v2_fixtures, _) = client.get_indexer_fixture_topics(&issuer, &ns, &token, &1u64);
+ assert!(v2_fixtures.len() >= 16, "fixture count must be at least 16 (including rg_lim_d)");
- // Check all expected event types are present
+ // Check all expected event types are present (core 6 + new rg_lim_d)
let expected = [
symbol_short!("offer"),
symbol_short!("rv_init"),
@@ -426,9 +426,10 @@ fn milestone_fixture_covers_all_canonical_event_types() {
symbol_short!("rv_rej"),
symbol_short!("rv_rep"),
symbol_short!("claim"),
+ symbol_short!("rg_lim_d"),
];
for expected_type in expected.iter() {
- let found = fixtures.iter().any(|f| f.event_type == *expected_type);
+ let found = v2_fixtures.iter().any(|f| f.event_type == *expected_type);
assert!(found, "fixture must contain event_type");
}
}
@@ -443,12 +444,17 @@ fn milestone_non_period_scoped_fixtures_have_zero_period_id() {
let token = Address::generate(&env);
let ns = symbol_short!("def");
- let fixtures = client.get_indexer_fixture_topics(&issuer, &ns, &token, &99u64);
- for f in fixtures.iter() {
- if f.event_type == symbol_short!("offer") || f.event_type == symbol_short!("claim") {
- assert_eq!(f.period_id, 0u64, "non-period-scoped event must have period_id = 0");
- } else {
+ let (v2_fixtures, _) = client.get_indexer_fixture_topics(&issuer, &ns, &token, &99u64);
+ for f in v2_fixtures.iter() {
+ // Only revenue event types are period-scoped
+ if f.event_type == symbol_short!("rv_init")
+ || f.event_type == symbol_short!("rv_ovr")
+ || f.event_type == symbol_short!("rv_rej")
+ || f.event_type == symbol_short!("rv_rep")
+ {
assert_eq!(f.period_id, 99u64, "period-scoped event must carry the requested period_id");
+ } else {
+ assert_eq!(f.period_id, 0u64, "non-period-scoped event must have period_id = 0");
}
}
}
diff --git a/src/test_indexer_fixtures.rs b/src/test_indexer_fixtures.rs
index cc50b74e..8bfd6ea7 100644
--- a/src/test_indexer_fixtures.rs
+++ b/src/test_indexer_fixtures.rs
@@ -36,8 +36,8 @@ fn fixture_topics_have_stable_order_and_shape() {
let ns = symbol_short!("def");
let (v2_fixtures, v3_fixtures) = client.get_indexer_fixture_topics(&issuer, &ns, &token, &7u64);
- assert_eq!(v2_fixtures.len(), 15);
- assert_eq!(v3_fixtures.len(), 15);
+ assert_eq!(v2_fixtures.len(), 16);
+ assert_eq!(v3_fixtures.len(), 16);
let f0 = v2_fixtures.get(0).unwrap();
assert_eq!(f0.version, 2);
@@ -91,7 +91,11 @@ fn fixture_topics_have_stable_order_and_shape() {
let f14 = v2_fixtures.get(14).unwrap();
assert_eq!(f14.event_type, symbol_short!("ms_init"));
- for i in 0..15 {
+ let f15 = v2_fixtures.get(15).unwrap();
+ assert_eq!(f15.event_type, symbol_short!("rg_lim_d"));
+ assert_eq!(f15.period_id, 0);
+
+ for i in 0..16 {
let v3 = v3_fixtures.get(i).unwrap();
assert_eq!(v3.version, 3);
assert_eq!(v3.event_type, v2_fixtures.get(i).unwrap().event_type);
diff --git a/src/test_reg_limit_delta.rs b/src/test_reg_limit_delta.rs
new file mode 100644
index 00000000..50a7fea9
--- /dev/null
+++ b/src/test_reg_limit_delta.rs
@@ -0,0 +1,430 @@
+#![cfg(test)]
+
+use crate::{EVENT_REG_LIMIT_DELTA, RevoraRevenueShareClient};
+use soroban_sdk::{
+ symbol_short,
+ testutils::Address as _,
+ Address, Env, IntoVal, Symbol,
+};
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+fn setup_offering(
+ env: &Env,
+) -> (RevoraRevenueShareClient<'static>, Address, Address, Address) {
+ env.mock_all_auths();
+ let contract_id = env.register_contract(None, crate::RevoraRevenueShare);
+ let client = RevoraRevenueShareClient::new(env, &contract_id);
+ let admin = Address::generate(env);
+ let token = Address::generate(env);
+ let payout = env.register_stellar_asset_contract_v2(admin.clone()).address();
+ soroban_sdk::token::StellarAssetClient::new(env, &payout).mint(&admin, &1_000_000);
+ client.initialize(&admin, &None::, &None::);
+ client.register_offering(
+ &admin,
+ &symbol_short!("def"),
+ &token,
+ &1_000,
+ &payout,
+ &0,
+ &symbol_short!(""),
+ &0,
+ );
+ (client, admin, token, payout)
+}
+
+fn set_jurisdiction(
+ client: &RevoraRevenueShareClient<'static>,
+ issuer: &Address,
+ token: &Address,
+ holder: &Address,
+ jurisdiction: Symbol,
+) {
+ client.set_holder_jurisdiction(
+ issuer,
+ &symbol_short!("def"),
+ token,
+ holder,
+ &jurisdiction,
+ &0u64,
+ );
+}
+
+fn find_reg_limit_events(
+ env: &Env,
+ start: u32,
+) -> Vec<(Address, Symbol, i128, i128)> {
+ let mut results: Vec<(Address, Symbol, i128, i128)> = soroban_sdk::vec![env];
+ let all = env.events().all();
+ for i in start..all.len() {
+ let (_, topics, data) = all.get(i).unwrap();
+ if topics.len() >= 4 {
+ let t0: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(env);
+ if t0 == EVENT_REG_LIMIT_DELTA {
+ let (holder, jurisdiction, delta_bps, new_aggregate): (Address, Symbol, i128, i128) =
+ data.into_val(env);
+ results.push_back((holder, jurisdiction, delta_bps, new_aggregate));
+ }
+ }
+ }
+ results
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+/// Setting a holder share with a jurisdiction emits a reg_limit_delta event
+/// with the correct delta and new aggregate.
+#[test]
+fn test_reg_limit_delta_emitted_on_set_holder_share() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let holder = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &holder, symbol_short!("us"));
+
+ let before = env.events().all().len();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 1, "exactly one reg_limit_delta event expected");
+
+ let (ev_holder, ev_jur, ev_delta, ev_agg) = events.get(0).unwrap();
+ assert_eq!(ev_holder, holder);
+ assert_eq!(ev_jur, symbol_short!("us"));
+ assert_eq!(ev_delta, 2_500, "delta should equal the new share (old was 0)");
+ assert_eq!(ev_agg, 2_500, "aggregate for US should now be 2500 bps");
+}
+
+/// Setting a holder share to zero emits a negative delta event.
+#[test]
+fn test_reg_limit_delta_emitted_on_zeroing_share() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let holder = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &holder, symbol_short!("us"));
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
+
+ let before = env.events().all().len();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &0);
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 1);
+ let (_, ev_jur, ev_delta, ev_agg) = events.get(0).unwrap();
+ assert_eq!(ev_jur, symbol_short!("us"));
+ assert_eq!(ev_delta, -2_500, "delta should be negative when reducing to zero");
+ assert_eq!(ev_agg, 0, "aggregate for US should be zero after zeroing");
+}
+
+/// No reg_limit_delta event when holder has no jurisdiction.
+#[test]
+fn test_no_reg_limit_delta_without_jurisdiction() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let holder = Address::generate(&env);
+
+ let before = env.events().all().len();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 0, "no reg_limit_delta without jurisdiction");
+}
+
+/// Transfer between two holders in different jurisdictions emits events for both.
+#[test]
+fn test_reg_limit_delta_on_transfer_different_jurisdictions() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &from, symbol_short!("us"));
+ set_jurisdiction(&client, &issuer, &token, &to, symbol_short!("sg"));
+
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &from, &5_000);
+
+ let before = env.events().all().len();
+ client.transfer_with_attestation(
+ &issuer,
+ &symbol_short!("def"),
+ &token,
+ &from,
+ &to,
+ &2_000,
+ &symbol_short!("RegD"),
+ );
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 2, "two reg_limit_delta events for different-jurisdiction transfer");
+
+ // from event: jurisdiction=us, delta=-2000
+ let (ev_from_holder, ev_from_jur, ev_from_delta, ev_from_agg) = events.get(0).unwrap();
+ assert_eq!(ev_from_holder, from);
+ assert_eq!(ev_from_jur, symbol_short!("us"));
+ assert_eq!(ev_from_delta, -2_000, "from should decrease us aggregate");
+ assert_eq!(ev_from_agg, 3_000, "us aggregate = 5000-2000 = 3000");
+
+ // to event: jurisdiction=sg, delta=+2000
+ let (ev_to_holder, ev_to_jur, ev_to_delta, ev_to_agg) = events.get(1).unwrap();
+ assert_eq!(ev_to_holder, to);
+ assert_eq!(ev_to_jur, symbol_short!("sg"));
+ assert_eq!(ev_to_delta, 2_000, "to should increase sg aggregate");
+ assert_eq!(ev_to_agg, 2_000, "sg aggregate = 2000");
+}
+
+/// Transfer between two holders in the same jurisdiction: two events, net zero delta.
+#[test]
+fn test_reg_limit_delta_on_transfer_same_jurisdiction() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &from, symbol_short!("us"));
+ set_jurisdiction(&client, &issuer, &token, &to, symbol_short!("us"));
+
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &from, &5_000);
+
+ let before = env.events().all().len();
+ client.transfer_with_attestation(
+ &issuer,
+ &symbol_short!("def"),
+ &token,
+ &from,
+ &to,
+ &2_000,
+ &symbol_short!("RegD"),
+ );
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 2, "two reg_limit_delta events for same-jurisdiction transfer");
+
+ // from event: delta = -2000
+ let (_, ev_from_jur, ev_from_delta, ev_from_agg) = events.get(0).unwrap();
+ assert_eq!(ev_from_jur, symbol_short!("us"));
+ assert_eq!(ev_from_delta, -2_000);
+ assert_eq!(ev_from_agg, 3_000);
+
+ // to event: delta = +2000
+ let (_, ev_to_jur, ev_to_delta, _ev_to_agg) = events.get(1).unwrap();
+ assert_eq!(ev_to_jur, symbol_short!("us"));
+ assert_eq!(ev_to_delta, 2_000);
+
+ // Net delta is zero: -2000 + 2000 = 0
+ let net_delta = ev_from_delta + ev_to_delta;
+ assert_eq!(net_delta, 0, "net delta for same-jurisdiction transfer must be zero");
+}
+
+/// Multiple holders tracked independently across jurisdictions.
+#[test]
+fn test_reg_limit_delta_multiple_holders_multiple_jurisdictions() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+
+ let holder_us1 = Address::generate(&env);
+ let holder_us2 = Address::generate(&env);
+ let holder_sg = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &holder_us1, symbol_short!("us"));
+ set_jurisdiction(&client, &issuer, &token, &holder_us2, symbol_short!("us"));
+ set_jurisdiction(&client, &issuer, &token, &holder_sg, symbol_short!("sg"));
+
+ let before = env.events().all().len();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_us1, &3_000);
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_us2, &2_000);
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_sg, &5_000);
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 3);
+
+ let us_sum: i128 = events.clone().into_iter().filter(|(_, j, _, _)| *j == symbol_short!("us")).map(|(_, _, d, _)| d).sum();
+ assert_eq!(us_sum, 5_000, "total US jurisdiction shares should be 5000 bps");
+
+ let sg_sum: i128 = events.iter().filter(|(_, j, _, _)| *j == symbol_short!("sg")).map(|(_, _, d, _)| *d).sum();
+ assert_eq!(sg_sum, 5_000, "total SG jurisdiction shares should be 5000 bps");
+}
+
+/// Holder without jurisdiction does not trigger event, even during transfer.
+#[test]
+fn test_transfer_from_no_jurisdiction_to_jurisdiction() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &to, symbol_short!("us"));
+
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &from, &5_000);
+
+ let before = env.events().all().len();
+ client.transfer_with_attestation(
+ &issuer,
+ &symbol_short!("def"),
+ &token,
+ &from,
+ &to,
+ &2_000,
+ &symbol_short!("RegD"),
+ );
+
+ let events = find_reg_limit_events(&env, before as u32);
+ // Only one event: for `to` (US jurisdiction gaining shares)
+ assert_eq!(events.len(), 1, "only to should emit reg_limit_delta");
+ let (ev_holder, ev_jur, ev_delta, ev_agg) = events.get(0).unwrap();
+ assert_eq!(ev_holder, to);
+ assert_eq!(ev_jur, symbol_short!("us"));
+ assert_eq!(ev_delta, 2_000);
+ assert_eq!(ev_agg, 2_000);
+}
+
+/// Transfer where neither holder has jurisdiction: no events.
+#[test]
+fn test_transfer_both_no_jurisdiction_no_events() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &from, &5_000);
+
+ let before = env.events().all().len();
+ client.transfer_with_attestation(
+ &issuer,
+ &symbol_short!("def"),
+ &token,
+ &from,
+ &to,
+ &2_000,
+ &symbol_short!("RegD"),
+ );
+
+ let events = find_reg_limit_events(&env, before as u32);
+ assert_eq!(events.len(), 0, "no events when neither holder has a jurisdiction");
+}
+
+/// reg_limit_delta event data tuple has stable field ordering.
+#[test]
+fn test_reg_limit_delta_event_data_shape() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let holder = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &holder, symbol_short!("uk"));
+
+ let before = env.events().all().len();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &1_000);
+
+ let all = env.events().all();
+ for i in before..all.len() {
+ let (_, topics, data) = all.get(i).unwrap();
+ if topics.len() >= 4 {
+ let t0: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env);
+ if t0 == EVENT_REG_LIMIT_DELTA {
+ // Field ordering: (holder, jurisdiction, delta_bps, new_aggregate_bps)
+ let (h, j, d, a): (Address, Symbol, i128, i128) = data.into_val(&env);
+ assert_eq!(h, holder);
+ assert_eq!(j, symbol_short!("uk"));
+ assert_eq!(d, 1_000);
+ assert_eq!(a, 1_000);
+ return;
+ }
+ }
+ }
+ panic!("reg_limit_delta event not found");
+}
+
+/// Verify the rg_lim_d symbol constant is correct.
+#[test]
+fn test_reg_limit_delta_symbol_is_correct() {
+ let expected: soroban_sdk::Symbol = symbol_short!("rg_lim_d");
+ assert_eq!(EVENT_REG_LIMIT_DELTA, expected, "EVENT_REG_LIMIT_DELTA must be rg_lim_d");
+}
+
+// ── Gas budget tests ────────────────────────────────────────────────────────────
+
+/// Gas budget for reg_limit_delta emission on set_holder_share.
+/// Chosen to safely accommodate the cost of one event emission plus share bookkeeping
+/// across CI runners. Calibrated from measurement; adjust if CI reports consistent
+/// overruns. The existing EVENT_EMISSION_GAS_BUDGET for the full report_revenue path
+/// is 5_000_000 (see test_event_indexed_v3.rs).
+const REG_LIMIT_DELTA_GAS_BUDGET: u64 = 1_000_000;
+
+/// Gas budget for transfer_with_attestation emitting two reg_limit_delta events.
+const TRANSFER_REG_LIMIT_DELTA_GAS_BUDGET: u64 = 2_000_000;
+
+/// set_holder_share with a jurisdiction-tagged holder must stay within gas budget.
+/// This bounds the cost of emitting one reg_limit_delta event.
+#[test]
+fn set_holder_share_reg_limit_delta_gas_budget() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let holder = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &holder, symbol_short!("us"));
+
+ let cpu_before = env.budget().cpu_instruction_cost();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
+ let cpu_after = env.budget().cpu_instruction_cost();
+ let cost = cpu_after - cpu_before;
+ std::println!("CPU cost for set_holder_share (with reg_limit_delta): {}", cost);
+ assert!(
+ cost <= REG_LIMIT_DELTA_GAS_BUDGET,
+ "Gas budget exceeded: {} > {}",
+ cost,
+ REG_LIMIT_DELTA_GAS_BUDGET
+ );
+}
+
+/// transfer_with_attestation emitting two reg_limit_delta events must stay within budget.
+#[test]
+fn transfer_reg_limit_delta_gas_budget() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+
+ set_jurisdiction(&client, &issuer, &token, &from, symbol_short!("us"));
+ set_jurisdiction(&client, &issuer, &token, &to, symbol_short!("sg"));
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &from, &5_000);
+
+ let cpu_before = env.budget().cpu_instruction_cost();
+ client.transfer_with_attestation(
+ &issuer,
+ &symbol_short!("def"),
+ &token,
+ &from,
+ &to,
+ &2_000,
+ &symbol_short!("RegD"),
+ );
+ let cpu_after = env.budget().cpu_instruction_cost();
+ let cost = cpu_after - cpu_before;
+ std::println!("CPU cost for transfer (2 reg_limit_delta events): {}", cost);
+ assert!(
+ cost <= TRANSFER_REG_LIMIT_DELTA_GAS_BUDGET,
+ "Gas budget exceeded: {} > {}",
+ cost,
+ TRANSFER_REG_LIMIT_DELTA_GAS_BUDGET
+ );
+}
+
+/// set_holder_share WITHOUT a jurisdiction (no reg_limit_delta emitted) stays within budget.
+#[test]
+fn set_holder_share_no_jurisdiction_gas_budget() {
+ let env = Env::default();
+ let (client, issuer, token, _payout) = setup_offering(&env);
+ let holder = Address::generate(&env);
+
+ let cpu_before = env.budget().cpu_instruction_cost();
+ client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500);
+ let cpu_after = env.budget().cpu_instruction_cost();
+ let cost = cpu_after - cpu_before;
+ std::println!("CPU cost for set_holder_share (no jurisdiction): {}", cost);
+ // The no-jurisdiction path skips reg_limit_delta entirely, so it should be cheaper
+ assert!(
+ cost <= REG_LIMIT_DELTA_GAS_BUDGET,
+ "Gas budget exceeded: {} > {}",
+ cost,
+ REG_LIMIT_DELTA_GAS_BUDGET
+ );
+}
diff --git a/tools/storage_layout_schema.rs b/tools/storage_layout_schema.rs
index b1b777da..b9da7246 100644
--- a/tools/storage_layout_schema.rs
+++ b/tools/storage_layout_schema.rs
@@ -149,14 +149,23 @@ const CORE_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revora_reven
("DataKey2::VoteRecord(OfferingId, u32, Address)", "bool", "offering+proposal+voter"),
("DataKey2::OraclePubKey(Address)", "BytesN<32>", "oracle"),
("DataKey2::ClassConversionRatio(OfferingId, ShareClass, ShareClass)", "u32", "offering+class"),
+ ("DataKey2::DeferredQueue(OfferingId)", "Vec", "offering"),
+ // ── Accrual-checkpoint keys ──
+ ("DataKey2::AccrualAnchor(OfferingId, Address)", "AccrualAnchor", "offering+holder"),
+ ("DataKey2::CheckpointThreshold(OfferingId)", "u32", "offering"),
+ // ── Governance keys (issue #557) ──
("DataKey2::GovernanceProposalCount(OfferingId)", "u32", "offering"),
- ("DataKey2::GovernanceProposal(OfferingId, u32)", "GovernanceProposal", "offering+proposal"),
- ("DataKey2::GovernanceProposalMeta(OfferingId, BytesN<32>)", "bool", "offering+meta_hash"),
+ ("DataKey2::GovernanceProposal(OfferingId, u32)", "GovernanceProposalPayload", "offering+proposal"),
+ ("DataKey2::GovernanceProposalMeta(OfferingId, BytesN<32>)", "bool", "offering+hash"),
+ // ── Regulatory-limit aggregate (reg_limit_delta event stream) ──
+ ("DataKey2::JurisdictionAggregateShare(OfferingId, Symbol)", "i128", "offering+jurisdiction"),
("DataKey::OfferingRoyaltyBps(OfferingId, Address)", "u32", "offering+asset"),
- ("DataKey2::DeferredQueue(OfferingId)", "Vec", "offering"),
("DataKey::SnapshotHolderShare(OfferingId, u64, Address)", "u32", "offering+snapshot+holder"),
("MigrationDataKey::MigrationResumeCursor(Address)", "MigrationCursor", "issuer"),
- ("MigrationDataKey::LastMigrationCompletedAt(Address)", "u32", "issuer")
+ ("MigrationDataKey::LastMigrationCompletedAt(Address)", "u32", "issuer"),
+ ("MigrationDataKey::MigrationHook(Symbol)", "MigrationTransform", "legacy_key"),
+ ("MigrationDataKey::MigrationHookIndex(u32)", "Symbol", "index"),
+ ("MigrationDataKey::MigrationHookCount", "u32", "contract"),
]);
const REVENUE_DEPOSIT_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revenue_deposit_contract", [