diff --git a/src/lib.rs b/src/lib.rs index 88a4e178..d29048fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,26 +28,29 @@ // points where the Soroban ABI requires all parameters to be explicit. This is // intentional and reviewed per-function, not suppressed globally. #![allow( - clippy::dbg_macro, - clippy::todo, - clippy::unimplemented, - clippy::panic, - clippy::unwrap_used, - clippy::expect_used, - clippy::wildcard_imports, - clippy::manual_let_else, - clippy::empty_line_after_doc_comments, - clippy::doc_lazy_continuation, - clippy::unnecessary_lazy_evaluations, - clippy::enum_variant_names + clippy::dbg_macro, + clippy::todo, + clippy::unimplemented, + clippy::panic, + clippy::unwrap_used, + clippy::expect_used, + clippy::wildcard_imports, + clippy::manual_let_else, + clippy::empty_line_after_doc_comments, + clippy::doc_lazy_continuation, + 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, + contract, contractclient, contracterror, contractimpl, contracttype, + symbol_short, token, xdr::ToXdr, Address, Bytes, BytesN, Env, IntoVal, Map, + Symbol, Vec, }; +const EVENT_SUPPLY_CAP_SATURATED: Symbol = symbol_short!("cap_sat"); + /// Cross-contract client trait for FX oracle integrations. /// /// Any oracle contract that implements this trait can be used in `set_fx_oracle` @@ -56,7 +59,7 @@ use soroban_sdk::{ /// in basis points (i.e. `1 unit of from == rate_bps/10_000 units of to`). #[contractclient(name = "FxOracleClient")] pub trait FxOracle { - fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64); + fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64); } /// A single entry in an oracle fallback chain. @@ -67,15 +70,15 @@ pub trait FxOracle { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct OracleEntry { - /// On-chain address of the oracle contract. - pub oracle: Address, - /// Symbol of the revenue / source currency (e.g. `EUR`). - pub revenue_symbol: Symbol, - /// Symbol of the payout / target currency (e.g. `USDC`). - pub payout_symbol: Symbol, - /// Maximum age in seconds before a quote is considered stale. - /// `0` disables the age check for this entry. - pub max_age_secs: u64, + /// On-chain address of the oracle contract. + pub oracle: Address, + /// Symbol of the revenue / source currency (e.g. `EUR`). + pub revenue_symbol: Symbol, + /// Symbol of the payout / target currency (e.g. `USDC`). + pub payout_symbol: Symbol, + /// Maximum age in seconds before a quote is considered stale. + /// `0` disables the age check for this entry. + pub max_age_secs: u64, } /// Ordered list of oracle fallback entries stored per offering. @@ -88,19 +91,19 @@ pub struct OracleEntry { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct OracleChain { - pub entries: Vec, + pub entries: Vec, } #[soroban_sdk::contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum DistributionError { - DistributionDeferred = 1, + DistributionDeferred = 1, } #[soroban_sdk::contracttype] pub enum DeferredDataKey { - DeferredReports(u32), + DeferredReports(u32), } /// A single entry in the deferred-distribution priority queue (#551). @@ -115,14 +118,14 @@ pub enum DeferredDataKey { #[soroban_sdk::contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeferredQueueEntry { - /// Unix timestamp at or after which this entry may be released. - pub release_ts: u64, - /// Issuer-assigned priority score; lower value = processed first. - pub priority: u32, - /// Monotonically-increasing per-offering insertion counter (tie-breaker). - pub queue_id: u32, - /// Arbitrary issuer-defined payload identifier (e.g. period_id). - pub payload_id: u64, + /// Unix timestamp at or after which this entry may be released. + pub release_ts: u64, + /// Issuer-assigned priority score; lower value = processed first. + pub priority: u32, + /// Monotonically-increasing per-offering insertion counter (tie-breaker). + pub queue_id: u32, + /// Arbitrary issuer-defined payload identifier (e.g. period_id). + pub payload_id: u64, } // Issue #109 — Revenue report correction and audit-summary reconciliation are @@ -146,215 +149,215 @@ pub struct DeferredQueueEntry { #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[repr(u32)] pub enum RevoraError { - /// revenue_share_bps exceeded 10000 (100%). - InvalidRevenueShareBps = 1, - /// Reserved / generic limit guard (e.g. offering limit per issuer, threshold out of range). - LimitReached = 2, - /// Holder concentration exceeds configured limit and enforcement is enabled. - ConcentrationLimitExceeded = 3, - /// No offering found for the given (issuer, token) pair. - OfferingNotFound = 4, - /// Revenue already deposited for this period. - PeriodAlreadyDeposited = 5, - /// No unclaimed periods for this holder. - NoPendingClaims = 6, - /// Holder is blacklisted for this offering. - HolderBlacklisted = 7, - /// Holder share_bps exceeded 10000 (100%). - InvalidShareBps = 8, - /// Payment token does not match previously set token for this offering. - PaymentTokenMismatch = 9, - /// Contract is frozen; state-changing operations are disabled. - ContractFrozen = 10, - /// Revenue for this period is not yet claimable (delay not elapsed). - ClaimDelayNotElapsed = 11, - /// Snapshot distribution is not enabled for this offering. - SnapshotNotEnabled = 12, - /// Provided snapshot reference is outdated or duplicates a previous one. - OutdatedSnapshot = 13, - /// Snapshot has been committed but not finalized via `finalize_snapshot`. - SnapshotNotFinalized = 49, - /// The recomputed snapshot digest does not match the committed `content_hash`. - SnapshotHashMismatch = 50, - /// `display_decimals` exceeds the maximum allowed precision of 18. - /// - /// Wire value: 51. Stable since v1. - DisplayDecimalsOutOfRange = 51, - /// Payout asset mismatch. - PayoutAssetMismatch = 14, - /// A transfer is already pending for this offering. - IssuerTransferPending = 15, - /// No transfer is pending for this offering. - NoTransferPending = 16, - /// Caller is not authorized to accept this transfer. - UnauthorizedTransferAccept = 17, - /// Metadata string exceeds maximum allowed length. - MetadataTooLarge = 18, - /// Caller is not authorized to perform this action. - NotAuthorized = 19, - /// Contract is not initialized (admin not set). - NotInitialized = 20, - /// Amount is invalid (e.g. negative for deposit, or out of allowed range) (#35). - InvalidAmount = 21, - /// period_id is invalid (e.g. zero when required to be positive) (#35). - InvalidPeriodId = 22, - /// Deposit would exceed the offering's supply cap (#96). - SupplyCapExceeded = 23, - /// Metadata format is invalid for configured scheme rules. - MetadataInvalidFormat = 24, - /// Current ledger timestamp is outside configured reporting window. - ReportingWindowClosed = 25, - /// Current ledger timestamp is outside configured claiming window. - ClaimWindowClosed = 26, - /// Off-chain signature has expired. - SignatureExpired = 27, - /// Signature nonce has already been used. - SignatureReplay = 28, - /// Off-chain signer key has not been registered. - SignerKeyNotRegistered = 29, - /// The provided attestation network identifier does not match the active ledger network. - NetworkIdMismatch = 77, - /// Transfer blocked because shares are still locked (lockup schedule active). - LockupViolation = 78, - /// Multisig proposal has expired. - /// Wire value: 30. Stable since v1. - ProposalExpired = 30, - /// Holder jurisdiction is not permitted by the offering's compliance allowlist. - JurisdictionDisallowed = 31, - /// Cross-contract token transfer failed. - TransferFailed = 39, - /// Contract is already at the target version; no migration needed. - AlreadyAtTargetVersion = 32, - /// Target version is lower than the current deployed version. - MigrationDowngradeNotAllowed = 33, - - /// Close-period abort due to a detected accrual or share ledger invariant violation. - /// - /// This error is returned before the period is sealed to prevent partially committed - /// close actions when the underlying state is inconsistent. - CloseAbortInvariantsViolated = 34, - - /// Admin rotation failed: new admin cannot be the same as current. - AdminRotationSameAddress = 40, - /// Admin rotation failed: another rotation is already pending. - AdminRotationPending = 41, - /// Admin rotation failed: no rotation is currently pending. - NoAdminRotationPending = 35, - /// Admin rotation failed: caller is not the pending new admin. - UnauthorizedRotationAccept = 36, - /// Admin rotation failed: the configured delay has not elapsed since the proposal. - AdminRotationDelayNotElapsed = 37, - /// Offering is frozen. - OfferingFrozen = 42, - /// Issuer transfer has expired. - IssuerTransferExpired = 43, - /// Transfer blocked because the offering has pre-cliff vesting schedules. - VestingTransferBlocked = 52, - /// Contract is paused. - ContractPaused = 44, - /// Blacklist size limit exceeded. - BlacklistSizeLimitExceeded = 45, - /// Approver has already approved this proposal. - AlreadyApproved = 46, - /// The requester is still within the faucet cooldown window. - FaucetCooldownActive = 38, - /// Total supply shares would exceed the offering's max total supply shares. - MaxTotalSupplySharesExceeded = 79, - - /// override_existing=true was requested but no persisted report exists for the given period_id. - MissingReportForOverride = 47, - - /// The period has been sealed by `close_period`; no further overrides are accepted. - /// - /// Wire value: 48. Stable since v1. - PeriodAlreadyClosed = 48, - - /// Concentration enforcement requires a fresh `report_concentration`, but the stored - /// concentration data is missing or older than the configured staleness window. - /// - /// Wire value: 53. Stable since v1. - StaleConcentrationData = 53, - - /// Disclosure URI exceeds the 256-byte maximum. - DisclosureUriTooLong = 54, - /// Empty URI paired with a non-zero hash is incoherent. - InconsistentDisclosure = 55, - /// sig_a and sig_b must be distinct addresses for dual-signature close. - DualSigSameSigner = 56, - /// Dual-signature close is not configured for this offering. - DualSigNotConfigured = 57, - /// The dispute ID does not correspond to an existing dispute record. - DisputeNotFound = 58, - /// A dispute with the same (offering_id, holder, meta_hash) already exists. - DisputeAlreadyOpen = 59, - /// The holder has reached the maximum number of open disputes per offering. - MaxDisputesReached = 60, - /// The caller holds zero shares in the offering and cannot open a dispute. - DisputeZeroShare = 61, - /// The attestation's embedded network_id does not match the current chain's network id. - /// - /// Prevents replay attacks where an attestation signed for testnet is replayed on mainnet - /// (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: 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, - /// The holder is still within the per-jurisdiction transfer cooldown window. - /// - /// Transfers are rejected until the cooldown period (configured via - /// [`set_transfer_cooldown`]) has elapsed since the holder's last transfer. - /// Wire value: 89. Stable since v1. - TransferCooldownActive = 89, + /// revenue_share_bps exceeded 10000 (100%). + InvalidRevenueShareBps = 1, + /// Reserved / generic limit guard (e.g. offering limit per issuer, threshold out of range). + LimitReached = 2, + /// Holder concentration exceeds configured limit and enforcement is enabled. + ConcentrationLimitExceeded = 3, + /// No offering found for the given (issuer, token) pair. + OfferingNotFound = 4, + /// Revenue already deposited for this period. + PeriodAlreadyDeposited = 5, + /// No unclaimed periods for this holder. + NoPendingClaims = 6, + /// Holder is blacklisted for this offering. + HolderBlacklisted = 7, + /// Holder share_bps exceeded 10000 (100%). + InvalidShareBps = 8, + /// Payment token does not match previously set token for this offering. + PaymentTokenMismatch = 9, + /// Contract is frozen; state-changing operations are disabled. + ContractFrozen = 10, + /// Revenue for this period is not yet claimable (delay not elapsed). + ClaimDelayNotElapsed = 11, + /// Snapshot distribution is not enabled for this offering. + SnapshotNotEnabled = 12, + /// Provided snapshot reference is outdated or duplicates a previous one. + OutdatedSnapshot = 13, + /// Snapshot has been committed but not finalized via `finalize_snapshot`. + SnapshotNotFinalized = 49, + /// The recomputed snapshot digest does not match the committed `content_hash`. + SnapshotHashMismatch = 50, + /// `display_decimals` exceeds the maximum allowed precision of 18. + /// + /// Wire value: 51. Stable since v1. + DisplayDecimalsOutOfRange = 51, + /// Payout asset mismatch. + PayoutAssetMismatch = 14, + /// A transfer is already pending for this offering. + IssuerTransferPending = 15, + /// No transfer is pending for this offering. + NoTransferPending = 16, + /// Caller is not authorized to accept this transfer. + UnauthorizedTransferAccept = 17, + /// Metadata string exceeds maximum allowed length. + MetadataTooLarge = 18, + /// Caller is not authorized to perform this action. + NotAuthorized = 19, + /// Contract is not initialized (admin not set). + NotInitialized = 20, + /// Amount is invalid (e.g. negative for deposit, or out of allowed range) (#35). + InvalidAmount = 21, + /// period_id is invalid (e.g. zero when required to be positive) (#35). + InvalidPeriodId = 22, + /// Deposit would exceed the offering's supply cap (#96). + SupplyCapExceeded = 23, + /// Metadata format is invalid for configured scheme rules. + MetadataInvalidFormat = 24, + /// Current ledger timestamp is outside configured reporting window. + ReportingWindowClosed = 25, + /// Current ledger timestamp is outside configured claiming window. + ClaimWindowClosed = 26, + /// Off-chain signature has expired. + SignatureExpired = 27, + /// Signature nonce has already been used. + SignatureReplay = 28, + /// Off-chain signer key has not been registered. + SignerKeyNotRegistered = 29, + /// The provided attestation network identifier does not match the active ledger network. + NetworkIdMismatch = 77, + /// Transfer blocked because shares are still locked (lockup schedule active). + LockupViolation = 78, + /// Multisig proposal has expired. + /// Wire value: 30. Stable since v1. + ProposalExpired = 30, + /// Holder jurisdiction is not permitted by the offering's compliance allowlist. + JurisdictionDisallowed = 31, + /// Cross-contract token transfer failed. + TransferFailed = 39, + /// Contract is already at the target version; no migration needed. + AlreadyAtTargetVersion = 32, + /// Target version is lower than the current deployed version. + MigrationDowngradeNotAllowed = 33, + + /// Close-period abort due to a detected accrual or share ledger invariant violation. + /// + /// This error is returned before the period is sealed to prevent partially committed + /// close actions when the underlying state is inconsistent. + CloseAbortInvariantsViolated = 34, + + /// Admin rotation failed: new admin cannot be the same as current. + AdminRotationSameAddress = 40, + /// Admin rotation failed: another rotation is already pending. + AdminRotationPending = 41, + /// Admin rotation failed: no rotation is currently pending. + NoAdminRotationPending = 35, + /// Admin rotation failed: caller is not the pending new admin. + UnauthorizedRotationAccept = 36, + /// Admin rotation failed: the configured delay has not elapsed since the proposal. + AdminRotationDelayNotElapsed = 37, + /// Offering is frozen. + OfferingFrozen = 42, + /// Issuer transfer has expired. + IssuerTransferExpired = 43, + /// Transfer blocked because the offering has pre-cliff vesting schedules. + VestingTransferBlocked = 52, + /// Contract is paused. + ContractPaused = 44, + /// Blacklist size limit exceeded. + BlacklistSizeLimitExceeded = 45, + /// Approver has already approved this proposal. + AlreadyApproved = 46, + /// The requester is still within the faucet cooldown window. + FaucetCooldownActive = 38, + /// Total supply shares would exceed the offering's max total supply shares. + MaxTotalSupplySharesExceeded = 79, + + /// override_existing=true was requested but no persisted report exists for the given period_id. + MissingReportForOverride = 47, + + /// The period has been sealed by `close_period`; no further overrides are accepted. + /// + /// Wire value: 48. Stable since v1. + PeriodAlreadyClosed = 48, + + /// Concentration enforcement requires a fresh `report_concentration`, but the stored + /// concentration data is missing or older than the configured staleness window. + /// + /// Wire value: 53. Stable since v1. + StaleConcentrationData = 53, + + /// Disclosure URI exceeds the 256-byte maximum. + DisclosureUriTooLong = 54, + /// Empty URI paired with a non-zero hash is incoherent. + InconsistentDisclosure = 55, + /// sig_a and sig_b must be distinct addresses for dual-signature close. + DualSigSameSigner = 56, + /// Dual-signature close is not configured for this offering. + DualSigNotConfigured = 57, + /// The dispute ID does not correspond to an existing dispute record. + DisputeNotFound = 58, + /// A dispute with the same (offering_id, holder, meta_hash) already exists. + DisputeAlreadyOpen = 59, + /// The holder has reached the maximum number of open disputes per offering. + MaxDisputesReached = 60, + /// The caller holds zero shares in the offering and cannot open a dispute. + DisputeZeroShare = 61, + /// The attestation's embedded network_id does not match the current chain's network id. + /// + /// Prevents replay attacks where an attestation signed for testnet is replayed on mainnet + /// (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: 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, + /// The holder is still within the per-jurisdiction transfer cooldown window. + /// + /// Transfers are rejected until the cooldown period (configured via + /// [`set_transfer_cooldown`]) has elapsed since the holder's last transfer. + /// Wire value: 89. Stable since v1. + TransferCooldownActive = 89, } pub mod tax_bucket; @@ -391,6 +394,8 @@ mod test_time_windows; // #[cfg(test)] // mod test_claim_transfer_fail; #[cfg(test)] +mod test_accrual_reconciliation_prop; +#[cfg(test)] mod test_close_period; #[cfg(test)] mod test_compute_share_decomposition_prop; @@ -408,8 +413,6 @@ mod test_quorum_check; #[cfg(test)] mod test_reg_limit_delta; #[cfg(test)] -mod test_accrual_reconciliation_prop; -#[cfg(test)] mod test_tax_year; #[cfg(test)] mod test_transfer_cooldown; @@ -439,7 +442,8 @@ const EVENT_REVENUE_REPORT_INITIAL_ASSET: Symbol = symbol_short!("rev_inia"); const EVENT_REVENUE_REPORT_OVERRIDE: Symbol = symbol_short!("rev_ovrd"); const EVENT_REVENUE_REPORT_OVERRIDE_ASSET: Symbol = symbol_short!("rev_ovra"); const EVENT_REVENUE_REPORT_REJECTED: Symbol = symbol_short!("rev_rej"); -const EVENT_REVENUE_REPORT_MISSING_OVERRIDE: Symbol = symbol_short!("rev_omiss"); +const EVENT_REVENUE_REPORT_MISSING_OVERRIDE: Symbol = + symbol_short!("rev_omiss"); const EVENT_REVENUE_REPORT_REJECTED_ASSET: Symbol = symbol_short!("rev_reja"); pub const EVENT_SCHEMA_VERSION_V2: u32 = 2; const DEFAULT_FAUCET_COOLDOWN_SECONDS: u64 = 3_600; @@ -476,34 +480,34 @@ const EVENT_DURATION_SET: Symbol = symbol_short!("dur_set"); #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum ProposalAction { - SetAdmin(Address), - Freeze, - SetThreshold(u32), - AddOwner(Address), - RemoveOwner(Address), - SetProposalDuration(u64), + SetAdmin(Address), + Freeze, + SetThreshold(u32), + AddOwner(Address), + RemoveOwner(Address), + SetProposalDuration(u64), } #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u32)] pub enum PauseState { - NotPaused = 0, - SoftPaused = 1, - HardPaused = 2, + NotPaused = 0, + SoftPaused = 1, + HardPaused = 2, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Proposal { - pub id: u32, - pub action: ProposalAction, - pub proposer: Address, - pub approvals: Vec
, - pub executed: bool, - pub expiry: u64, - pub epoch: u64, - pub quorum_bps: u32, + pub id: u32, + pub action: ProposalAction, + pub proposer: Address, + 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"); @@ -547,7 +551,8 @@ const EVENT_ROYALTY_CONFIG: Symbol = symbol_short!("roy_cfg"); const EVENT_ROYALTY_PAID: Symbol = symbol_short!("roy_paid"); const EVENT_INDEXED_V2: Symbol = symbol_short!("ev_idx2"); const EVENT_INDEXED_V3: Symbol = symbol_short!("ev_idx3"); -pub const EVENT_PROOF_REJECT_DEPTH: Symbol = symbol_short!("proof_reject_depth"); +pub const EVENT_PROOF_REJECT_DEPTH: Symbol = + symbol_short!("proof_reject_depth"); pub const MAX_PROOF_DEPTH: u32 = 32; const EVENT_TYPE_OFFER: Symbol = symbol_short!("offer"); /// Emitted when a period is sealed by `close_period`. @@ -787,26 +792,26 @@ pub const STORAGE_LAYOUT_VERSION: u32 = 5; /// - Same major and minor, higher patch is valid (bugfix). /// - Any decrease in any component is a downgrade. pub fn assert_semver_forward( - from: (u32, u32, u32), - to: (u32, u32, u32), + from: (u32, u32, u32), + to: (u32, u32, u32), ) -> Result<(), RevoraError> { - if to == from { - return Err(RevoraError::AlreadyAtTargetVersion); - } - if to.0 < from.0 - || (to.0 == from.0 && to.1 < from.1) - || (to.0 == from.0 && to.1 == from.1 && to.2 < from.2) - { - return Err(RevoraError::MigrationDowngradeNotAllowed); - } - Ok(()) + if to == from { + return Err(RevoraError::AlreadyAtTargetVersion); + } + if to.0 < from.0 + || (to.0 == from.0 && to.1 < from.1) + || (to.0 == from.0 && to.1 == from.1 && to.2 < from.2) + { + return Err(RevoraError::MigrationDowngradeNotAllowed); + } + Ok(()) } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct TenantId { - pub issuer: Address, - pub namespace: Symbol, + pub issuer: Address, + pub namespace: Symbol, } /// Each variant is assigned a stable u32 discriminant for bitmask operations. @@ -829,26 +834,26 @@ pub struct TenantId { #[derive(Clone, Debug, PartialEq, Eq, Copy)] #[repr(u32)] pub enum FreezeReason { - /// Broad compliance or regulatory action. - Compliance = 0, - /// Court-ordered legal hold. - LegalHold = 1, - /// Active dispute under investigation. - DisputeOpen = 2, - /// Address matched on a sanctions list. - SanctionsMatch = 3, - // Legacy variants kept for storage compatibility. - Sanctions = 4, - CourtOrder = 5, - IssuerDispute = 6, - Manual = 7, + /// Broad compliance or regulatory action. + Compliance = 0, + /// Court-ordered legal hold. + LegalHold = 1, + /// Active dispute under investigation. + DisputeOpen = 2, + /// Address matched on a sanctions list. + SanctionsMatch = 3, + // Legacy variants kept for storage compatibility. + 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) - } + /// Convert this reason to its single-bit mask. + pub fn to_bitmask(self) -> u32 { + 1u32 << (self as u32) + } } /// Outcome of a dispute resolution (#593). @@ -859,9 +864,9 @@ impl FreezeReason { #[contracttype] #[derive(Clone, Debug, PartialEq, Eq, Copy)] pub enum DisputeOutcome { - Upheld, - Rejected, - PartiallyUpheld, + Upheld, + Rejected, + PartiallyUpheld, } /// On-chain dispute tracking entry (#593). @@ -869,64 +874,64 @@ pub enum DisputeOutcome { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct DisputeEntry { - /// Unique dispute ID (auto-incremented). - pub dispute_id: u64, - /// The offering this dispute belongs to. - pub offering_id: OfferingId, - /// The frozen holder. - pub holder: Address, - /// The freeze reason (always IssuerDispute for disputes). - pub freeze_reason: FreezeReason, - /// Ledger timestamp when the dispute was created. - pub created_at: u64, - /// Whether the dispute has been resolved. - pub resolved: bool, - /// Resolution outcome, populated when resolved. - pub outcome: Option, - /// Evidence hash (32-byte SHA-256 or equivalent), populated when resolved. - pub evidence_hash: Option>, - /// Admin address that resolved the dispute. - pub resolved_by: Option
, - /// Ledger timestamp when the dispute was resolved. - pub resolved_at: Option, + /// Unique dispute ID (auto-incremented). + pub dispute_id: u64, + /// The offering this dispute belongs to. + pub offering_id: OfferingId, + /// The frozen holder. + pub holder: Address, + /// The freeze reason (always IssuerDispute for disputes). + pub freeze_reason: FreezeReason, + /// Ledger timestamp when the dispute was created. + pub created_at: u64, + /// Whether the dispute has been resolved. + pub resolved: bool, + /// Resolution outcome, populated when resolved. + pub outcome: Option, + /// Evidence hash (32-byte SHA-256 or equivalent), populated when resolved. + pub evidence_hash: Option>, + /// Admin address that resolved the dispute. + pub resolved_by: Option
, + /// Ledger timestamp when the dispute was resolved. + pub resolved_at: Option, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct OfferingId { - pub issuer: Address, - pub namespace: Symbol, - pub token: Address, + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum ShareClass { - A, - B, - Custom(Symbol), + A, + B, + Custom(Symbol), } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClassConfig { - pub bps: u32, - pub voting: bool, + pub bps: u32, + pub voting: bool, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum Source { - Manual, - OFAC, + Manual, + OFAC, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct SanctionsAttestation { - pub source: Source, - pub ref_id: Symbol, - pub attested_at: u64, + pub source: Source, + pub ref_id: Symbol, + pub attested_at: u64, } /// Domain-separated attestation for `transfer_with_attestation`. @@ -964,45 +969,45 @@ pub struct SanctionsAttestation { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct SignedAttestation { - /// sha256 of the Stellar network passphrase — the domain separator. - /// - /// Prevents cross-network replay: an attestation valid on testnet will have - /// a different `network_id` than one valid on mainnet, so the signature is - /// cryptographically bound to exactly one network. - pub network_id: BytesN<32>, - /// The pre-signed digest over `(network_id || issuer || namespace || token - /// || from || to || amount_bps)`. - pub digest: BytesN<32>, + /// sha256 of the Stellar network passphrase — the domain separator. + /// + /// Prevents cross-network replay: an attestation valid on testnet will have + /// a different `network_id` than one valid on mainnet, so the signature is + /// cryptographically bound to exactly one network. + pub network_id: BytesN<32>, + /// The pre-signed digest over `(network_id || issuer || namespace || token + /// || from || to || amount_bps)`. + pub digest: BytesN<32>, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Issuers { - pub primary: Address, - pub co: Vec
, - pub quorum: u32, + pub primary: Address, + pub co: Vec
, + pub quorum: u32, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Offering { - /// The issuers authorized to manage this offering. - pub issuers: Issuers, - /// The namespace this offering belongs to. - pub namespace: Symbol, - /// The token representing this offering. - pub token: Address, - /// Cumulative revenue share for all holders in basis points (0-10000). - pub revenue_share_bps: u32, - pub payout_asset: Address, - /// Human-readable ticker/symbol for the payout denomination (e.g. `USDC`, `XLM`). - /// Used by wallets and dashboards to display amounts without guessing the payment token. - /// Maximum 9 characters (Soroban `Symbol` limit). - pub denomination_symbol: Symbol, - /// Number of decimal places to use when displaying amounts for this offering. - /// Must be ≤ `MAX_TOKEN_DECIMALS` (18) and ≤ the payment token's on-chain decimals. - /// Defaults to 0 when unset. - pub display_decimals: u32, + /// The issuers authorized to manage this offering. + pub issuers: Issuers, + /// The namespace this offering belongs to. + pub namespace: Symbol, + /// The token representing this offering. + pub token: Address, + /// Cumulative revenue share for all holders in basis points (0-10000). + pub revenue_share_bps: u32, + pub payout_asset: Address, + /// Human-readable ticker/symbol for the payout denomination (e.g. `USDC`, `XLM`). + /// Used by wallets and dashboards to display amounts without guessing the payment token. + /// Maximum 9 characters (Soroban `Symbol` limit). + pub denomination_symbol: Symbol, + /// Number of decimal places to use when displaying amounts for this offering. + /// Must be ≤ `MAX_TOKEN_DECIMALS` (18) and ≤ the payment token's on-chain decimals. + /// Defaults to 0 when unset. + pub display_decimals: u32, } /// Per-offering FX oracle configuration used when `report_revenue` receives a @@ -1010,10 +1015,10 @@ pub struct Offering { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct FxOracleConfig { - pub oracle: Address, - pub revenue_symbol: Symbol, - pub payout_symbol: Symbol, - pub max_oracle_age_secs: u64, + pub oracle: Address, + pub revenue_symbol: Symbol, + pub payout_symbol: Symbol, + pub max_oracle_age_secs: u64, } /// Per-offering concentration guardrail config (#26). @@ -1023,15 +1028,15 @@ pub struct FxOracleConfig { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct ConcentrationLimitConfig { - /// Maximum allowed share in basis points for a single holder (0 = disabled). - pub max_bps: u32, - /// If true, `report_revenue` will fail if current concentration exceeds `max_bps`. - pub enforce: bool, - /// Maximum age (in seconds) of a `report_concentration` call before it is considered stale. - /// When `enforce` is true and this is > 0, `report_revenue` rejects if no concentration has - /// been reported or the last report is older than this many seconds. 0 = disabled (no staleness - /// check). - pub max_staleness_secs: u64, + /// Maximum allowed share in basis points for a single holder (0 = disabled). + pub max_bps: u32, + /// If true, `report_revenue` will fail if current concentration exceeds `max_bps`. + pub enforce: bool, + /// Maximum age (in seconds) of a `report_concentration` call before it is considered stale. + /// When `enforce` is true and this is > 0, `report_revenue` rejects if no concentration has + /// been reported or the last report is older than this many seconds. 0 = disabled (no staleness + /// check). + pub max_staleness_secs: u64, } /// Per-offering platform fee model (#468). @@ -1043,18 +1048,18 @@ pub struct ConcentrationLimitConfig { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct PlatformFeeModel { - /// Platform fee in basis points (0 = disabled; no fee deducted and no `plat_fee` event). - pub fee_bps: u32, - /// Destination address the platform fee is routed to. - pub treasury: Address, + /// Platform fee in basis points (0 = disabled; no fee deducted and no `plat_fee` event). + pub fee_bps: u32, + /// Destination address the platform fee is routed to. + pub treasury: Address, } /// Per-offering investment constraints (#97). Min/max stake per investor; off-chain enforced. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct InvestmentConstraintsConfig { - pub min_stake: i128, - pub max_stake: i128, + pub min_stake: i128, + pub max_stake: i128, } /// Per-offering TWAP (Time-Weighted Average Price) window configuration (#546). @@ -1069,13 +1074,13 @@ pub struct InvestmentConstraintsConfig { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct TwapConfig { - /// Smoothing horizon in seconds. Bounded by - /// [`MIN_TWAP_WINDOW_SECS`] and [`MAX_TWAP_WINDOW_SECS`]. - pub twap_window_secs: u64, - /// Ledger timestamp at which this configuration was last written. - pub updated_at: u64, - /// Address that performed the most recent `set_twap_window` call. - pub updated_by: Address, + /// Smoothing horizon in seconds. Bounded by + /// [`MIN_TWAP_WINDOW_SECS`] and [`MAX_TWAP_WINDOW_SECS`]. + pub twap_window_secs: u64, + /// Ledger timestamp at which this configuration was last written. + pub updated_at: u64, + /// Address that performed the most recent `set_twap_window` call. + pub updated_by: Address, } /// Off-chain disclosure binding for an offering (#485). @@ -1084,10 +1089,10 @@ pub struct TwapConfig { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct DisclosureMeta { - /// Off-chain document URI, e.g. `ipfs://…` or `https://…`. Max 256 bytes. - pub uri: Bytes, - /// SHA-256 (or equivalent) hash of the document at `uri`. Exactly 32 bytes. - pub hash: BytesN<32>, + /// Off-chain document URI, e.g. `ipfs://…` or `https://…`. Max 256 bytes. + pub uri: Bytes, + /// SHA-256 (or equivalent) hash of the document at `uri`. Exactly 32 bytes. + pub hash: BytesN<32>, } /// Per-offering audit log summary (#34). @@ -1095,29 +1100,29 @@ pub struct DisclosureMeta { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct AuditSummary { - /// Cumulative revenue amount reported for this offering. - pub total_revenue: i128, - /// Total number of revenue reports submitted. - pub report_count: u64, + /// Cumulative revenue amount reported for this offering. + pub total_revenue: i128, + /// Total number of revenue reports submitted. + pub report_count: u64, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct TransferRestrictions { - pub category: Symbol, - pub max_holders: u32, + pub category: Symbol, + pub max_holders: u32, } /// Read-only comparison between stored audit state and recomputed report state. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct AuditReconciliationResult { - pub stored_total_revenue: i128, - pub stored_report_count: u64, - pub computed_total_revenue: i128, - pub computed_report_count: u64, - pub is_consistent: bool, - pub is_saturated: bool, + pub stored_total_revenue: i128, + pub stored_report_count: u64, + pub computed_total_revenue: i128, + pub computed_report_count: u64, + pub is_consistent: bool, + pub is_saturated: bool, } /// One entry in a distribution proof: the holder's address, their share in basis points, @@ -1129,44 +1134,44 @@ pub struct AuditReconciliationResult { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct DistributionEntry { - /// The holder's address. - pub holder: Address, - /// The holder's share in basis points (0–10000). - pub share_bps: u32, - /// The normalized payout computed by the contract for this period. - /// Equals `compute_share(normalize_amount(period_revenue, decimals), share_bps, rounding_mode)`. - /// Zero when `share_bps == 0` or `period_revenue == 0`. - pub normalized_payout: i128, + /// The holder's address. + pub holder: Address, + /// The holder's share in basis points (0–10000). + pub share_bps: u32, + /// The normalized payout computed by the contract for this period. + /// Equals `compute_share(normalize_amount(period_revenue, decimals), share_bps, rounding_mode)`. + /// Zero when `share_bps == 0` or `period_revenue == 0`. + pub normalized_payout: i128, } /// Pending issuer transfer details including expiry tracking. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct PendingTransfer { - pub new_issuer: Address, - pub timestamp: u64, - /// Effective expiry in seconds. 0 means use ISSUER_TRANSFER_EXPIRY_SECS default. - pub expiry_secs: u64, + pub new_issuer: Address, + pub timestamp: u64, + /// Effective expiry in seconds. 0 means use ISSUER_TRANSFER_EXPIRY_SECS default. + pub expiry_secs: u64, } /// Cross-offering aggregated metrics (#39). #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct AggregatedMetrics { - pub total_reported_revenue: i128, - pub total_deposited_revenue: i128, - pub total_report_count: u64, - pub offering_count: u32, + pub total_reported_revenue: i128, + pub total_deposited_revenue: i128, + pub total_report_count: u64, + pub offering_count: u32, } /// Result of simulate_distribution (#29): per-holder payout and total. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct SimulateDistributionResult { - /// Total amount that would be distributed. - pub total_distributed: i128, - /// Payout per holder (holder address, amount). - pub payouts: Vec<(Address, i128)>, + /// Total amount that would be distributed. + pub total_distributed: i128, + /// Payout per holder (holder address, amount). + pub payouts: Vec<(Address, i128)>, } /// Result of `preflight_close_period` (#563): the same view that a successful @@ -1187,61 +1192,61 @@ pub struct SimulateDistributionResult { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct PreflightCloseResult { - pub period_id: u64, - /// Reported/deposited revenue currently stored at - /// `DataKey::PeriodRevenue(offering_id, period_id)`, or `0` if no report - /// has landed yet. This is the amount the write path would seal into - /// the period. - pub period_revenue: i128, - /// Canonical class pay order that `close_period` will write via - /// `record_and_emit_pay_order`. Empty when no classes are registered. - pub class_pay_order: Vec, - /// Per-holder distribution preview. - pub payouts: Vec, - /// Saturating sum of `payouts[i].normalized_payout`. Never exceeds - /// `period_revenue` because each entry is the result of - /// `compute_share(period_revenue, share_bps, mode)` with `share_bps <= 10000`. - pub total_distributed: i128, + pub period_id: u64, + /// Reported/deposited revenue currently stored at + /// `DataKey::PeriodRevenue(offering_id, period_id)`, or `0` if no report + /// has landed yet. This is the amount the write path would seal into + /// the period. + pub period_revenue: i128, + /// Canonical class pay order that `close_period` will write via + /// `record_and_emit_pay_order`. Empty when no classes are registered. + pub class_pay_order: Vec, + /// Per-holder distribution preview. + pub payouts: Vec, + /// Saturating sum of `payouts[i].normalized_payout`. Never exceeds + /// `period_revenue` because each entry is the result of + /// `compute_share(period_revenue, share_bps, mode)` with `share_bps <= 10000`. + pub total_distributed: i128, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct HolderShareCheckpoint { - pub start_index: u32, - pub share_bps: u32, + pub start_index: u32, + pub share_bps: u32, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct HolderAccrualState { - pub last_settled_idx: u32, - pub last_acc_per_share_e18: i128, - pub accrued_owed: i128, + pub last_settled_idx: u32, + pub last_acc_per_share_e18: i128, + pub accrued_owed: i128, } /// Read-only per-period statement row for a holder. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct HolderStatementEntry { - /// Deterministic revenue period identifier. - pub period_id: u64, - /// Timestamp at which the period's revenue was deposited. - pub deposit_timestamp: u64, - /// Amount currently attributable to the holder for this period. - pub claimable_amount: i128, + /// Deterministic revenue period identifier. + pub period_id: u64, + /// Timestamp at which the period's revenue was deposited. + pub deposit_timestamp: u64, + /// Amount currently attributable to the holder for this period. + pub claimable_amount: i128, } /// Versioned structured topic payload for indexers. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct EventIndexTopicV2 { - pub version: u32, - pub event_type: Symbol, - pub issuer: Address, - pub namespace: Symbol, - pub token: Address, - /// 0 when the event is not period-scoped. - pub period_id: u64, + pub version: u32, + pub event_type: Symbol, + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + /// 0 when the event is not period-scoped. + pub period_id: u64, } /// Versioned structured topic payload for indexers (V3). @@ -1250,103 +1255,103 @@ pub struct EventIndexTopicV2 { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct EventIndexTopicV3 { - pub version: u32, - pub event_type: Symbol, - pub issuer: Address, - pub namespace: Symbol, - pub token: Address, - /// 0 when the event is not period-scoped. - pub period_id: u64, - /// Reserved for future use. Facilitates additive schema evolution without struct reshuffle. - pub _reserved: u32, + pub version: u32, + pub event_type: Symbol, + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + /// 0 when the event is not period-scoped. + pub period_id: u64, + /// Reserved for future use. Facilitates additive schema evolution without struct reshuffle. + pub _reserved: u32, } /// Versioned domain-separated payload for off-chain authorized actions. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MetaAuthorization { - pub version: u32, - pub contract: Address, - pub signer: Address, - pub nonce: u64, - pub expiry: u64, - pub action: MetaAction, + pub version: u32, + pub contract: Address, + pub signer: Address, + pub nonce: u64, + pub expiry: u64, + pub action: MetaAction, } /// Off-chain authorized action variants. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum MetaAction { - SetHolderShare(MetaSetHolderSharePayload), - ApproveRevenueReport(MetaRevenueApprovalPayload), + SetHolderShare(MetaSetHolderSharePayload), + ApproveRevenueReport(MetaRevenueApprovalPayload), } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MetaSetHolderSharePayload { - pub issuer: Address, - pub namespace: Symbol, - pub token: Address, - pub holder: Address, - pub share_bps: u32, + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + pub holder: Address, + pub share_bps: u32, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MetaRevenueApprovalPayload { - pub issuer: Address, - pub namespace: Symbol, - pub token: Address, - pub payout_asset: Address, - pub amount: i128, - pub period_id: u64, - pub override_existing: bool, + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + pub payout_asset: Address, + pub amount: i128, + pub period_id: u64, + pub override_existing: bool, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct AccessWindow { - pub start_timestamp: u64, - pub end_timestamp: u64, + pub start_timestamp: u64, + pub end_timestamp: u64, } /// Per-holder pending redemption request. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct PendingRedemption { - pub shares_bps: u32, - pub timestamp: u64, + pub shares_bps: u32, + pub timestamp: u64, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum WindowDataKey { - Report(OfferingId), - Claim(OfferingId), - Redemption(OfferingId), + Report(OfferingId), + Claim(OfferingId), + Redemption(OfferingId), } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum MetaDataKey { - /// Off-chain signer public key (ed25519) bound to signer address. - SignerKey(Address), - /// Offering-scoped delegate signer allowed for meta-actions. - Delegate(OfferingId), - /// Replay protection key: signer + nonce consumed marker. - NonceUsed(Address, u64), - /// Approved revenue report marker keyed by offering and period. - RevenueApproved(OfferingId, u64), + /// Off-chain signer public key (ed25519) bound to signer address. + SignerKey(Address), + /// Offering-scoped delegate signer allowed for meta-actions. + Delegate(OfferingId), + /// Replay protection key: signer + nonce consumed marker. + NonceUsed(Address, u64), + /// Approved revenue report marker keyed by offering and period. + RevenueApproved(OfferingId, u64), } /// Defines how fractional shares are handled during distribution calculations. #[contracttype] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RoundingMode { - /// Truncate toward zero: share = (amount * bps) / 10000. - Truncation = 0, - /// Standard rounding: share = round((amount * bps) / 10000), where >= 0.5 rounds up. - RoundHalfUp = 1, + /// Truncate toward zero: share = (amount * bps) / 10000. + Truncation = 0, + /// Standard rounding: share = round((amount * bps) / 10000), where >= 0.5 rounds up. + RoundHalfUp = 1, } /// Immutable record of a committed snapshot for an offering. @@ -1366,16 +1371,16 @@ pub enum RoundingMode { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct SnapshotEntry { - /// Monotonically increasing snapshot identifier (must be > previous snapshot_ref). - pub snapshot_ref: u64, - /// Ledger timestamp at commit time (set by the contract, not the caller). - pub committed_at: u64, - /// Off-chain content hash of the holder-share dataset (32 bytes, caller-supplied). - pub content_hash: BytesN<32>, - /// Total number of holder entries recorded in this snapshot. - pub holder_count: u32, - /// Total basis points across all holders (informational; not enforced on-chain). - pub total_bps: u32, + /// Monotonically increasing snapshot identifier (must be > previous snapshot_ref). + pub snapshot_ref: u64, + /// Ledger timestamp at commit time (set by the contract, not the caller). + pub committed_at: u64, + /// Off-chain content hash of the holder-share dataset (32 bytes, caller-supplied). + pub content_hash: BytesN<32>, + /// Total number of holder entries recorded in this snapshot. + pub holder_count: u32, + /// Total basis points across all holders (informational; not enforced on-chain). + pub total_bps: u32, } /// Primary storage keys for core contract state. @@ -1387,107 +1392,107 @@ pub struct SnapshotEntry { #[contracttype] #[derive(Clone)] pub(crate) enum DataKey { - /// Deprecated shared period tracker retained for backward compatibility with older storage. - LastPeriodId(OfferingId), - Blacklist(OfferingId), - - /// Per-offering whitelist; when non-empty, only these addresses are eligible for distribution. - Whitelist(OfferingId), - /// Per-offering: blacklist addresses in insertion order for deterministic get_blacklist (#38). - BlacklistOrder(OfferingId), - /// Per-offering: metadata for blacklist entries pinned to an OFAC snapshot hash. - BlacklistMeta(OfferingId), - OfferCount(TenantId), - OfferItem(TenantId, u32), - /// Per-offering concentration limit config. - ConcentrationLimit(OfferingId), - /// Per-offering: last reported concentration in bps. - CurrentConcentration(OfferingId), - /// Per-offering: ledger timestamp of the last report_concentration call. - ConcentrationReportedAt(OfferingId), - /// Per-offering: audit summary. - AuditSummary(OfferingId), - /// Per-offering: rounding mode for share math. - RoundingMode(OfferingId), - /// Per-offering: revenue reports map (period_id -> (amount, timestamp)). - RevenueReports(OfferingId), - /// Per-offering per period: cumulative reported revenue amount. - RevenueIndex(OfferingId, u64), - /// Revenue amount deposited for (offering_id, period_id). - PeriodRevenue(OfferingId, u64), - /// Maps (offering_id, sequential_index) -> period_id for enumeration. - PeriodEntry(OfferingId, u32), - /// Total number of deposited periods for an offering. - PeriodCount(OfferingId), - /// Per-offering accrual index in e18 fixed-point. - AccrualIndexE18(OfferingId), - /// Holder's share in basis points for (offering_id, holder). - HolderShare(OfferingId, Address), - /// Last accrual index a holder has claimed up to. - LastClaimedAccrualIndex(OfferingId, Address), - /// Per-offering running total of all persisted holder shares (basis points). - HolderShareTotal(OfferingId), - /// Next period index to claim for (offering_id, holder). - LastClaimedIdx(OfferingId, Address), - /// Payment token address for an offering. - PaymentToken(OfferingId), - /// Per-offering claim delay in seconds (#27). 0 = immediate claim. - ClaimDelaySecs(OfferingId), - /// Ledger timestamp when revenue was deposited for (offering_id, period_id). - PeriodDepositTime(OfferingId, u64), - /// Global admin address; can set freeze (#32). - Admin, - /// Contract frozen flag; when true, state-changing ops are disabled (#32). - Frozen, - /// Proposed new admin address (pending two-step rotation). - PendingAdmin, - - /// Whether snapshot distribution is enabled for an offering. - SnapshotConfig(OfferingId), - /// Latest recorded snapshot reference for snapshot deposits on an offering. - LastSnapshotRef(OfferingId), - /// Committed snapshot entry keyed by (offering_id, snapshot_ref). - SnapshotEntry(OfferingId, u64), - /// Per-snapshot holder share at index N. - SnapshotHolder(OfferingId, u64, u32), - /// Total number of holders recorded in a snapshot. - SnapshotHolderCount(OfferingId, u64), - - /// Per-snapshot holder share by address for O(1) vote-weight lookup. - /// Key: (OfferingId, snapshot_ref, Address) -> u32 (share_bps). - SnapshotHolderShare(OfferingId, u64, Address), - - /// Pending issuer transfer for an offering. - PendingIssuerTransfer(OfferingId), - /// Current issuer lookup by offering token. - OfferingIssuer(OfferingId), - /// Testnet mode flag. - TestnetMode, - - /// Safety role address for emergency pause (#7). - Safety, - /// Global pause flag. - Paused, - - /// Configuration flag: when true, contract is event-only (no persistent business state). - EventOnlyMode, - /// Last migrated storage version for upgrade hooks. - DeployedVersion, - /// Persistent storage layout version stamp. Set during `initialize` and migrations. - StorageLayoutVersion, - - /// Platform fee in basis points. - PlatformFeeBps, - /// Per-offering per-asset fee override (#98). - OfferingFeeBps(OfferingId, Address), - /// Per-offering per-asset secondary-market royalty override (#562). - OfferingRoyaltyBps(OfferingId, Address), - /// Platform level per-asset fee (#98). - PlatformFeePerAsset(Address), - /// Whether snapshot finalization is enforced globally. - SnapshotFinalizationRequired, - /// Latest committed snapshot reference for an offering. - LastSnapshotCommitRef(OfferingId), + /// Deprecated shared period tracker retained for backward compatibility with older storage. + LastPeriodId(OfferingId), + Blacklist(OfferingId), + + /// Per-offering whitelist; when non-empty, only these addresses are eligible for distribution. + Whitelist(OfferingId), + /// Per-offering: blacklist addresses in insertion order for deterministic get_blacklist (#38). + BlacklistOrder(OfferingId), + /// Per-offering: metadata for blacklist entries pinned to an OFAC snapshot hash. + BlacklistMeta(OfferingId), + OfferCount(TenantId), + OfferItem(TenantId, u32), + /// Per-offering concentration limit config. + ConcentrationLimit(OfferingId), + /// Per-offering: last reported concentration in bps. + CurrentConcentration(OfferingId), + /// Per-offering: ledger timestamp of the last report_concentration call. + ConcentrationReportedAt(OfferingId), + /// Per-offering: audit summary. + AuditSummary(OfferingId), + /// Per-offering: rounding mode for share math. + RoundingMode(OfferingId), + /// Per-offering: revenue reports map (period_id -> (amount, timestamp)). + RevenueReports(OfferingId), + /// Per-offering per period: cumulative reported revenue amount. + RevenueIndex(OfferingId, u64), + /// Revenue amount deposited for (offering_id, period_id). + PeriodRevenue(OfferingId, u64), + /// Maps (offering_id, sequential_index) -> period_id for enumeration. + PeriodEntry(OfferingId, u32), + /// Total number of deposited periods for an offering. + PeriodCount(OfferingId), + /// Per-offering accrual index in e18 fixed-point. + AccrualIndexE18(OfferingId), + /// Holder's share in basis points for (offering_id, holder). + HolderShare(OfferingId, Address), + /// Last accrual index a holder has claimed up to. + LastClaimedAccrualIndex(OfferingId, Address), + /// Per-offering running total of all persisted holder shares (basis points). + HolderShareTotal(OfferingId), + /// Next period index to claim for (offering_id, holder). + LastClaimedIdx(OfferingId, Address), + /// Payment token address for an offering. + PaymentToken(OfferingId), + /// Per-offering claim delay in seconds (#27). 0 = immediate claim. + ClaimDelaySecs(OfferingId), + /// Ledger timestamp when revenue was deposited for (offering_id, period_id). + PeriodDepositTime(OfferingId, u64), + /// Global admin address; can set freeze (#32). + Admin, + /// Contract frozen flag; when true, state-changing ops are disabled (#32). + Frozen, + /// Proposed new admin address (pending two-step rotation). + PendingAdmin, + + /// Whether snapshot distribution is enabled for an offering. + SnapshotConfig(OfferingId), + /// Latest recorded snapshot reference for snapshot deposits on an offering. + LastSnapshotRef(OfferingId), + /// Committed snapshot entry keyed by (offering_id, snapshot_ref). + SnapshotEntry(OfferingId, u64), + /// Per-snapshot holder share at index N. + SnapshotHolder(OfferingId, u64, u32), + /// Total number of holders recorded in a snapshot. + SnapshotHolderCount(OfferingId, u64), + + /// Per-snapshot holder share by address for O(1) vote-weight lookup. + /// Key: (OfferingId, snapshot_ref, Address) -> u32 (share_bps). + SnapshotHolderShare(OfferingId, u64, Address), + + /// Pending issuer transfer for an offering. + PendingIssuerTransfer(OfferingId), + /// Current issuer lookup by offering token. + OfferingIssuer(OfferingId), + /// Testnet mode flag. + TestnetMode, + + /// Safety role address for emergency pause (#7). + Safety, + /// Global pause flag. + Paused, + + /// Configuration flag: when true, contract is event-only (no persistent business state). + EventOnlyMode, + /// Last migrated storage version for upgrade hooks. + DeployedVersion, + /// Persistent storage layout version stamp. Set during `initialize` and migrations. + StorageLayoutVersion, + + /// Platform fee in basis points. + PlatformFeeBps, + /// Per-offering per-asset fee override (#98). + OfferingFeeBps(OfferingId, Address), + /// Per-offering per-asset secondary-market royalty override (#562). + OfferingRoyaltyBps(OfferingId, Address), + /// Platform level per-asset fee (#98). + PlatformFeePerAsset(Address), + /// Whether snapshot finalization is enforced globally. + SnapshotFinalizationRequired, + /// Latest committed snapshot reference for an offering. + LastSnapshotCommitRef(OfferingId), } /// Per-holder anchor for checkpoint-compressed accrual ranges. @@ -1503,11 +1508,11 @@ pub(crate) enum DataKey { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct AccrualAnchor { - /// Highest period index (inclusive) covered by this anchor. - pub end_idx: u32, - /// Pre-computed sum of claimable amounts for all compressed periods - /// whose share-bps transitions are folded into this anchor. - pub claimable_sum: i128, + /// Highest period index (inclusive) covered by this anchor. + pub end_idx: u32, + /// Pre-computed sum of claimable amounts for all compressed periods + /// whose share-bps transitions are folded into this anchor. + pub claimable_sum: i128, } /// Secondary storage keys for auxiliary/extended contract state. @@ -1515,175 +1520,175 @@ pub struct AccrualAnchor { #[contracttype] #[derive(Clone)] pub enum DataKey2 { - /// Whether the snapshot has been finalized successfully. - SnapshotFinalized(OfferingId, u64), - /// Per-offering supply cap (max total deposited revenue). - SupplyCap(OfferingId), - /// Total revenue deposited so far for supply-cap tracking. - DepositedRevenue(OfferingId), - /// Per-offering investment constraints (min/max stake). - InvestmentConstraints(OfferingId), - /// Per-offering minimum revenue threshold. - MinRevenueThreshold(OfferingId), - /// Last reported period_id for an offering. - LastReportedPeriodId(OfferingId), - /// Last deposited period_id for an offering. - LastDepositedPeriodId(OfferingId), - /// Payment token decimals configured for an offering. - PaymentTokenDecimals(OfferingId), - /// Offering-scoped freeze flag. - FrozenOffering(OfferingId), - /// Global count of unique issuers (#39). - IssuerCount, - /// Issuer address at global index (#39). - IssuerItem(u32), - /// Whether an issuer is already registered in the global registry (#39). - IssuerRegistered(Address), - - /// Per-issuer namespace tracking. - NamespaceCount(Address), - NamespaceItem(Address, u32), - NamespaceRegistered(Address, Symbol), - - /// DataKey for testing storage boundaries without affecting business state. - StressDataEntry(Address, u32), - /// Tracks total amount of dummy data allocated per admin. - StressDataCount(Address), - /// Holder's configured jurisdiction tag for (offering_id, holder). - HolderJurisdiction(OfferingId, Address), - /// Oracle public key mapped by oracle address. - OraclePubKey(Address), - /// Conversion ratio (in bps) from one class to another. - ClassConversionRatio(OfferingId, ShareClass, ShareClass), - /// Per-offering jurisdiction allowlist. Empty means compliance gating is disabled. - AllowedJurisdictions(OfferingId), - /// Global cumulative normalized accrual per 1 bps share, scaled by 1e18. - GlobalAccPerShareE18(OfferingId), - /// Snapshot of cumulative accrual after `index` deposited periods. - AccPerShareAtIndex(OfferingId, u32), - /// Cached holder accrual state used to freeze matured entitlements across share changes. - HolderAccrualState(OfferingId, Address), - /// Piecewise-constant share schedule keyed by deposited-period index. - HolderShareSchedule(OfferingId, Address), - /// Per-holder checkpoint anchor for compressed accrual ranges. - AccrualAnchor(OfferingId, Address), - /// Per-offering checkpoint compression threshold. When the holder share - /// schedule length exceeds this value the oldest entries are folded into - /// an `AccrualAnchor` and pruned from the schedule. - CheckpointThreshold(OfferingId), - /// Packed flags: (event_versioning_enabled: bool, event_only_mode: bool). - ContractFlags, - - /// Direct offering index: (issuer, namespace, token) -> Offering for O(1) get_offering (#360). - OfferingRecord(OfferingId), - - /// Per-offering blacklist size limit (#358). If not set, defaults to MAX_BLACKLIST_SIZE. - BlacklistSizeLimit(OfferingId), - - /// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period. - ClosedPeriod(OfferingId, u64), - - /// Off-chain disclosure metadata (URI + hash) for an offering (#485). - DisclosureMeta(OfferingId), - - /// Governance proposal count scoped to an offering. - GovernanceProposalCount(OfferingId), - /// Governance proposal payload keyed by (offering_id, proposal_id). - GovernanceProposal(OfferingId, u32), - /// Duplicate meta-hash guard keyed by (offering_id, meta_hash). - GovernanceProposalMeta(OfferingId, BytesN<32>), - - /// Timestamp of the last faucet request for a requester address. - FaucetLastRequest(Address), - /// Whether dual-signature close-of-period is enabled for this offering. - DualSigEnabled(OfferingId), - /// Append-only admin rotation log entry keyed by rotation_id (sequential counter). - AdminRotationLog(u64), - /// Monotonically increasing counter for admin rotation entries. - AdminRotationCount, - - // ── Missing variants added for compilation ── - /// Current accrual index counter for dividend-accrual ledger. - AccrualIndex(OfferingId), - /// Per-offering platform fee model. - OfferingPlatformFee(OfferingId), - /// Denomination metadata (symbol, decimals) for an offering. - DenominationMetadata(OfferingId), - /// FX oracle configuration for an offering. - FxOracleConfig(OfferingId), - /// Transfer restrictions per category for an offering. - TransferRestrictions(OfferingId, Symbol), - /// Holder category tag for transfer restriction purposes. - HolderCategory(OfferingId, Address), - /// 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. - MaxTotalSupplyShares(OfferingId), - /// Per-entry faucet seed for testnet holder seeding. - FaucetSeedEntry(OfferingId, u32), - /// Running count of faucet seed slots generated for an offering; used by faucet_reset - FaucetSeedCount(OfferingId), - - /// Per-offering fiscal year start month (1-12, default 1 = January). - /// Used by `get_holder_tax_year` to determine which fiscal year a payout - /// timestamp belongs to. - FiscalYearStartMonth(OfferingId), - /// Per-holder, per-fiscal-year accumulated tax summary. - /// Updated on every claim via `rollover_distribution`. - TaxYearEntry(OfferingId, Address, u64), - - // ── Multisig keys ── - /// Multisig approval threshold. - MultisigThreshold, - /// Multisig owner list. - MultisigOwners, - /// Multisig proposal counter. - MultisigProposalCount, - /// Default proposal duration in seconds. - MultisigProposalDuration, - /// Multisig proposal by id. - MultisigProposal(u32), - - // ── Governance keys (issue #557) ── - /// Per-offering governance proposal counter. - GovProposalCount(OfferingId), - /// Per-offering governance proposal by id. - GovProposal(OfferingId, u32), - /// Vote record for (offering_id, proposal_id, voter) -> bool (true=yes, false=no). - VoteRecord(OfferingId, u32, Address), - - // ── Deferred-distribution priority queue (issue #551) ── - /// Priority-ordered deferred-distribution queue for an offering. - /// Value: `Vec` stored in `(release_ts, priority, queue_id)` sorted order. - DeferredQueue(OfferingId), - - // ── 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), - /// Per-offering per-jurisdiction transfer cooldown in seconds. - /// When set, a holder whose jurisdiction matches cannot transfer again - /// until `cooldown_secs` have elapsed since their last transfer. - TransferCooldownConfig(OfferingId, Symbol), - /// Ledger timestamp of the last transfer for (offering_id, holder). - /// Used by the cooldown check to reject premature transfers. - HolderLastTransferTime(OfferingId, Address), + /// Whether the snapshot has been finalized successfully. + SnapshotFinalized(OfferingId, u64), + /// Per-offering supply cap (max total deposited revenue). + SupplyCap(OfferingId), + /// Total revenue deposited so far for supply-cap tracking. + DepositedRevenue(OfferingId), + /// Per-offering investment constraints (min/max stake). + InvestmentConstraints(OfferingId), + /// Per-offering minimum revenue threshold. + MinRevenueThreshold(OfferingId), + /// Last reported period_id for an offering. + LastReportedPeriodId(OfferingId), + /// Last deposited period_id for an offering. + LastDepositedPeriodId(OfferingId), + /// Payment token decimals configured for an offering. + PaymentTokenDecimals(OfferingId), + /// Offering-scoped freeze flag. + FrozenOffering(OfferingId), + /// Global count of unique issuers (#39). + IssuerCount, + /// Issuer address at global index (#39). + IssuerItem(u32), + /// Whether an issuer is already registered in the global registry (#39). + IssuerRegistered(Address), + + /// Per-issuer namespace tracking. + NamespaceCount(Address), + NamespaceItem(Address, u32), + NamespaceRegistered(Address, Symbol), + + /// DataKey for testing storage boundaries without affecting business state. + StressDataEntry(Address, u32), + /// Tracks total amount of dummy data allocated per admin. + StressDataCount(Address), + /// Holder's configured jurisdiction tag for (offering_id, holder). + HolderJurisdiction(OfferingId, Address), + /// Oracle public key mapped by oracle address. + OraclePubKey(Address), + /// Conversion ratio (in bps) from one class to another. + ClassConversionRatio(OfferingId, ShareClass, ShareClass), + /// Per-offering jurisdiction allowlist. Empty means compliance gating is disabled. + AllowedJurisdictions(OfferingId), + /// Global cumulative normalized accrual per 1 bps share, scaled by 1e18. + GlobalAccPerShareE18(OfferingId), + /// Snapshot of cumulative accrual after `index` deposited periods. + AccPerShareAtIndex(OfferingId, u32), + /// Cached holder accrual state used to freeze matured entitlements across share changes. + HolderAccrualState(OfferingId, Address), + /// Piecewise-constant share schedule keyed by deposited-period index. + HolderShareSchedule(OfferingId, Address), + /// Per-holder checkpoint anchor for compressed accrual ranges. + AccrualAnchor(OfferingId, Address), + /// Per-offering checkpoint compression threshold. When the holder share + /// schedule length exceeds this value the oldest entries are folded into + /// an `AccrualAnchor` and pruned from the schedule. + CheckpointThreshold(OfferingId), + /// Packed flags: (event_versioning_enabled: bool, event_only_mode: bool). + ContractFlags, + + /// Direct offering index: (issuer, namespace, token) -> Offering for O(1) get_offering (#360). + OfferingRecord(OfferingId), + + /// Per-offering blacklist size limit (#358). If not set, defaults to MAX_BLACKLIST_SIZE. + BlacklistSizeLimit(OfferingId), + + /// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period. + ClosedPeriod(OfferingId, u64), + + /// Off-chain disclosure metadata (URI + hash) for an offering (#485). + DisclosureMeta(OfferingId), + + /// Governance proposal count scoped to an offering. + GovernanceProposalCount(OfferingId), + /// Governance proposal payload keyed by (offering_id, proposal_id). + GovernanceProposal(OfferingId, u32), + /// Duplicate meta-hash guard keyed by (offering_id, meta_hash). + GovernanceProposalMeta(OfferingId, BytesN<32>), + + /// Timestamp of the last faucet request for a requester address. + FaucetLastRequest(Address), + /// Whether dual-signature close-of-period is enabled for this offering. + DualSigEnabled(OfferingId), + /// Append-only admin rotation log entry keyed by rotation_id (sequential counter). + AdminRotationLog(u64), + /// Monotonically increasing counter for admin rotation entries. + AdminRotationCount, + + // ── Missing variants added for compilation ── + /// Current accrual index counter for dividend-accrual ledger. + AccrualIndex(OfferingId), + /// Per-offering platform fee model. + OfferingPlatformFee(OfferingId), + /// Denomination metadata (symbol, decimals) for an offering. + DenominationMetadata(OfferingId), + /// FX oracle configuration for an offering. + FxOracleConfig(OfferingId), + /// Transfer restrictions per category for an offering. + TransferRestrictions(OfferingId, Symbol), + /// Holder category tag for transfer restriction purposes. + HolderCategory(OfferingId, Address), + /// 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. + MaxTotalSupplyShares(OfferingId), + /// Per-entry faucet seed for testnet holder seeding. + FaucetSeedEntry(OfferingId, u32), + /// Running count of faucet seed slots generated for an offering; used by faucet_reset + FaucetSeedCount(OfferingId), + + /// Per-offering fiscal year start month (1-12, default 1 = January). + /// Used by `get_holder_tax_year` to determine which fiscal year a payout + /// timestamp belongs to. + FiscalYearStartMonth(OfferingId), + /// Per-holder, per-fiscal-year accumulated tax summary. + /// Updated on every claim via `rollover_distribution`. + TaxYearEntry(OfferingId, Address, u64), + + // ── Multisig keys ── + /// Multisig approval threshold. + MultisigThreshold, + /// Multisig owner list. + MultisigOwners, + /// Multisig proposal counter. + MultisigProposalCount, + /// Default proposal duration in seconds. + MultisigProposalDuration, + /// Multisig proposal by id. + MultisigProposal(u32), + + // ── Governance keys (issue #557) ── + /// Per-offering governance proposal counter. + GovProposalCount(OfferingId), + /// Per-offering governance proposal by id. + GovProposal(OfferingId, u32), + /// Vote record for (offering_id, proposal_id, voter) -> bool (true=yes, false=no). + VoteRecord(OfferingId, u32, Address), + + // ── Deferred-distribution priority queue (issue #551) ── + /// Priority-ordered deferred-distribution queue for an offering. + /// Value: `Vec` stored in `(release_ts, priority, queue_id)` sorted order. + DeferredQueue(OfferingId), + + // ── 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), + /// Per-offering per-jurisdiction transfer cooldown in seconds. + /// When set, a holder whose jurisdiction matches cannot transfer again + /// until `cooldown_secs` have elapsed since their last transfer. + TransferCooldownConfig(OfferingId, Symbol), + /// Ledger timestamp of the last transfer for (offering_id, holder). + /// Used by the cooldown check to reject premature transfers. + HolderLastTransferTime(OfferingId, Address), } /// Maximum number of offerings returned in a single page. @@ -1729,66 +1734,72 @@ const MAX_OPEN_DISPUTES_PER_HOLDER: u32 = 5; /// Each category has specific rules for what constitutes a valid amount. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AmountValidationCategory { - /// Revenue deposit: amount must be strictly positive (> 0). - /// Reason: Depositing zero or negative tokens has no economic meaning. - RevenueDeposit, - /// Revenue report: amount can be zero but not negative (>= 0). - /// Reason: Zero revenue is valid (no distribution triggered); negative is impossible. - RevenueReport, - /// Holder share allocation: amount can be zero but not negative (>= 0). - /// Reason: Zero share means no allocation; negative share is invalid. - HolderShare, - /// Minimum revenue threshold: must be non-negative (>= 0). - /// Reason: Threshold of zero means no minimum; negative threshold is nonsensical. - MinRevenueThreshold, - /// Supply cap configuration: must be non-negative (>= 0). - /// Reason: Zero cap means unlimited; negative cap is invalid. - SupplyCap, - /// Investment constraints (min_stake): must be non-negative (>= 0). - /// Reason: Minimum stake cannot be negative. - InvestmentMinStake, - /// Investment constraints (max_stake): must be non-negative (>= 0) and >= min_stake. - /// Reason: Maximum stake must be valid range; zero means unlimited. - InvestmentMaxStake, - /// Snapshot reference: must be positive (> 0) and strictly increasing. - /// Reason: Zero is invalid; must be strictly monotonic. - SnapshotReference, - /// Period ID: unsigned, but some contexts require > 0. - /// Reason: Period 0 may be ambiguous in some business logic. - PeriodId, - /// Generic distribution simulation: any i128 is valid (can be negative for modeling). - /// Reason: Simulation-only, no state mutation. - Simulation, - /// Max total supply shares configuration: must be non-negative (>= 0). - /// Reason: Zero cap means unlimited; negative cap is invalid. - MaxTotalSupplyShares, + /// Revenue deposit: amount must be strictly positive (> 0). + /// Reason: Depositing zero or negative tokens has no economic meaning. + RevenueDeposit, + /// Revenue report: amount can be zero but not negative (>= 0). + /// Reason: Zero revenue is valid (no distribution triggered); negative is impossible. + RevenueReport, + /// Holder share allocation: amount can be zero but not negative (>= 0). + /// Reason: Zero share means no allocation; negative share is invalid. + HolderShare, + /// Minimum revenue threshold: must be non-negative (>= 0). + /// Reason: Threshold of zero means no minimum; negative threshold is nonsensical. + MinRevenueThreshold, + /// Supply cap configuration: must be non-negative (>= 0). + /// Reason: Zero cap means unlimited; negative cap is invalid. + SupplyCap, + /// Investment constraints (min_stake): must be non-negative (>= 0). + /// Reason: Minimum stake cannot be negative. + InvestmentMinStake, + /// Investment constraints (max_stake): must be non-negative (>= 0) and >= min_stake. + /// Reason: Maximum stake must be valid range; zero means unlimited. + InvestmentMaxStake, + /// Snapshot reference: must be positive (> 0) and strictly increasing. + /// Reason: Zero is invalid; must be strictly monotonic. + SnapshotReference, + /// Period ID: unsigned, but some contexts require > 0. + /// Reason: Period 0 may be ambiguous in some business logic. + PeriodId, + /// Generic distribution simulation: any i128 is valid (can be negative for modeling). + /// Reason: Simulation-only, no state mutation. + Simulation, + /// Max total supply shares configuration: must be non-negative (>= 0). + /// Reason: Zero cap means unlimited; negative cap is invalid. + MaxTotalSupplyShares, } /// Result of amount validation with detailed classification. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AmountValidationResult { - /// The original amount that was validated. - pub amount: i128, - /// The category of validation applied. - pub category: AmountValidationCategory, - /// Whether the amount passed validation. - pub is_valid: bool, - /// Specific error code if validation failed. - pub error_code: Option, - /// Human-readable description of why validation passed/failed. - pub reason: Symbol, + /// The original amount that was validated. + pub amount: i128, + /// The category of validation applied. + pub category: AmountValidationCategory, + /// Whether the amount passed validation. + pub is_valid: bool, + /// Specific error code if validation failed. + pub error_code: Option, + /// Human-readable description of why validation passed/failed. + pub reason: Symbol, } impl AmountValidationResult { - fn new( - amount: i128, - category: AmountValidationCategory, - is_valid: bool, - error_code: Option, - reason: Symbol, - ) -> Self { - Self { amount, category, is_valid, error_code, reason } - } + fn new( + amount: i128, + category: AmountValidationCategory, + is_valid: bool, + error_code: Option, + reason: Symbol, + ) -> Self { + Self { + amount, + category, + is_valid, + error_code, + reason, + } + } } /// Event symbol emitted when amount validation fails. @@ -1803,154 +1814,172 @@ const EVENT_AMOUNT_VALIDATION_FAILED: Symbol = symbol_short!("amt_valid"); pub struct AmountValidationMatrix; impl AmountValidationMatrix { - /// Validate an amount against the specified category's rules. - /// - /// # Arguments - /// * `amount` - The i128 amount to validate - /// * `category` - The validation context/category - /// - /// # Returns - /// * `Ok(())` if validation passes - /// * `Err((RevoraError, Symbol))` with specific error and reason if validation fails - /// - /// # Security Properties - /// - All negative amounts are rejected in deposit contexts - /// - Zero is allowed where semantically meaningful (reports, shares) - /// - Overflow-protected comparisons via saturating arithmetic where needed - pub fn validate( - amount: i128, - category: AmountValidationCategory, - ) -> Result<(), (RevoraError, Symbol)> { - match category { - AmountValidationCategory::RevenueDeposit => { - if amount <= 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("must_pos"))); - } - } - AmountValidationCategory::RevenueReport => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::HolderShare => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::MinRevenueThreshold => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::SupplyCap => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::InvestmentMinStake => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::InvestmentMaxStake => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::SnapshotReference => { - if amount <= 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("snap_pos"))); - } - } - AmountValidationCategory::PeriodId => { - if amount < 0 { - return Err((RevoraError::InvalidPeriodId, symbol_short!("no_neg"))); - } - } - AmountValidationCategory::Simulation => {} - AmountValidationCategory::MaxTotalSupplyShares => { - if amount < 0 { - return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); - } - } + /// Validate an amount against the specified category's rules. + /// + /// # Arguments + /// * `amount` - The i128 amount to validate + /// * `category` - The validation context/category + /// + /// # Returns + /// * `Ok(())` if validation passes + /// * `Err((RevoraError, Symbol))` with specific error and reason if validation fails + /// + /// # Security Properties + /// - All negative amounts are rejected in deposit contexts + /// - Zero is allowed where semantically meaningful (reports, shares) + /// - Overflow-protected comparisons via saturating arithmetic where needed + pub fn validate( + amount: i128, + category: AmountValidationCategory, + ) -> Result<(), (RevoraError, Symbol)> { + match category { + AmountValidationCategory::RevenueDeposit => { + if amount <= 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("must_pos"))); + } + } + AmountValidationCategory::RevenueReport => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } + AmountValidationCategory::HolderShare => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } + AmountValidationCategory::MinRevenueThreshold => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } + AmountValidationCategory::SupplyCap => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } + AmountValidationCategory::InvestmentMinStake => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } + AmountValidationCategory::InvestmentMaxStake => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); + } + } + AmountValidationCategory::SnapshotReference => { + if amount <= 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("snap_pos"))); } - Ok(()) - } - - /// Validate that max_stake >= min_stake when both are provided. - /// - /// # Arguments - /// * `min_stake` - The minimum stake value - /// * `max_stake` - The maximum stake value - /// - /// # Returns - /// * `Ok(())` if min <= max - /// * `Err(RevoraError::InvalidAmount)` if min > max - pub fn validate_stake_range(min_stake: i128, max_stake: i128) -> Result<(), RevoraError> { - if max_stake > 0 && min_stake > max_stake { - return Err(RevoraError::InvalidAmount); + } + AmountValidationCategory::PeriodId => { + if amount < 0 { + return Err((RevoraError::InvalidPeriodId, symbol_short!("no_neg"))); } - Ok(()) - } - - /// Validate that snapshot reference is strictly increasing. - /// - /// # Arguments - /// * `new_ref` - The new snapshot reference - /// * `last_ref` - The last recorded snapshot reference - /// - /// # Returns - /// * `Ok(())` if new_ref > last_ref - /// * `Err(RevoraError::OutdatedSnapshot)` if new_ref <= last_ref - pub fn validate_snapshot_monotonic(new_ref: i128, last_ref: i128) -> Result<(), RevoraError> { - if new_ref <= last_ref { - return Err(RevoraError::OutdatedSnapshot); + } + AmountValidationCategory::Simulation => {} + AmountValidationCategory::MaxTotalSupplyShares => { + if amount < 0 { + return Err((RevoraError::InvalidAmount, symbol_short!("no_neg"))); } - Ok(()) - } - - /// Get a detailed validation result for an amount. - /// - /// Unlike `validate()`, this always returns a result struct with full context. - pub fn validate_detailed( - amount: i128, - category: AmountValidationCategory, - ) -> AmountValidationResult { - let (is_valid, error_code, reason) = match Self::validate(amount, category) { - Ok(()) => (true, None, symbol_short!("valid")), - Err((err, reason)) => (false, Some(err as u32), reason), - }; - AmountValidationResult::new(amount, category, is_valid, error_code, reason) + } } - - /// Batch validate multiple amounts against the same category. - /// - /// Returns the first failing index, or None if all pass. - pub fn validate_batch(amounts: &[i128], category: AmountValidationCategory) -> Option { - for (i, &amount) in amounts.iter().enumerate() { - if Self::validate(amount, category).is_err() { - return Some(i); - } - } - None - } - - /// Get the default validation category for a given function name (for testing/debugging). - /// - /// This is a best-effort mapping; some functions have multiple amount parameters - /// with different validation requirements. - pub fn category_for_function(fn_name: &str) -> Option { - match fn_name { - "deposit_revenue" => Some(AmountValidationCategory::RevenueDeposit), - "report_revenue" => Some(AmountValidationCategory::RevenueReport), - "set_holder_share" => Some(AmountValidationCategory::HolderShare), - "set_min_revenue_threshold" => Some(AmountValidationCategory::MinRevenueThreshold), - "set_investment_constraints" => Some(AmountValidationCategory::InvestmentMinStake), - "simulate_distribution" => Some(AmountValidationCategory::Simulation), - "set_max_total_supply_shares" => Some(AmountValidationCategory::MaxTotalSupplyShares), - _ => None, - } + Ok(()) + } + + /// Validate that max_stake >= min_stake when both are provided. + /// + /// # Arguments + /// * `min_stake` - The minimum stake value + /// * `max_stake` - The maximum stake value + /// + /// # Returns + /// * `Ok(())` if min <= max + /// * `Err(RevoraError::InvalidAmount)` if min > max + pub fn validate_stake_range( + min_stake: i128, + max_stake: i128, + ) -> Result<(), RevoraError> { + if max_stake > 0 && min_stake > max_stake { + return Err(RevoraError::InvalidAmount); + } + Ok(()) + } + + /// Validate that snapshot reference is strictly increasing. + /// + /// # Arguments + /// * `new_ref` - The new snapshot reference + /// * `last_ref` - The last recorded snapshot reference + /// + /// # Returns + /// * `Ok(())` if new_ref > last_ref + /// * `Err(RevoraError::OutdatedSnapshot)` if new_ref <= last_ref + pub fn validate_snapshot_monotonic( + new_ref: i128, + last_ref: i128, + ) -> Result<(), RevoraError> { + if new_ref <= last_ref { + return Err(RevoraError::OutdatedSnapshot); } + Ok(()) + } + + /// Get a detailed validation result for an amount. + /// + /// Unlike `validate()`, this always returns a result struct with full context. + pub fn validate_detailed( + amount: i128, + category: AmountValidationCategory, + ) -> AmountValidationResult { + let (is_valid, error_code, reason) = match Self::validate(amount, category) + { + Ok(()) => (true, None, symbol_short!("valid")), + Err((err, reason)) => (false, Some(err as u32), reason), + }; + AmountValidationResult::new(amount, category, is_valid, error_code, reason) + } + + /// Batch validate multiple amounts against the same category. + /// + /// Returns the first failing index, or None if all pass. + pub fn validate_batch( + amounts: &[i128], + category: AmountValidationCategory, + ) -> Option { + for (i, &amount) in amounts.iter().enumerate() { + if Self::validate(amount, category).is_err() { + return Some(i); + } + } + None + } + + /// Get the default validation category for a given function name (for testing/debugging). + /// + /// This is a best-effort mapping; some functions have multiple amount parameters + /// with different validation requirements. + pub fn category_for_function( + fn_name: &str, + ) -> Option { + match fn_name { + "deposit_revenue" => Some(AmountValidationCategory::RevenueDeposit), + "report_revenue" => Some(AmountValidationCategory::RevenueReport), + "set_holder_share" => Some(AmountValidationCategory::HolderShare), + "set_min_revenue_threshold" => { + Some(AmountValidationCategory::MinRevenueThreshold) + } + "set_investment_constraints" => { + Some(AmountValidationCategory::InvestmentMinStake) + } + "simulate_distribution" => Some(AmountValidationCategory::Simulation), + "set_max_total_supply_shares" => { + Some(AmountValidationCategory::MaxTotalSupplyShares) + } + _ => None, + } + } } // ── Contract ───────────────────────────────────────────────── @@ -1959,13012 +1988,15536 @@ pub struct RevoraRevenueShare; #[contractimpl] impl RevoraRevenueShare { - const META_AUTH_VERSION: u32 = 1; - - /// Returns error if contract is frozen (#32). Call at start of state-mutating entrypoints. - fn require_not_frozen(env: &Env) -> Result<(), RevoraError> { - // Ensure on-chain storage layout is compatible with this binary. - Self::assert_storage_layout_compatible(env)?; - - let key = DataKey::Frozen; - if env.storage().persistent().get::(&key).unwrap_or(false) { - return Err(RevoraError::ContractFrozen); - } - Ok(()) - } - - /// Ensure the on-chain storage layout is compatible with this binary. - /// - /// - If the on-chain layout version is greater than the compiled `STORAGE_LAYOUT_VERSION`, - /// reject with `MigrationDowngradeNotAllowed`. - /// - If the on-chain layout version is absent or older, stamp the storage with the - /// compiled `STORAGE_LAYOUT_VERSION` and emit `EVENT_LAYOUT_VERSION` to signal migration. - fn assert_storage_layout_compatible(env: &Env) -> Result<(), RevoraError> { - Self::assert_contract_version_compatible(env)?; - let key = DataKey::StorageLayoutVersion; - if let Some(stored_v) = env.storage().persistent().get::(&key) { - if stored_v > STORAGE_LAYOUT_VERSION { - return Err(RevoraError::MigrationDowngradeNotAllowed); - } - if stored_v < STORAGE_LAYOUT_VERSION { - env.storage().persistent().set(&key, &STORAGE_LAYOUT_VERSION); - env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); - } - } else { - // No layout stamp found: stamp it now (first-time initialize/migration path). - env.storage().persistent().set(&key, &STORAGE_LAYOUT_VERSION); - env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); - } - Ok(()) - } - - /// Ensure the loaded WASM version is not older than the persisted minimum supported version. - /// - /// On `initialize` the current `CONTRACT_VERSION` is persisted as the floor for all future - /// contract WASM binaries. `migrate_storage` ratchets this floor upward. If a WASM binary - /// with a lower `CONTRACT_VERSION` is deployed later, every state-mutating entrypoint is - /// blocked and a `downgrade_reject` event is emitted. - /// - /// # Errors - /// - [`RevoraError::MigrationDowngradeNotAllowed`] if `CONTRACT_VERSION < persisted version`. - fn assert_contract_version_compatible(env: &Env) -> Result<(), RevoraError> { - if let Some(min_supported) = - env.storage().persistent().get::(&DataKey::DeployedVersion) - { - if CONTRACT_VERSION < min_supported { - env.events().publish( - (Symbol::new(env, "downgrade_reject"),), - (CONTRACT_VERSION, min_supported), - ); - return Err(RevoraError::MigrationDowngradeNotAllowed); - } - } - Ok(()) + const META_AUTH_VERSION: u32 = 1; + + /// Returns error if contract is frozen (#32). Call at start of state-mutating entrypoints. + fn require_not_frozen(env: &Env) -> Result<(), RevoraError> { + // Ensure on-chain storage layout is compatible with this binary. + Self::assert_storage_layout_compatible(env)?; + + let key = DataKey::Frozen; + if env + .storage() + .persistent() + .get::(&key) + .unwrap_or(false) + { + return Err(RevoraError::ContractFrozen); } - - /// Returns true if the contract is in testnet mode (relaxed validation). - fn is_testnet_mode(env: Env) -> bool { - env.storage().persistent().get::(&DataKey::TestnetMode).unwrap_or(false) + Ok(()) + } + + /// Ensure the on-chain storage layout is compatible with this binary. + /// + /// - If the on-chain layout version is greater than the compiled `STORAGE_LAYOUT_VERSION`, + /// reject with `MigrationDowngradeNotAllowed`. + /// - If the on-chain layout version is absent or older, stamp the storage with the + /// compiled `STORAGE_LAYOUT_VERSION` and emit `EVENT_LAYOUT_VERSION` to signal migration. + fn assert_storage_layout_compatible(env: &Env) -> Result<(), RevoraError> { + Self::assert_contract_version_compatible(env)?; + let key = DataKey::StorageLayoutVersion; + if let Some(stored_v) = env.storage().persistent().get::(&key) + { + if stored_v > STORAGE_LAYOUT_VERSION { + return Err(RevoraError::MigrationDowngradeNotAllowed); + } + if stored_v < STORAGE_LAYOUT_VERSION { + env + .storage() + .persistent() + .set(&key, &STORAGE_LAYOUT_VERSION); + env + .events() + .publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); + } + } else { + // No layout stamp found: stamp it now (first-time initialize/migration path). + env + .storage() + .persistent() + .set(&key, &STORAGE_LAYOUT_VERSION); + env + .events() + .publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); + } + Ok(()) + } + + /// Ensure the loaded WASM version is not older than the persisted minimum supported version. + /// + /// On `initialize` the current `CONTRACT_VERSION` is persisted as the floor for all future + /// contract WASM binaries. `migrate_storage` ratchets this floor upward. If a WASM binary + /// with a lower `CONTRACT_VERSION` is deployed later, every state-mutating entrypoint is + /// blocked and a `downgrade_reject` event is emitted. + /// + /// # Errors + /// - [`RevoraError::MigrationDowngradeNotAllowed`] if `CONTRACT_VERSION < persisted version`. + fn assert_contract_version_compatible(env: &Env) -> Result<(), RevoraError> { + if let Some(min_supported) = env + .storage() + .persistent() + .get::(&DataKey::DeployedVersion) + { + if CONTRACT_VERSION < min_supported { + env.events().publish( + (Symbol::new(env, "downgrade_reject"),), + (CONTRACT_VERSION, min_supported), + ); + return Err(RevoraError::MigrationDowngradeNotAllowed); + } } + Ok(()) + } + + /// Returns true if the contract is in testnet mode (relaxed validation). + fn is_testnet_mode(env: Env) -> bool { + env + .storage() + .persistent() + .get::(&DataKey::TestnetMode) + .unwrap_or(false) + } + + /// Returns error if the specific offering is frozen. + fn require_not_offering_frozen( + env: &Env, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + if env + .storage() + .persistent() + .get::(&DataKey2::FrozenOffering(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::OfferingFrozen); + } + Ok(()) + } - /// Returns error if the specific offering is frozen. - fn require_not_offering_frozen(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { - if env - .storage() - .persistent() - .get::(&DataKey2::FrozenOffering(offering_id.clone())) - .unwrap_or(false) - { - return Err(RevoraError::OfferingFrozen); - } - Ok(()) + /// Require that enough issuers have authorized the operation (quorum check). + fn require_issuer_quorum_auth(env: &Env, issuers: &Issuers) { + // Collect all issuers (primary + co) + let mut all_issuers = Vec::new(env); + all_issuers.push_back(issuers.primary.clone()); + for co_issuer in issuers.co.iter() { + all_issuers.push_back(co_issuer.clone()); } - /// Require that enough issuers have authorized the operation (quorum check). - fn require_issuer_quorum_auth(env: &Env, issuers: &Issuers) { - // Collect all issuers (primary + co) - let mut all_issuers = Vec::new(env); - all_issuers.push_back(issuers.primary.clone()); - for co_issuer in issuers.co.iter() { - all_issuers.push_back(co_issuer.clone()); - } + // Count how many of them have authorized + let mut auth_count = 0u32; + for issuer in all_issuers.iter() { + if env.has_auth(&issuer) { + auth_count += 1; + } + } - // Count how many of them have authorized - let mut auth_count = 0u32; - for issuer in all_issuers.iter() { - if env.has_auth(&issuer) { - auth_count += 1; - } - } + // Ensure we meet the quorum + assert!(auth_count >= issuers.quorum, "Issuer quorum not met"); + } - // Ensure we meet the quorum - assert!(auth_count >= issuers.quorum, "Issuer quorum not met"); + /// Input validation (#35): require period_id > 0. + fn require_valid_period_id(period_id: u64) -> Result<(), RevoraError> { + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); } - - /// Input validation (#35): require period_id > 0. - fn require_valid_period_id(period_id: u64) -> Result<(), RevoraError> { - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - Ok(()) + Ok(()) + } + + /// Require that `caller` is a registered multisig owner. + fn require_multisig_owner( + env: &Env, + caller: &Address, + ) -> Result<(), RevoraError> { + let owners: Vec
= env + .storage() + .persistent() + .get(&DataKey2::MultisigOwners) + .ok_or(RevoraError::NotInitialized)?; + if !owners.contains(caller) { + return Err(RevoraError::NotAuthorized); } - - /// Require that `caller` is a registered multisig owner. - fn require_multisig_owner(env: &Env, caller: &Address) -> Result<(), RevoraError> { - let owners: Vec
= env - .storage() - .persistent() - .get(&DataKey2::MultisigOwners) - .ok_or(RevoraError::NotInitialized)?; - if !owners.contains(caller) { - return Err(RevoraError::NotAuthorized); - } - Ok(()) + Ok(()) + } + + /// Check if a holder is emergency frozen for an offering. + fn is_frozen(env: &Env, offering_id: &OfferingId, holder: &Address) -> bool { + // 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::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. + fn require_not_frozen( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> Result<(), RevoraError> { + if Self::is_frozen(env, offering_id, holder) { + return Err(RevoraError::HolderFrozen); } - - /// Check if a holder is emergency frozen for an offering. - fn is_frozen(env: &Env, offering_id: &OfferingId, holder: &Address) -> bool { - // 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::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. - fn require_not_frozen( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - ) -> Result<(), RevoraError> { - if Self::is_frozen(env, offering_id, holder) { - return Err(RevoraError::HolderFrozen); - } - Ok(()) + Ok(()) + } + + /// Enforce the dispute window for `IssuerDispute` freezes. + /// Checks that the current time is within the dispute window of the most + /// recently closed period. If no period has been closed, the check passes. + fn enforce_dispute_window( + env: &Env, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + // If no period has been closed yet, there is no deadline to enforce. + let last_closed = env.storage().persistent().get::( + &DataKey2::LastClosedPeriodTimestamp(offering_id.clone()), + ); + + if let Some(last_closed_at) = last_closed { + let window_secs = env + .storage() + .persistent() + .get::(&DataKey2::DisputeWindowSecs(offering_id.clone())) + .unwrap_or(DEFAULT_DISPUTE_WINDOW_SECS); + + let deadline = last_closed_at.saturating_add(window_secs); + let now = env.ledger().timestamp(); + + if now > deadline { + return Err(RevoraError::DisputeWindowClosed); + } } - - /// Enforce the dispute window for `IssuerDispute` freezes. - /// Checks that the current time is within the dispute window of the most - /// recently closed period. If no period has been closed, the check passes. - fn enforce_dispute_window(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { - // If no period has been closed yet, there is no deadline to enforce. - let last_closed = env - .storage() - .persistent() - .get::(&DataKey2::LastClosedPeriodTimestamp(offering_id.clone())); - - if let Some(last_closed_at) = last_closed { - let window_secs = env - .storage() - .persistent() - .get::(&DataKey2::DisputeWindowSecs(offering_id.clone())) - .unwrap_or(DEFAULT_DISPUTE_WINDOW_SECS); - - let deadline = last_closed_at.saturating_add(window_secs); - let now = env.ledger().timestamp(); - - if now > deadline { - return Err(RevoraError::DisputeWindowClosed); - } - } - Ok(()) - } - - /// Require that caller is either admin or issuer of the offering. - fn require_admin_or_issuer( - env: &Env, - caller: &Address, - offering_id: &OfferingId, - ) -> Result<(), RevoraError> { - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller == &admin || caller == &offering_id.issuer { - return Ok(()); - } - Err(RevoraError::NotAuthorized) - } - - /// Return the effective fee bps for (offering, asset): offering override > platform asset > platform global. - fn get_effective_fee_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - asset: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - // 1. Per-offering per-asset override - if let Some(bps) = env - .storage() - .persistent() - .get::(&DataKey::OfferingFeeBps(offering_id, asset.clone())) - { - return bps; - } - // 2. Platform per-asset fee - if let Some(bps) = - env.storage().persistent().get::(&DataKey::PlatformFeePerAsset(asset)) - { - return bps; - } - // 3. Global platform fee - env.storage().persistent().get::(&DataKey::PlatformFeeBps).unwrap_or(0) - } - - fn get_secondary_market_royalty_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - asset: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey::OfferingRoyaltyBps(offering_id, asset)) - .unwrap_or(0) + Ok(()) + } + + /// Require that caller is either admin or issuer of the offering. + fn require_admin_or_issuer( + env: &Env, + caller: &Address, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller == &admin || caller == &offering_id.issuer { + return Ok(()); + } + Err(RevoraError::NotAuthorized) + } + + /// Return the effective fee bps for (offering, asset): offering override > platform asset > platform global. + fn get_effective_fee_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + asset: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + // 1. Per-offering per-asset override + if let Some(bps) = env + .storage() + .persistent() + .get::(&DataKey::OfferingFeeBps(offering_id, asset.clone())) + { + return bps; } + // 2. Platform per-asset fee + if let Some(bps) = env + .storage() + .persistent() + .get::(&DataKey::PlatformFeePerAsset(asset)) + { + return bps; + } + // 3. Global platform fee + env + .storage() + .persistent() + .get::(&DataKey::PlatformFeeBps) + .unwrap_or(0) + } + + fn get_secondary_market_royalty_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + asset: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey::OfferingRoyaltyBps(offering_id, asset)) + .unwrap_or(0) + } + + /// 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; + }; - /// 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); + // 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, + T: IntoVal + + soroban_sdk::TryIntoVal, + { + env + .events() + .publish(topic_tuple, (EVENT_SCHEMA_VERSION_V2, data)); + } + + /// Dual-emit both V2 and V3 indexed events for the same state change. + /// + /// V2 subscribers continue to read `ev_idx2` unchanged. V3 subscribers + /// consume `ev_idx3` which carries `version=3` and the `_reserved` field + /// enabling additive schema evolution without struct reshuffles. + /// + /// Both events share the same `data` payload; the only difference is the + /// topic struct (V2 vs V3) and the outer topic symbol. + fn emit_v2_and_v3( + env: &Env, + topic_v2: EventIndexTopicV2, + topic_v3: EventIndexTopicV3, + data: D, + ) where + D: IntoVal + + soroban_sdk::TryIntoVal + + Clone, + { + env + .events() + .publish((EVENT_INDEXED_V2, topic_v2), data.clone()); + env.events().publish((EVENT_INDEXED_V3, topic_v3), data); + } + + fn jurisdiction_set_event(env: &Env) -> Symbol { + Symbol::new(env, "jur_set") + } + + fn jurisdiction_reject_event(env: &Env) -> Symbol { + Symbol::new(env, "jur_reject") + } + + fn is_event_versioning_enabled(_env: Env) -> bool { + true + } + + /// Return `true` if V2-compat downgrade mode is enabled. + /// + /// When enabled, V2-shaped indexed events (`EVENT_INDEXED_V2`) are emitted + /// alongside V3 events, allowing indexers pinned to V2 to continue working + /// during the deprecation window. + /// + /// Defaults to `true` (emit V2 events). Admin can disable via `set_emit_v2_compat`. + fn is_emit_v2_compat(env: &Env) -> bool { + env + .storage() + .persistent() + .get::(&DataKey2::EmitV2Compat) + .unwrap_or(true) + } + + /// Emit both V2 and V3 indexed events, suppressing the V2 emission when the + /// `emit_v2_compat` flag is disabled. + /// + /// V3 events are always emitted. V2 events are only emitted when `emit_v2_compat` + /// is `true` (the default during the deprecation window). + fn emit_v2_and_v3( + env: &Env, + v2_topic: EventIndexTopicV2, + v3_topic: EventIndexTopicV3, + data: T, + ) where + T: IntoVal + Clone, + { + // V3 is always emitted — it is the current canonical event schema. + env + .events() + .publish((EVENT_INDEXED_V3, v3_topic), data.clone()); + // V2 is only emitted when the compat flag is on (downgrade path). + if Self::is_emit_v2_compat(env) { + env.events().publish((EVENT_INDEXED_V2, v2_topic), data); + } + } + + /// Advance the cumulative accrual index for an offering and emit an `acc_idx` indexed event. + /// + /// The index accumulates `(amount * 1e18) / 10_000` per accepted revenue report, expressing + /// cumulative revenue in 1e18 fixed-point per basis-point of holder share. This lets + /// off-chain indexers reconstruct per-holder owed amounts without re-reading all periods. + /// + /// Skips silently when `amount == 0` (no-op report). + fn update_and_emit_accrual_index( + env: &Env, + offering_id: &OfferingId, + amount: i128, + period_id: u64, + ) { + if amount == 0 { + return; + } + const E18: i128 = 1_000_000_000_000_000_000; + const BPS_MAX: i128 = 10_000; + let idx_key = DataKey2::AccrualIndex(offering_id.clone()); + let current: i128 = env.storage().persistent().get(&idx_key).unwrap_or(0); + let delta = amount.saturating_mul(E18).checked_div(BPS_MAX).unwrap_or(0); + let new_idx = current.saturating_add(delta); + env.storage().persistent().set(&idx_key, &new_idx); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: INDEXER_EVENT_SCHEMA_VERSION, + event_type: EVENT_TYPE_ACC_IDX, + issuer: offering_id.issuer.clone(), + namespace: offering_id.namespace.clone(), + token: offering_id.token.clone(), + period_id, + }, + ), + (new_idx,), + ); + } + + fn validate_window(window: &AccessWindow) -> Result<(), RevoraError> { + if window.start_timestamp > window.end_timestamp { + return Err(RevoraError::LimitReached); + } + Ok(()) + } - // Compute new aggregate with saturating arithmetic. - let new_aggregate = old_aggregate.saturating_add(delta_bps); - env.storage().persistent().set(&agg_key, &new_aggregate); + fn require_valid_meta_nonce_and_expiry( + env: &Env, + signer: &Address, + nonce: u64, + expiry: u64, + ) -> Result<(), RevoraError> { + if env.ledger().timestamp() > expiry { + return Err(RevoraError::SignatureExpired); + } + let nonce_key = MetaDataKey::NonceUsed(signer.clone(), nonce); + if env.storage().persistent().has(&nonce_key) { + return Err(RevoraError::SignatureReplay); + } + Ok(()) + } + + fn is_window_open(env: &Env, window: &AccessWindow) -> bool { + let now = env.ledger().timestamp(); + now >= window.start_timestamp && now <= window.end_timestamp + } + + fn require_report_window_open( + env: &Env, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + let key = WindowDataKey::Report(offering_id.clone()); + if let Some(window) = env + .storage() + .persistent() + .get::(&key) + { + if !Self::is_window_open(env, &window) { + return Err(RevoraError::ReportingWindowClosed); + } + } + Ok(()) + } + + fn require_claim_window_open( + env: &Env, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + let key = WindowDataKey::Claim(offering_id.clone()); + if let Some(window) = env + .storage() + .persistent() + .get::(&key) + { + if !Self::is_window_open(env, &window) { + return Err(RevoraError::ClaimWindowClosed); + } + } + Ok(()) + } + + fn require_redemption_window_open( + env: &Env, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + let key = WindowDataKey::Redemption(offering_id.clone()); + if let Some(window) = env + .storage() + .persistent() + .get::(&key) + { + if !Self::is_window_open(env, &window) { + return Err(RevoraError::RedemptionWindowClosed); + } + } + Ok(()) + } + + fn mark_meta_nonce_used(env: &Env, signer: &Address, nonce: u64) { + let nonce_key = MetaDataKey::NonceUsed(signer.clone(), nonce); + env.storage().persistent().set(&nonce_key, &true); + } + + fn verify_meta_signature( + env: &Env, + signer: &Address, + nonce: u64, + expiry: u64, + action: MetaAction, + signature: &BytesN<64>, + ) -> Result<(), RevoraError> { + Self::require_valid_meta_nonce_and_expiry(env, signer, nonce, expiry)?; + let pk_key = MetaDataKey::SignerKey(signer.clone()); + let public_key: BytesN<32> = env + .storage() + .persistent() + .get(&pk_key) + .ok_or(RevoraError::SignerKeyNotRegistered)?; + let payload = MetaAuthorization { + version: Self::META_AUTH_VERSION, + contract: env.current_contract_address(), + signer: signer.clone(), + nonce, + expiry, + action, + }; + let payload_bytes = payload.to_xdr(env); + env + .crypto() + .ed25519_verify(&public_key, &payload_bytes, signature); + Ok(()) + } + + fn set_holder_share_internal( + env: &Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_bps: u32, + share_class: Option, + nonce: Option, + ) -> Result<(), RevoraError> { + if share_bps > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Emit the regulatory-limit delta event. + // ── Nonce monotonicity guard ────────────────────────────────────────── + // When a nonce is supplied the caller guarantees this update is strictly + // newer than any previously accepted one. Reject equal or smaller values + // to prevent replayed or out-of-order off-chain updates from silently + // overwriting newer on-chain share state. + if let Some(n) = nonce { + let nonce_key = + DataKey2::HolderShareNonce(offering_id.clone(), holder.clone()); + let last_nonce: u64 = + env.storage().persistent().get(&nonce_key).unwrap_or(0); + if n <= last_nonce { + return Err(RevoraError::StaleNonce); + } + env.storage().persistent().set(&nonce_key, &n); + } + + // Check max total supply shares cap + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); + let max_shares: i128 = + env.storage().persistent().get(&max_shares_key).unwrap_or(0); + if max_shares > 0 { + let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); + let current_total_shares: i128 = env + .storage() + .persistent() + .get(&total_shares_key) + .unwrap_or(0); + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + let new_total_shares = current_total_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); + if new_total_shares > max_shares { + return Err(RevoraError::MaxTotalSupplySharesExceeded); + } + if new_total_shares == max_shares { 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), + ( + EVENT_SUPPLY_CAP_SATURATED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (new_total_shares, max_shares), + ); + } + } + + // Maintain a running total of persisted holder shares for this offering. + let total_key = DataKey::HolderShareTotal(offering_id.clone()); + let mut current_total: u32 = + env.storage().persistent().get(&total_key).unwrap_or(0); + + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + let classes: Option> = + env.storage().persistent().get(&classes_key); + + if let Some(cls_vec) = classes { + let sc = match share_class { + Some(ref sc) => sc.clone(), + None => return Err(RevoraError::InvalidShareClass), + }; + let mut found = false; + for (class_name, _) in cls_vec.iter() { + if class_name == sc { + found = true; + break; + } + } + } + + let new_total = current_total + .s_sub(old_share) + .unwrap_or(0) + .s_add(share_bps) + .unwrap_or(u32::MAX); + if new_total > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + + // Update total shares issued + let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); + let current_total_shares: i128 = env + .storage() + .persistent() + .get(&total_shares_key) + .unwrap_or(0); + let new_total_shares = current_total_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); + env + .storage() + .persistent() + .set(&total_shares_key, &new_total_shares); + + if let Some(ref sc) = share_class { + let class_shares_key = + DataKey2::TotalClassSharesIssued(offering_id.clone(), sc.clone()); + let current_class_shares: i128 = env + .storage() + .persistent() + .get(&class_shares_key) + .unwrap_or(0); + let new_class_shares = current_class_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); + env + .storage() + .persistent() + .set(&class_shares_key, &new_class_shares); + } + + // Persist updated holder share and running total. + env.storage().persistent().set( + &DataKey::HolderShare(offering_id.clone(), holder.clone()), + &share_bps, + ); + 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), + ); + // Versioned v2 event: [2, holder, share_bps] — always emitted (#RC26Q2-C31) + Self::emit_v2_event( + env, + (EVENT_SHARE_SET_V2, issuer, namespace, token), + (holder, share_bps), + ); + Ok(()) + } + + fn get_period_count_internal(env: &Env, offering_id: &OfferingId) -> u32 { + env + .storage() + .persistent() + .get::<_, u32>(&DataKey::PeriodCount(offering_id.clone())) + .unwrap_or(0) + } + + fn accrual_delta_e18(amount: i128) -> i128 { + amount + .checked_mul(ACCRUAL_SCALE_E18) + .unwrap_or(i128::MAX) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0) + } + + fn get_acc_per_share_at_index( + env: &Env, + offering_id: &OfferingId, + index: u32, + ) -> i128 { + if index == 0 { + return 0; + } + env + .storage() + .persistent() + .get::<_, i128>(&DataKey2::AccPerShareAtIndex(offering_id.clone(), index)) + .unwrap_or(0) + } + + fn get_holder_share_schedule( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> Vec { + if let Some(schedule) = env + .storage() + .persistent() + .get::<_, Vec>(&DataKey2::HolderShareSchedule( + offering_id.clone(), + holder.clone(), + )) + { + return schedule; + } + + let current_share: u32 = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + let mut schedule = Vec::new(env); + if current_share > 0 { + schedule.push_back(HolderShareCheckpoint { + start_index: 0, + share_bps: current_share, + }); + } + schedule + } + + fn get_checkpoint_threshold(env: &Env, offering_id: &OfferingId) -> u32 { + let key = DataKey2::CheckpointThreshold(offering_id.clone()); + env + .storage() + .persistent() + .get(&key) + .unwrap_or(CHECKPOINT_THRESHOLD_DEFAULT) + } + + /// Compute the pre-claimable sum for a range of period indices using the + /// holder's share schedule and the global `AccPerShareAtIndex` values. + /// + /// This is a lossless computation: the result equals what + /// `compute_holder_payout_for_range` would produce for the same + /// `[start_idx, end_idx)` interval, but without iterating through + /// every period index individually. + /// + /// The sum is: + /// `sum_i ( (AccPerShareAtIndex[i+1] - AccPerShareAtIndex[i]) * share_bps_i / 1e18 )` + fn compute_anchor_claimable_sum( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + start_idx: u32, + end_idx: u32, + ) -> i128 { + if start_idx >= end_idx { + return 0; + } + let schedule = Self::get_holder_share_schedule(env, offering_id, holder); + if schedule.is_empty() { + return 0; + } + let mut total = 0_i128; + let mut current_index = start_idx; + let mut current_share = 0_u32; + let mut schedule_idx = 0_u32; + + while schedule_idx < schedule.len() { + let checkpoint = schedule.get(schedule_idx).unwrap(); + if checkpoint.start_index > start_idx { + break; + } + current_share = checkpoint.share_bps; + schedule_idx = schedule_idx.saturating_add(1); + } + + while current_index < end_idx { + while schedule_idx < schedule.len() { + let checkpoint = schedule.get(schedule_idx).unwrap(); + if checkpoint.start_index > current_index { + break; + } + current_share = checkpoint.share_bps; + schedule_idx = schedule_idx.saturating_add(1); + } + + if current_share > 0 { + let acc_end = Self::get_acc_per_share_at_index( + env, + offering_id, + current_index.saturating_add(1), ); + let acc_start = + Self::get_acc_per_share_at_index(env, offering_id, current_index); + let delta = acc_end.saturating_sub(acc_start); + total = total.saturating_add( + delta.saturating_mul(current_share as i128) / ACCRUAL_SCALE_E18, + ); + } + + current_index = current_index.saturating_add(1); + } + + total + } + + fn record_holder_share_transition( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + old_share: u32, + new_share: u32, + ) { + if old_share == new_share { + return; + } + + let period_count = Self::get_period_count_internal(env, offering_id); + let existing = Self::get_holder_share_schedule(env, offering_id, holder); + let mut updated = Vec::new(env); + + for i in 0..existing.len() { + let checkpoint = existing.get(i).unwrap(); + if checkpoint.start_index == period_count { + continue; + } + updated.push_back(checkpoint); + } + + updated.push_back(HolderShareCheckpoint { + start_index: period_count, + share_bps: new_share, + }); + env.storage().persistent().set( + &DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), + &updated, + ); + } + + fn get_holder_accrual_state( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> HolderAccrualState { + let last_claimed_idx: u32 = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::LastClaimedIdx( + offering_id.clone(), + holder.clone(), + )) + .unwrap_or(0); + + let mut state = env + .storage() + .persistent() + .get::<_, HolderAccrualState>(&DataKey2::HolderAccrualState( + offering_id.clone(), + holder.clone(), + )) + .unwrap_or(HolderAccrualState { + last_settled_idx: last_claimed_idx, + last_acc_per_share_e18: Self::get_acc_per_share_at_index( + env, + offering_id, + last_claimed_idx, + ), + accrued_owed: 0, + }); + + if state.last_settled_idx < last_claimed_idx { + state.last_settled_idx = last_claimed_idx; + state.last_acc_per_share_e18 = + Self::get_acc_per_share_at_index(env, offering_id, last_claimed_idx); + state.accrued_owed = 0; + } + + state + } + + fn compute_holder_payout_for_range( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + start_idx: u32, + end_idx: u32, + ) -> i128 { + if start_idx >= end_idx { + return 0; + } + + let schedule = Self::get_holder_share_schedule(env, offering_id, holder); + if schedule.is_empty() { + return 0; + } + + let mut total = 0_i128; + let mut current_index = start_idx; + let mut current_share = 0_u32; + let mut schedule_idx = 0_u32; + + while schedule_idx < schedule.len() { + let checkpoint = schedule.get(schedule_idx).unwrap(); + if checkpoint.start_index > start_idx { + break; + } + current_share = checkpoint.share_bps; + schedule_idx = schedule_idx.saturating_add(1); + } + + while current_index < end_idx { + while schedule_idx < schedule.len() { + let checkpoint = schedule.get(schedule_idx).unwrap(); + if checkpoint.start_index > current_index { + break; + } + current_share = checkpoint.share_bps; + schedule_idx = schedule_idx.saturating_add(1); + } + + if current_share > 0 { + let acc_end = Self::get_acc_per_share_at_index( + env, + offering_id, + current_index.saturating_add(1), + ); + let acc_start = + Self::get_acc_per_share_at_index(env, offering_id, current_index); + let delta = acc_end.saturating_sub(acc_start); + total = total.saturating_add( + delta.saturating_mul(current_share as i128) / ACCRUAL_SCALE_E18, + ); + } + + current_index = current_index.saturating_add(1); + } + + total + } + + fn find_matured_claim_end_idx( + env: &Env, + offering_id: &OfferingId, + start_idx: u32, + ) -> u32 { + let period_count = Self::get_period_count_internal(env, offering_id); + if start_idx >= period_count { + return start_idx; + } + + let delay_secs: u64 = env + .storage() + .persistent() + .get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone())) + .unwrap_or(0); + let now = env.ledger().timestamp(); + let mut end_idx = start_idx; + + while end_idx < period_count { + let entry_key = DataKey::PeriodEntry(offering_id.clone(), end_idx); + let period_id: u64 = + env.storage().persistent().get(&entry_key).unwrap_or(0); + if period_id == 0 { + end_idx = end_idx.saturating_add(1); + continue; + } + + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_time: u64 = + env.storage().persistent().get(&time_key).unwrap_or(0); + if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { + break; + } + + end_idx = end_idx.saturating_add(1); + } + + end_idx + } + + fn cache_holder_accrual_through_matured( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) { + let mut state = Self::get_holder_accrual_state(env, offering_id, holder); + let matured_end = Self::find_matured_claim_end_idx( + env, + offering_id, + state.last_settled_idx, + ); + if matured_end <= state.last_settled_idx { + return; + } + + let anchor_key = + DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); + let anchor: Option = + env.storage().persistent().get(&anchor_key); + + let mut delta: i128 = 0; + let mut schedule_start = state.last_settled_idx; + + if let Some(a) = anchor { + if state.last_settled_idx <= a.end_idx { + let anchor_start = state.last_settled_idx; + let anchor_end_incl = + core::cmp::min(matured_end, a.end_idx.saturating_add(1)); + if anchor_start < anchor_end_incl { + delta = delta.saturating_add(a.claimable_sum); + } + schedule_start = + core::cmp::max(schedule_start, a.end_idx.saturating_add(1)); + if matured_end > a.end_idx { + env.storage().persistent().remove(&anchor_key); + } + } + } + + if schedule_start < matured_end { + delta = delta.saturating_add(Self::compute_holder_payout_for_range( + env, + offering_id, + holder, + schedule_start, + matured_end, + )); + } + + state.accrued_owed = state.accrued_owed.saturating_add(delta); + state.last_settled_idx = matured_end; + state.last_acc_per_share_e18 = + Self::get_acc_per_share_at_index(env, offering_id, matured_end); + + env.storage().persistent().set( + &DataKey2::HolderAccrualState(offering_id.clone(), holder.clone()), + &state, + ); + } + + fn normalize_jurisdictions( + env: &Env, + jurisdictions: Vec, + ) -> Vec { + let mut normalized = Vec::new(env); + for i in 0..jurisdictions.len() { + let jurisdiction = jurisdictions.get(i).unwrap(); + if !Self::vec_contains_symbol(&normalized, &jurisdiction) { + normalized.push_back(jurisdiction); + } + } + normalized + } + + fn vec_contains_symbol(values: &Vec, target: &Symbol) -> bool { + for i in 0..values.len() { + if values.get(i).unwrap() == *target { + return true; + } + } + false + } + + fn get_allowed_jurisdictions_internal( + env: &Env, + offering_id: &OfferingId, + ) -> Vec { + env + .storage() + .persistent() + .get(&DataKey2::AllowedJurisdictions(offering_id.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + fn get_holder_jurisdiction_internal( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::HolderJurisdiction( + offering_id.clone(), + holder.clone(), + )) + } + + fn emit_jurisdiction_reject( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + jurisdiction: Symbol, + action: Symbol, + ) { + env.events().publish( + ( + Self::jurisdiction_reject_event(env), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder.clone(), jurisdiction, action), + ); + } + + fn require_holder_jurisdiction_allowed( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + action: Symbol, + ) -> Result<(), RevoraError> { + let allowed = Self::get_allowed_jurisdictions_internal(env, offering_id); + if allowed.is_empty() { + return Ok(()); + } + + let jurisdiction = + Self::get_holder_jurisdiction_internal(env, offering_id, holder) + .unwrap_or(EVENT_JUR_UNSET); + + if Self::vec_contains_symbol(&allowed, &jurisdiction) { + return Ok(()); + } + + Self::emit_jurisdiction_reject( + env, + offering_id, + holder, + jurisdiction, + action, + ); + Err(RevoraError::JurisdictionDisallowed) + } + + /// Return the per-offering jurisdiction migration grace period in seconds. + /// Defaults to [`DEFAULT_JURISDICTION_GRACE_SECS`] (7 days) when not configured. + fn get_jurisdiction_grace_secs(env: &Env, offering_id: &OfferingId) -> u64 { + env + .storage() + .persistent() + .get::(&DataKey2::JurisdictionGracePeriod( + offering_id.clone(), + )) + .unwrap_or(DEFAULT_JURISDICTION_GRACE_SECS) + } + + /// Check whether a pending jurisdiction migration has passed its deadline. + /// + /// If the holder has a pending migration whose deadline has elapsed, this function + /// checks whether the *new* (target) jurisdiction is allowed. If the new jurisdiction + /// is disallowed, returns `Err(JurisdictionMigrationDeadlineExceeded)`. + /// + /// When the new jurisdiction **is** allowed, the migration is finalized: the holder's + /// jurisdiction is updated and the pending migration entry is cleared. + /// + /// If there is no pending migration, or the deadline has not yet passed, returns `Ok(())`. + fn require_jurisdiction_migration_not_expired( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + action: Symbol, + ) -> Result<(), RevoraError> { + let mig_key = + DataKey2::JurisdictionMigration(offering_id.clone(), holder.clone()); + let migration = env + .storage() + .persistent() + .get::(&mig_key); + + let Some(migration) = migration else { + return Ok(()); + }; + + let now = env.ledger().timestamp(); + if now < migration.deadline { + // Grace period still active + return Ok(()); + } + + // Deadline has passed — check if the new jurisdiction is allowed + let allowed = Self::get_allowed_jurisdictions_internal(env, offering_id); + + let new_ok = allowed.is_empty() + || Self::vec_contains_symbol(&allowed, &migration.new_jurisdiction); + + if new_ok { + // Migration is allowed — finalize it + env.storage().persistent().set( + &DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), + &migration.new_jurisdiction, + ); + env.storage().persistent().remove(&mig_key); + return Ok(()); + } + + Self::emit_jurisdiction_reject( + env, + offering_id, + holder, + migration.new_jurisdiction, + action, + ); + Err(RevoraError::JurisdictionMigrationDeadlineExceeded) + } + + /// Return the explicitly persisted payment token lock for an offering, if any. + /// + /// The `PaymentToken` key is written only after the first successful deposit. + /// Before that point, the offering has no locked payment token. + fn get_locked_payment_token_for_offering( + env: &Env, + offering_id: &OfferingId, + ) -> Option
{ + let pt_key = DataKey::PaymentToken(offering_id.clone()); + env.storage().persistent().get::(&pt_key) + } + + /// Internal helper for revenue deposits. + /// Validates amount using the Negative Amount Validation Matrix (#163). + fn do_deposit_revenue( + env: &Env, + issuer: Address, + namespace: Symbol, + token: Address, + payment_token: Address, + amount: i128, + period_id: u64, + ) -> Result<(), RevoraError> { + // Negative Amount Validation Matrix: RevenueDeposit requires amount > 0 (#163) + if let Err((err, reason)) = AmountValidationMatrix::validate( + amount, + AmountValidationCategory::RevenueDeposit, + ) { + env.events().publish( + ( + EVENT_AMOUNT_VALIDATION_FAILED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, err as u32, reason), + ); + return Err(err); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + // Validate inputs (#35) + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); } + Self::require_positive_amount(amount)?; - fn emit_v2_event(env: &Env, topic_tuple: Topics, data: T) - where - Topics: IntoVal + soroban_sdk::events::Topics, - T: IntoVal + soroban_sdk::TryIntoVal, + // Verify offering exists + if Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .is_none() { - env.events().publish(topic_tuple, (EVENT_SCHEMA_VERSION_V2, data)); - } - - /// Dual-emit both V2 and V3 indexed events for the same state change. - /// - /// V2 subscribers continue to read `ev_idx2` unchanged. V3 subscribers - /// consume `ev_idx3` which carries `version=3` and the `_reserved` field - /// enabling additive schema evolution without struct reshuffles. - /// - /// Both events share the same `data` payload; the only difference is the - /// topic struct (V2 vs V3) and the outer topic symbol. - fn emit_v2_and_v3( - env: &Env, - topic_v2: EventIndexTopicV2, - topic_v3: EventIndexTopicV3, - data: D, - ) where - D: IntoVal + soroban_sdk::TryIntoVal + Clone, - { - env.events().publish((EVENT_INDEXED_V2, topic_v2), data.clone()); - env.events().publish((EVENT_INDEXED_V3, topic_v3), data); + return Err(RevoraError::OfferingNotFound); } - fn jurisdiction_set_event(env: &Env) -> Symbol { - Symbol::new(env, "jur_set") - } + let last_period_key = DataKey2::LastDepositedPeriodId(offering_id.clone()); - fn jurisdiction_reject_event(env: &Env) -> Symbol { - Symbol::new(env, "jur_reject") + // Check period not already deposited + let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); + if env.storage().persistent().has(&rev_key) { + return Err(RevoraError::PeriodAlreadyDeposited); } - fn is_event_versioning_enabled(_env: Env) -> bool { - true - } + // Enforce period ordering invariant only after duplicate detection so repeated + // deposits fail with the period-specific error rather than a generic sequence error. + Self::require_next_period_id(env, last_period_key.clone(), period_id)?; - /// Return `true` if V2-compat downgrade mode is enabled. - /// - /// When enabled, V2-shaped indexed events (`EVENT_INDEXED_V2`) are emitted - /// alongside V3 events, allowing indexers pinned to V2 to continue working - /// during the deprecation window. - /// - /// Defaults to `true` (emit V2 events). Admin can disable via `set_emit_v2_compat`. - fn is_emit_v2_compat(env: &Env) -> bool { - env.storage().persistent().get::(&DataKey2::EmitV2Compat).unwrap_or(true) + // Supply cap check (#96): reject if deposit would exceed cap + let cap_key = DataKey2::SupplyCap(offering_id.clone()); + let cap: i128 = env.storage().persistent().get(&cap_key).unwrap_or(0); + if cap > 0 { + let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); + let deposited: i128 = + env.storage().persistent().get(&deposited_key).unwrap_or(0); + let new_total = deposited.s_add(amount)?; + if new_total > cap { + return Err(RevoraError::SupplyCapExceeded); + } } - /// Emit both V2 and V3 indexed events, suppressing the V2 emission when the - /// `emit_v2_compat` flag is disabled. - /// - /// V3 events are always emitted. V2 events are only emitted when `emit_v2_compat` - /// is `true` (the default during the deprecation window). - fn emit_v2_and_v3( - env: &Env, - v2_topic: EventIndexTopicV2, - v3_topic: EventIndexTopicV3, - data: T, - ) where - T: IntoVal + Clone, + let pt_key = DataKey::PaymentToken(offering_id.clone()); + if let Some(locked_payment_token) = + Self::get_locked_payment_token_for_offering(env, &offering_id) { - // V3 is always emitted — it is the current canonical event schema. - env.events().publish((EVENT_INDEXED_V3, v3_topic), data.clone()); - // V2 is only emitted when the compat flag is on (downgrade path). - if Self::is_emit_v2_compat(env) { - env.events().publish((EVENT_INDEXED_V2, v2_topic), data); - } + if locked_payment_token != payment_token { + return Err(RevoraError::PaymentTokenMismatch); + } } - /// Advance the cumulative accrual index for an offering and emit an `acc_idx` indexed event. - /// - /// The index accumulates `(amount * 1e18) / 10_000` per accepted revenue report, expressing - /// cumulative revenue in 1e18 fixed-point per basis-point of holder share. This lets - /// off-chain indexers reconstruct per-holder owed amounts without re-reading all periods. - /// - /// Skips silently when `amount == 0` (no-op report). - fn update_and_emit_accrual_index( - env: &Env, - offering_id: &OfferingId, - amount: i128, - period_id: u64, + // Transfer tokens from issuer to contract + let contract_addr = env.current_contract_address(); + if token::Client::new(env, &payment_token) + .try_transfer(&issuer, &contract_addr, &amount) + .is_err() + { + return Err(RevoraError::TransferFailed); + } + + // Store period revenue + env.storage().persistent().set(&rev_key, &amount); + + if !env.storage().persistent().has(&pt_key) { + env.storage().persistent().set(&pt_key, &payment_token); + } + + // Store deposit timestamp for time-delayed claims (#27) + let deposit_time = env.ledger().timestamp(); + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + env.storage().persistent().set(&time_key, &deposit_time); + + // Append to indexed period list + let count_key = DataKey::PeriodCount(offering_id.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + let entry_key = DataKey::PeriodEntry(offering_id.clone(), count); + env.storage().persistent().set(&entry_key, &period_id); + env.storage().persistent().set(&count_key, &(count + 1)); + Self::commit_period_id(env, last_period_key, period_id); + + let decimals = Self::get_payment_token_decimals( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ); + let normalized = Self::normalize_amount(amount, decimals); + let acc_delta_e18 = Self::accrual_delta_e18(normalized); + let global_acc_key = DataKey2::GlobalAccPerShareE18(offering_id.clone()); + let current_acc: i128 = + env.storage().persistent().get(&global_acc_key).unwrap_or(0); + let next_acc = current_acc.saturating_add(acc_delta_e18); + env.storage().persistent().set(&global_acc_key, &next_acc); + env.storage().persistent().set( + &DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), + &next_acc, + ); + + // Update cumulative deposited revenue and emit cap-reached event if applicable (#96) + let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); + let deposited: i128 = + env.storage().persistent().get(&deposited_key).unwrap_or(0); + let new_deposited = deposited.s_add(amount)?; + env + .storage() + .persistent() + .set(&deposited_key, &new_deposited); + + let cap_val: i128 = env.storage().persistent().get(&cap_key).unwrap_or(0); + if cap_val > 0 && new_deposited >= cap_val { + env.events().publish( + ( + EVENT_SUPPLY_CAP_REACHED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (new_deposited, cap_val), + ); + } + + // Update the e18 accrual index + let decimals = Self::get_payment_token_decimals( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ); + let normalized_amount = Self::normalize_amount(amount, decimals); + let total_share_bps_key = DataKey::HolderShareTotal(offering_id.clone()); + let total_share_bps: u32 = env + .storage() + .persistent() + .get(&total_share_bps_key) + .unwrap_or(0); + + if total_share_bps > 0 { + let accrual_delta = (normalized_amount.checked_mul(E18)) + .and_then(|x| x.checked_div(total_share_bps as i128)) + .unwrap_or(0); + let current_accrual_key = DataKey::AccrualIndexE18(offering_id.clone()); + let current_accrual: i128 = env + .storage() + .persistent() + .get(¤t_accrual_key) + .unwrap_or(0); + let new_accrual = current_accrual + .checked_add(accrual_delta) + .unwrap_or(current_accrual); + env + .storage() + .persistent() + .set(¤t_accrual_key, &new_accrual); + } + + // Versioned event v2: [version: u32, payment_token: Address, amount: i128, period_id: u64] + Self::emit_v2_event( + env, + ( + EVENT_REV_DEPOSIT_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payment_token, amount, period_id), + ); + env.events().publish( + (EVENT_ACC_UPD, issuer, namespace, token), + (period_id, count + 1, acc_delta_e18, next_acc), + ); + Ok(()) + } + + /// Return the supply cap for an offering (0 = no cap). (#96) + pub fn get_supply_cap( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> i128 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::SupplyCap(offering_id)) + .unwrap_or(0) + } + + pub fn set_max_total_supply_shares( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + max_total_supply_shares: i128, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + if let Err((err, _)) = AmountValidationMatrix::validate( + max_total_supply_shares, + AmountValidationCategory::MaxTotalSupplyShares, ) { - if amount == 0 { - return; - } - const E18: i128 = 1_000_000_000_000_000_000; - const BPS_MAX: i128 = 10_000; - let idx_key = DataKey2::AccrualIndex(offering_id.clone()); - let current: i128 = env.storage().persistent().get(&idx_key).unwrap_or(0); - let delta = amount.saturating_mul(E18).checked_div(BPS_MAX).unwrap_or(0); - let new_idx = current.saturating_add(delta); - env.storage().persistent().set(&idx_key, &new_idx); - env.events().publish( - ( - EVENT_INDEXED_V2, - EventIndexTopicV2 { - version: INDEXER_EVENT_SCHEMA_VERSION, - event_type: EVENT_TYPE_ACC_IDX, - issuer: offering_id.issuer.clone(), - namespace: offering_id.namespace.clone(), - token: offering_id.token.clone(), - period_id, - }, - ), - (new_idx,), - ); + return Err(err); } - fn validate_window(window: &AccessWindow) -> Result<(), RevoraError> { - if window.start_timestamp > window.end_timestamp { - return Err(RevoraError::LimitReached); - } - Ok(()) + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id); + if max_total_supply_shares > 0 { + env + .storage() + .persistent() + .set(&max_shares_key, &max_total_supply_shares); + } else { + env.storage().persistent().remove(&max_shares_key); } + Ok(()) + } + + pub fn get_max_total_supply_shares( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> i128 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::MaxTotalSupplyShares(offering_id)) + .unwrap_or(0) + } + + pub fn get_total_shares_issued( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> i128 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::TotalSharesIssued(offering_id)) + .unwrap_or(0) + } + + pub fn get_total_class_shares_issued( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + share_class: ShareClass, + ) -> i128 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::TotalClassSharesIssued(offering_id, share_class)) + .unwrap_or(0) + } + + // ── Fee BPS Configuration (#98) ────────────────────────────────────────── + + /// Set the global platform fee in basis points. Admin-only. (#98) + /// + /// Emits `EVENT_PLATFORM_FEE_SET` with the new `fee_bps` value. + /// + /// ### Errors + /// - `NotInitialized` — contract not yet initialized. + /// - `InvalidRevenueShareBps` — `fee_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). + pub fn set_platform_fee(env: Env, fee_bps: u32) -> Result<(), RevoraError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + if fee_bps > MAX_PLATFORM_FEE_BPS { + return Err(RevoraError::InvalidRevenueShareBps); + } + env + .storage() + .persistent() + .set(&DataKey::PlatformFeeBps, &fee_bps); + env.events().publish((EVENT_PLATFORM_FEE_SET,), fee_bps); + Ok(()) + } + + /// Return the global platform fee in basis points (0 = no fee). (#98) + /// + /// O(1) — single persistent storage read. + pub fn get_platform_fee(env: Env) -> u32 { + env + .storage() + .persistent() + .get(&DataKey::PlatformFeeBps) + .unwrap_or(0) + } + + /// Calculate the platform fee for `amount` using the stored global platform fee BPS. (#98) + /// + /// O(1) — one storage read plus integer arithmetic; no storage writes. + pub fn calculate_platform_fee(env: Env, amount: i128) -> i128 { + let fee_bps: i128 = env + .storage() + .persistent() + .get::(&DataKey::PlatformFeeBps) + .unwrap_or(0) as i128; + (amount * fee_bps).checked_div(BPS_DENOMINATOR).unwrap_or(0) + } + + /// Set a per-offering per-asset fee override in basis points. Issuer-only. (#98) + /// + /// Emits `EVENT_FEE_CONFIG` with `(issuer, namespace, token, asset, fee_bps)`. + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist or caller is not the issuer. + /// - `InvalidRevenueShareBps` — `fee_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). + pub fn set_offering_fee_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + asset: Address, + fee_bps: u32, + ) -> Result<(), RevoraError> { + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + if fee_bps > MAX_PLATFORM_FEE_BPS { + return Err(RevoraError::InvalidRevenueShareBps); + } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage().persistent().set( + &DataKey::OfferingFeeBps(offering_id, asset.clone()), + &fee_bps, + ); + env + .events() + .publish((EVENT_FEE_CONFIG, issuer, namespace, token, asset), fee_bps); + Ok(()) + } + + /// Return the per-offering per-asset fee override in basis points (0 = use platform default). (#98) + /// + /// O(1) — single persistent storage read. + pub fn get_offering_fee_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + asset: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey::OfferingFeeBps(offering_id, asset)) + .unwrap_or(0) + } + + /// Set a per-offering per-asset secondary-market royalty in basis points. Issuer-only. (#562) + /// + /// Emits `EVENT_ROYALTY_CONFIG` with `(issuer, namespace, token, asset, royalty_bps)`. + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist or caller is not the issuer. + /// - `InvalidRevenueShareBps` — `royalty_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). + pub fn set_secondary_market_royalty_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + asset: Address, + royalty_bps: u32, + ) -> Result<(), RevoraError> { + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + if royalty_bps > MAX_PLATFORM_FEE_BPS { + return Err(RevoraError::InvalidRevenueShareBps); + } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage().persistent().set( + &DataKey::OfferingRoyaltyBps(offering_id, asset.clone()), + &royalty_bps, + ); + env.events().publish( + (EVENT_ROYALTY_CONFIG, issuer, namespace, token, asset), + royalty_bps, + ); + Ok(()) + } + + /// Return the per-offering per-asset secondary-market royalty in basis points. + /// 0 means no royalty is configured. + pub fn get_secondary_market_royalty_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + asset: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey::OfferingRoyaltyBps(offering_id, asset)) + .unwrap_or(0) + } + + /// Pay a configured secondary-market royalty on a transfer. + /// + /// The royalty amount is routed to the issuer and is computed as + /// `amount * royalty_bps / BPS_DENOMINATOR`. + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist. + /// - `InvalidAmount` — transfer amount is not positive. + /// - `TransferFailed` — token transfer to issuer failed. + pub fn pay_secondary_market_royalty( + env: Env, + payer: Address, + issuer: Address, + namespace: Symbol, + token: Address, + payment_asset: Address, + amount: i128, + seller: Address, + buyer: Address, + ) -> Result { + if amount <= 0 { + return Err(RevoraError::InvalidAmount); + } + + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + payer.require_auth(); + + let royalty_bps = Self::get_secondary_market_royalty_bps( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + payment_asset.clone(), + ); + let royalty_amount = (amount * royalty_bps as i128) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0); + + if royalty_amount > 0 { + if token::Client::new(&env, &payment_asset) + .try_transfer(&payer, ¤t_issuer, &royalty_amount) + .is_err() + { + return Err(RevoraError::TransferFailed); + } + } + + Self::emit_v2_event( + &env, + ( + EVENT_ROYALTY_PAID, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + ( + payer.clone(), + seller.clone(), + buyer.clone(), + payment_asset.clone(), + amount, + royalty_amount, + ), + ); + env.events().publish( + (EVENT_ROYALTY_PAID, issuer, namespace, token), + (payer, seller, buyer, payment_asset, amount, royalty_amount), + ); + + Ok(royalty_amount) + } + + /// Atomic swap for secondary market transfers. Transfers shares from seller to buyer + /// and settlement asset from buyer to seller, routing a royalty fee to the issuer. + /// + /// All three parties (issuer, seller, buyer) must authorize the swap via `require_auth`. + /// The swap executes both legs atomically: + /// 1. Royalty fee is routed from buyer to issuer (if configured). + /// 2. Remaining payment is transferred from buyer to seller. + /// 3. Shares (amount_bps) are transferred from seller to buyer via `transfer_with_attestation`. + /// + /// If any leg fails, all previous state changes are rolled back by the Soroban host + /// (transaction revert), guaranteeing atomicity. + /// + /// ### Auth + /// - `issuer` — offering issuer or co-issuer. + /// - `seller` — the address selling shares. + /// - `buyer` — the address purchasing shares and providing payment. + /// + /// ### Events + /// - `swap_v1` — emitted on successful completion. + /// - Inherits events from `pay_secondary_market_royalty` (roy_paid) and + /// `transfer_with_attestation` (sh_set2, rg_lim_d, xfer_att). + /// + /// ### Errors + /// - `InvalidAmount` — `payment_amount` is zero or negative. + /// - `TransferFailed` — token transfer for settlement failed. + /// - Inherits errors from `transfer_with_attestation` and `pay_secondary_market_royalty`. + pub fn atomic_swap( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + seller: Address, + buyer: Address, + amount_bps: u32, + category: Symbol, + payment_asset: Address, + payment_amount: i128, + ) -> Result<(), RevoraError> { + issuer.require_auth(); + seller.require_auth(); + buyer.require_auth(); + + if payment_amount <= 0 { + return Err(RevoraError::InvalidAmount); + } + + let royalty_amount = Self::pay_secondary_market_royalty( + env.clone(), + buyer.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + payment_asset.clone(), + payment_amount, + seller.clone(), + buyer.clone(), + )?; + + let seller_amount = payment_amount.checked_sub(royalty_amount).unwrap_or(0); + if seller_amount > 0 { + if token::Client::new(&env, &payment_asset) + .try_transfer(&buyer, &seller, &seller_amount) + .is_err() + { + return Err(RevoraError::TransferFailed); + } + } + + // Internal callers without attestation context pass expires_at=0 to + // skip attestation nonce/expiry validation. The attest_hash and network_id + // are zero-filled since atomic_swap does not require off-chain attestation. + let zero_hash = BytesN::from_array(&env, &[0u8; 32]); + let net_id = env.ledger().network_id(); + Self::transfer_with_attestation( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + seller.clone(), + buyer.clone(), + amount_bps, + category, + )?; + + // ── Emit swap_v1 event ───────────────────────────────────────────────── + Self::emit_v2_event( + &env, + ( + EVENT_SWAP_V1, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + ( + seller.clone(), + buyer.clone(), + amount_bps, + payment_asset.clone(), + payment_amount, + royalty_amount, + ), + ); + env.events().publish( + (EVENT_SWAP_V1, issuer, namespace, token), + ( + seller, + buyer, + amount_bps, + payment_asset, + payment_amount, + royalty_amount, + ), + ); - fn require_valid_meta_nonce_and_expiry( - env: &Env, - signer: &Address, - nonce: u64, - expiry: u64, - ) -> Result<(), RevoraError> { - if env.ledger().timestamp() > expiry { - return Err(RevoraError::SignatureExpired); - } - let nonce_key = MetaDataKey::NonceUsed(signer.clone(), nonce); - if env.storage().persistent().has(&nonce_key) { - return Err(RevoraError::SignatureReplay); - } - Ok(()) - } + Ok(()) + } + + /// Set a platform-level per-asset fee in basis points. Admin-only. (#98) + /// + /// Emits `EVENT_FEE_CONFIG` with `(asset, fee_bps)`. + /// + /// ### Errors + /// - `NotInitialized` — contract not yet initialized. + /// - `InvalidRevenueShareBps` — `fee_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). + pub fn set_platform_fee_per_asset( + env: Env, + asset: Address, + fee_bps: u32, + ) -> Result<(), RevoraError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + if fee_bps > MAX_PLATFORM_FEE_BPS { + return Err(RevoraError::InvalidRevenueShareBps); + } + env + .storage() + .persistent() + .set(&DataKey::PlatformFeePerAsset(asset.clone()), &fee_bps); + env.events().publish((EVENT_FEE_CONFIG, asset), fee_bps); + Ok(()) + } + + /// Return the platform-level per-asset fee in basis points (0 = no per-asset override). (#98) + /// + /// O(1) — single persistent storage read. + pub fn get_platform_fee_per_asset(env: Env, asset: Address) -> u32 { + env + .storage() + .persistent() + .get(&DataKey::PlatformFeePerAsset(asset)) + .unwrap_or(0) + } + + // ── Platform Fee Model (#468) ────────────────────────────────── + + /// Configure the per-offering platform fee model: a programmable `fee_bps` cut routed + /// to `treasury` on each `report_revenue` call. Admin-only. (#468) + /// + /// The fee and the offering's holders share the same 100% (10_000 bps) budget, so this + /// rejects any configuration where `fee_bps` plus the offering's aggregate holder share + /// would exceed 10_000 bps. Setting `fee_bps = 0` disables the fee (no deduction and no + /// `plat_fee` event on subsequent reports) while still recording the `treasury` for clarity. + /// + /// Emits `EVENT_PLAT_FEE_SET` with topic `(issuer, namespace, token)` and data + /// `(fee_bps, treasury)`. + /// + /// ### Auth + /// Contract admin (`require_auth`). + /// + /// ### Errors + /// - `NotInitialized` — contract admin is not set. + /// - `OfferingNotFound` — offering does not exist. + /// - `FeeExceedsHolderShare` — `fee_bps` + aggregate holder share would exceed 10_000 bps. + pub fn set_offering_platform_fee( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + fee_bps: u32, + treasury: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - fn is_window_open(env: &Env, window: &AccessWindow) -> bool { - let now = env.ledger().timestamp(); - now >= window.start_timestamp && now <= window.end_timestamp + // Offering must exist before a fee model can be attached to it. + if !env + .storage() + .persistent() + .has(&DataKey::OfferingIssuer(offering_id.clone())) + { + return Err(RevoraError::OfferingNotFound); } - fn require_report_window_open(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { - let key = WindowDataKey::Report(offering_id.clone()); - if let Some(window) = env.storage().persistent().get::(&key) { - if !Self::is_window_open(env, &window) { - return Err(RevoraError::ReportingWindowClosed); - } - } - Ok(()) + // Fee bps + holder bps must always sum to at most 10_000 at the offering level. + // The aggregate holder share is maintained incrementally by `set_holder_share_internal`. + let holder_aggregate_bps: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShareTotal(offering_id.clone())) + .unwrap_or(0); + if fee_bps.saturating_add(holder_aggregate_bps) > 10_000 { + return Err(RevoraError::FeeExceedsHolderShare); } - fn require_claim_window_open(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { - let key = WindowDataKey::Claim(offering_id.clone()); - if let Some(window) = env.storage().persistent().get::(&key) { - if !Self::is_window_open(env, &window) { - return Err(RevoraError::ClaimWindowClosed); - } - } - Ok(()) - } + let model = PlatformFeeModel { + fee_bps, + treasury: treasury.clone(), + }; + env + .storage() + .persistent() + .set(&DataKey2::OfferingPlatformFee(offering_id), &model); + env.events().publish( + (EVENT_PLAT_FEE_SET, issuer, namespace, token), + (fee_bps, treasury), + ); + Ok(()) + } + + /// Return the configured per-offering platform fee model, if any. (#468) + /// + /// O(1) — single persistent storage read. Returns `None` when no fee model is configured. + pub fn get_offering_platform_fee( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::OfferingPlatformFee(offering_id)) + } + + /// Apply the per-offering platform fee for a recorded revenue report. (#468) + /// + /// When a fee model is configured with a non-zero `fee_bps`, the programmable share of + /// `amount` is routed to the treasury and surfaced via `EVENT_PLAT_FEE`. A `fee_bps` of 0 + /// (or a computed fee of 0, e.g. zero-revenue reports) is a no-op and emits no event, so + /// indexers can rely on `plat_fee` being present only when a real fee was taken. + /// + /// Returns the fee amount routed to the treasury (0 when no fee applies). + fn apply_platform_fee( + env: &Env, + offering_id: &OfferingId, + issuer: &Address, + namespace: &Symbol, + token: &Address, + amount: i128, + period_id: u64, + ) -> i128 { + let model: PlatformFeeModel = match env + .storage() + .persistent() + .get(&DataKey2::OfferingPlatformFee(offering_id.clone())) + { + Some(m) => m, + None => return 0, + }; - fn require_redemption_window_open( - env: &Env, - offering_id: &OfferingId, - ) -> Result<(), RevoraError> { - let key = WindowDataKey::Redemption(offering_id.clone()); - if let Some(window) = env.storage().persistent().get::(&key) { - if !Self::is_window_open(env, &window) { - return Err(RevoraError::RedemptionWindowClosed); - } - } - Ok(()) - } - - fn mark_meta_nonce_used(env: &Env, signer: &Address, nonce: u64) { - let nonce_key = MetaDataKey::NonceUsed(signer.clone(), nonce); - env.storage().persistent().set(&nonce_key, &true); - } - - fn verify_meta_signature( - env: &Env, - signer: &Address, - nonce: u64, - expiry: u64, - action: MetaAction, - signature: &BytesN<64>, - ) -> Result<(), RevoraError> { - Self::require_valid_meta_nonce_and_expiry(env, signer, nonce, expiry)?; - let pk_key = MetaDataKey::SignerKey(signer.clone()); - let public_key: BytesN<32> = - env.storage().persistent().get(&pk_key).ok_or(RevoraError::SignerKeyNotRegistered)?; - let payload = MetaAuthorization { - version: Self::META_AUTH_VERSION, - contract: env.current_contract_address(), - signer: signer.clone(), - nonce, - expiry, - action, - }; - let payload_bytes = payload.to_xdr(env); - env.crypto().ed25519_verify(&public_key, &payload_bytes, signature); - Ok(()) - } - - fn set_holder_share_internal( - env: &Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - share_bps: u32, - share_class: Option, - nonce: Option, - ) -> Result<(), RevoraError> { - if share_bps > 10_000 { - return Err(RevoraError::InvalidShareBps); + if model.fee_bps == 0 || amount <= 0 { + return 0; + } + + let fee_amount = amount + .saturating_mul(model.fee_bps as i128) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0); + if fee_amount <= 0 { + return 0; + } + + env.events().publish( + ( + EVENT_PLAT_FEE, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (model.treasury, model.fee_bps, fee_amount, period_id), + ); + fee_amount + } + + /// Return true if the contract is in event-only mode. + pub fn is_event_only(env: &Env) -> bool { + let (_, event_only): (bool, bool) = env + .storage() + .persistent() + .get(&DataKey2::ContractFlags) + .unwrap_or((false, false)); + event_only + } + + /// Input validation (#35): require amount > 0 for transfers/deposits. + #[allow(dead_code)] + fn require_positive_amount(amount: i128) -> Result<(), RevoraError> { + if amount <= 0 { + return Err(RevoraError::InvalidAmount); + } + Ok(()) + } + + /// Require `period_id` to be strictly greater than the last committed period for the key. + fn require_next_period_id( + env: &Env, + key: K, + period_id: u64, + ) -> Result<(), RevoraError> + where + K: IntoVal + Clone, + { + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + let last: u64 = env.storage().persistent().get(&key).unwrap_or(0); + if period_id != last + 1 { + return Err(RevoraError::InvalidPeriodId); + } + Ok(()) + } + + fn commit_period_id(env: &Env, key: K, period_id: u64) + where + K: IntoVal + Clone, + { + env.storage().persistent().set(&key, &period_id); + } + + fn get_min_revenue_threshold_for_offering( + env: &Env, + offering_id: &OfferingId, + ) -> i128 { + env + .storage() + .persistent() + .get(&DataKey2::MinRevenueThreshold(offering_id.clone())) + .unwrap_or(0) + } + + fn compute_audit_summary_from_reports( + env: &Env, + offering_id: &OfferingId, + ) -> (AuditSummary, bool) { + let reports_key = DataKey::RevenueReports(offering_id.clone()); + let reports: Map = env + .storage() + .persistent() + .get(&reports_key) + .unwrap_or_else(|| Map::new(env)); + + let mut total_revenue: i128 = 0; + let mut is_saturated = false; + let keys = reports.keys(); + for i in 0..keys.len() { + let period_id = keys.get(i).unwrap(); + if let Some((amount, _)) = reports.get(period_id) { + if let Ok(next) = total_revenue.s_add(amount) { + total_revenue = next; + } else { + is_saturated = true; + total_revenue = i128::MAX; + } + } + } + + ( + AuditSummary { + total_revenue, + report_count: reports.len() as u64, + }, + is_saturated, + ) + } + + /// Initialize the contract with an admin and an optional safety role. + /// + /// This method follows the singleton pattern and can only be called once. + /// + /// ### Parameters + /// - `admin`: The primary administrative address with authority to pause/unpause and manage offerings. + /// - `safety`: Optional address allowed to trigger emergency pauses but not manage offerings. + /// + /// ### Panics + /// Panics if the contract has already been initialized. + /// Get the current issuer for an offering token (used for auth checks after transfers). + fn get_current_issuer( + env: &Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::OfferingIssuer(offering_id); + env.storage().persistent().get(&key) + } + + fn ensure_issuer_registered(env: &Env, issuer: &Address) { + let issuer_key = DataKey2::IssuerRegistered(issuer.clone()); + if !env.storage().persistent().has(&issuer_key) { + let count: u32 = env + .storage() + .persistent() + .get(&DataKey2::IssuerCount) + .unwrap_or(0); + env + .storage() + .persistent() + .set(&DataKey2::IssuerItem(count), issuer); + env + .storage() + .persistent() + .set(&DataKey2::IssuerCount, &(count + 1)); + env.storage().persistent().set(&issuer_key, &true); + } + } + + fn ensure_namespace_registered( + env: &Env, + issuer: &Address, + namespace: &Symbol, + ) { + let ns_key = + DataKey2::NamespaceRegistered(issuer.clone(), namespace.clone()); + if !env.storage().persistent().has(&ns_key) { + let ns_count: u32 = env + .storage() + .persistent() + .get(&DataKey2::NamespaceCount(issuer.clone())) + .unwrap_or(0); + env.storage().persistent().set( + &DataKey2::NamespaceItem(issuer.clone(), ns_count), + namespace, + ); + env + .storage() + .persistent() + .set(&DataKey2::NamespaceCount(issuer.clone()), &(ns_count + 1)); + env.storage().persistent().set(&ns_key, &true); + } + } + + /// Enable or disable testnet mode for the contract. + /// + /// ### Security Note + /// This mode MUST only be enabled on test networks. It relaxes critical + /// validation rules (like concentration limits) to facilitate automated + /// testing and integration flows. + pub fn set_testnet_mode(env: Env, enabled: bool) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + env + .storage() + .persistent() + .set(&DataKey::TestnetMode, &enabled); + env.events().publish((EVENT_TESTNET_MODE,), enabled); + Ok(()) + } + + /// Read-only accessor for the on-chain storage layout version stamp. + pub fn storage_layout_version(env: Env) -> Option { + env + .storage() + .persistent() + .get(&DataKey::StorageLayoutVersion) + } + + /// Admin-only setter to adjust the stored layout version (used by migrations/tests). + /// Emits `EVENT_LAYOUT_VERSION` when the stored value is changed. + pub fn set_storage_layout_version( + env: Env, + caller: Address, + v: u32, + ) -> Result<(), RevoraError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey::StorageLayoutVersion, &v); + env.events().publish((EVENT_LAYOUT_VERSION,), v); + Ok(()) + } + + pub fn get_pending_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey::PendingIssuerTransfer( + offering_id, + )) + .map(|pending| pending.new_issuer) + } + + /// Return full details of a pending issuer transfer, including the proposed new issuer, + /// the proposal timestamp, and the effective expiry in seconds (0 = default 7 days). + pub fn get_pending_transfer_details( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env.storage().persistent().get::( + &DataKey::PendingIssuerTransfer(offering_id), + ) + } + + fn find_pending_transfer_for_new_issuer( + env: &Env, + namespace: &Symbol, + token: &Address, + new_issuer: &Address, + ) -> Option { + let issuer_count: u32 = env + .storage() + .persistent() + .get(&DataKey2::IssuerCount) + .unwrap_or(0); + for i in 0..issuer_count { + let issuer: Address = env + .storage() + .persistent() + .get(&DataKey2::IssuerItem(i)) + .unwrap(); + let ns_count: u32 = env + .storage() + .persistent() + .get(&DataKey2::NamespaceCount(issuer.clone())) + .unwrap_or(0); + for j in 0..ns_count { + let namespace_item: Symbol = env + .storage() + .persistent() + .get(&DataKey2::NamespaceItem(issuer.clone(), j)) + .unwrap(); + if namespace_item != *namespace { + continue; } let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), + issuer: issuer.clone(), + namespace: namespace_item.clone(), + token: token.clone(), }; + if let Some(pending) = + env.storage().persistent().get::( + &DataKey::PendingIssuerTransfer(offering_id.clone()), + ) + { + if pending.new_issuer == *new_issuer { + return Some(offering_id); + } + } + } + } + None + } + + pub fn propose_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + ) -> Result<(), RevoraError> { + Self::do_propose_issuer_transfer( + env, issuer, namespace, token, new_issuer, 0, + ) + } + + /// Propose an issuer transfer with a custom expiry window. + /// + /// `expiry_secs` is clamped to `[MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS]`. + /// Pass `0` to use the default `ISSUER_TRANSFER_EXPIRY_SECS` (7 days). + #[allow(clippy::too_many_arguments)] + pub fn propose_transfer_with_expiry( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + expiry_secs: u64, + ) -> Result<(), RevoraError> { + Self::do_propose_issuer_transfer( + env, + issuer, + namespace, + token, + new_issuer, + expiry_secs, + ) + } + + fn do_propose_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + expiry_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // ── Nonce monotonicity guard ────────────────────────────────────────── - // When a nonce is supplied the caller guarantees this update is strictly - // newer than any previously accepted one. Reject equal or smaller values - // to prevent replayed or out-of-order off-chain updates from silently - // overwriting newer on-chain share state. - if let Some(n) = nonce { - let nonce_key = DataKey2::HolderShareNonce(offering_id.clone(), holder.clone()); - let last_nonce: u64 = env.storage().persistent().get(&nonce_key).unwrap_or(0); - if n <= last_nonce { - return Err(RevoraError::StaleNonce); - } - env.storage().persistent().set(&nonce_key, &n); - } - - // Check max total supply shares cap - let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); - let max_shares: i128 = env.storage().persistent().get(&max_shares_key).unwrap_or(0); - if max_shares > 0 { - let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); - let current_total_shares: i128 = - env.storage().persistent().get(&total_shares_key).unwrap_or(0); - let old_share: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) - .unwrap_or(0); - let new_total_shares = current_total_shares - .saturating_sub(old_share as i128) - .saturating_add(share_bps as i128); - if new_total_shares > max_shares { - return Err(RevoraError::MaxTotalSupplySharesExceeded); - } - if new_total_shares == max_shares { - env.events().publish( - (EVENT_SUPPLY_CAP_SATURATED, issuer.clone(), namespace.clone(), token.clone()), - (new_total_shares, max_shares), - ); - } - } - - // Maintain a running total of persisted holder shares for this offering. - let total_key = DataKey::HolderShareTotal(offering_id.clone()); - let mut current_total: u32 = env.storage().persistent().get(&total_key).unwrap_or(0); - - let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - let classes: Option> = - env.storage().persistent().get(&classes_key); - - if let Some(cls_vec) = classes { - let sc = match share_class { - Some(ref sc) => sc.clone(), - None => return Err(RevoraError::InvalidShareClass), - }; - let mut found = false; - for (class_name, _) in cls_vec.iter() { - if class_name == sc { - found = true; - break; - } - } - } - - let new_total = - current_total.s_sub(old_share).unwrap_or(0).s_add(share_bps).unwrap_or(u32::MAX); - if new_total > 10_000 { - return Err(RevoraError::InvalidShareBps); - } - - // Update total shares issued - let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); - let current_total_shares: i128 = - env.storage().persistent().get(&total_shares_key).unwrap_or(0); - let new_total_shares = current_total_shares - .saturating_sub(old_share as i128) - .saturating_add(share_bps as i128); - env.storage().persistent().set(&total_shares_key, &new_total_shares); - - if let Some(ref sc) = share_class { - let class_shares_key = - DataKey2::TotalClassSharesIssued(offering_id.clone(), sc.clone()); - let current_class_shares: i128 = - env.storage().persistent().get(&class_shares_key).unwrap_or(0); - let new_class_shares = current_class_shares - .saturating_sub(old_share as i128) - .saturating_add(share_bps as i128); - env.storage().persistent().set(&class_shares_key, &new_class_shares); - } + let key = DataKey::PendingIssuerTransfer(offering_id.clone()); + if env.storage().persistent().has(&key) { + return Err(RevoraError::IssuerTransferPending); + } - // Persist updated holder share and running total. - env.storage() - .persistent() - .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); - env.storage().persistent().set(&total_key, &new_total); - Self::record_holder_share_transition(env, &offering_id, &holder, old_share, share_bps); + // Clamp expiry: 0 means default; non-zero is clamped to [MIN, MAX]. + let effective_expiry = if expiry_secs == 0 { + 0 + } else { + expiry_secs.clamp( + MIN_ISSUER_TRANSFER_EXPIRY_SECS, + MAX_ISSUER_TRANSFER_EXPIRY_SECS, + ) + }; - // 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); + let timestamp = env.ledger().timestamp(); + env.storage().persistent().set( + &key, + &PendingTransfer { + new_issuer: new_issuer.clone(), + timestamp, + expiry_secs: effective_expiry, + }, + ); + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_PROPOSED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (new_issuer.clone(), timestamp), + ); + Ok(()) + } + + pub fn replace_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::NotAuthorized); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - env.events().publish( - (EVENT_SHARE_SET, issuer.clone(), namespace.clone(), token.clone()), - (holder.clone(), share_bps), - ); - // Versioned v2 event: [2, holder, share_bps] — always emitted (#RC26Q2-C31) - Self::emit_v2_event( - env, - (EVENT_SHARE_SET_V2, issuer, namespace, token), - (holder, share_bps), - ); - Ok(()) - } + let key = DataKey::PendingIssuerTransfer(offering_id.clone()); + if !env.storage().persistent().has(&key) { + return Err(RevoraError::NoTransferPending); + } + + let pending: PendingTransfer = + env.storage().persistent().get(&key).unwrap(); + let timestamp = env.ledger().timestamp(); + // Preserve the original expiry_secs so the replacement inherits the same window. + env.storage().persistent().set( + &key, + &PendingTransfer { + new_issuer: new_issuer.clone(), + timestamp, + expiry_secs: pending.expiry_secs, + }, + ); + + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_CANCELLED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (issuer.clone(), pending.new_issuer.clone()), + ); + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_PROPOSED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (new_issuer.clone(), timestamp), + ); + Ok(()) + } + + pub fn accept_issuer_transfer( + env: Env, + new_issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + new_issuer.require_auth(); + + let offering_id = Self::find_pending_transfer_for_new_issuer( + &env, + &namespace, + &token, + &new_issuer, + ) + .ok_or(RevoraError::NoTransferPending)?; + + let pending: PendingTransfer = env + .storage() + .persistent() + .get(&DataKey::PendingIssuerTransfer(offering_id.clone())) + .ok_or(RevoraError::NoTransferPending)?; + + let current_timestamp = env.ledger().timestamp(); + let effective_expiry = if pending.expiry_secs == 0 { + ISSUER_TRANSFER_EXPIRY_SECS + } else { + pending.expiry_secs + }; + if current_timestamp > pending.timestamp.saturating_add(effective_expiry) { + return Err(RevoraError::IssuerTransferExpired); + } + + let old_issuer = offering_id.issuer.clone(); + + if new_issuer == old_issuer { + env + .storage() + .persistent() + .remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_ACCEPTED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (old_issuer, new_issuer.clone()), + ); + return Ok(()); + } + + let new_offering_id = OfferingId { + issuer: new_issuer.clone(), + namespace: offering_id.namespace.clone(), + token: offering_id.token.clone(), + }; - fn get_period_count_internal(env: &Env, offering_id: &OfferingId) -> u32 { - env.storage() - .persistent() - .get::<_, u32>(&DataKey::PeriodCount(offering_id.clone())) - .unwrap_or(0) + // Prevent duplicate offering entries for the same new issuer / namespace / token. + if Self::get_offering( + env.clone(), + new_issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ) + .is_some() + { + return Err(RevoraError::LimitReached); } - fn accrual_delta_e18(amount: i128) -> i128 { - amount - .checked_mul(ACCRUAL_SCALE_E18) - .unwrap_or(i128::MAX) - .checked_div(BPS_DENOMINATOR) - .unwrap_or(0) - } - - fn get_acc_per_share_at_index(env: &Env, offering_id: &OfferingId, index: u32) -> i128 { - if index == 0 { - return 0; - } - env.storage() - .persistent() - .get::<_, i128>(&DataKey2::AccPerShareAtIndex(offering_id.clone(), index)) - .unwrap_or(0) - } - - fn get_holder_share_schedule( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - ) -> Vec { - if let Some(schedule) = env.storage().persistent().get::<_, Vec>( - &DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), - ) { - return schedule; - } - - let current_share: u32 = env - .storage() - .persistent() - .get::<_, u32>(&DataKey::HolderShare(offering_id.clone(), holder.clone())) - .unwrap_or(0); - let mut schedule = Vec::new(env); - if current_share > 0 { - schedule.push_back(HolderShareCheckpoint { start_index: 0, share_bps: current_share }); - } - schedule - } - - fn get_checkpoint_threshold(env: &Env, offering_id: &OfferingId) -> u32 { - let key = DataKey2::CheckpointThreshold(offering_id.clone()); - env.storage().persistent().get(&key).unwrap_or(CHECKPOINT_THRESHOLD_DEFAULT) - } - - /// Compute the pre-claimable sum for a range of period indices using the - /// holder's share schedule and the global `AccPerShareAtIndex` values. - /// - /// This is a lossless computation: the result equals what - /// `compute_holder_payout_for_range` would produce for the same - /// `[start_idx, end_idx)` interval, but without iterating through - /// every period index individually. - /// - /// The sum is: - /// `sum_i ( (AccPerShareAtIndex[i+1] - AccPerShareAtIndex[i]) * share_bps_i / 1e18 )` - fn compute_anchor_claimable_sum( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - start_idx: u32, - end_idx: u32, - ) -> i128 { - if start_idx >= end_idx { - return 0; - } - let schedule = Self::get_holder_share_schedule(env, offering_id, holder); - if schedule.is_empty() { - return 0; - } - let mut total = 0_i128; - let mut current_index = start_idx; - let mut current_share = 0_u32; - let mut schedule_idx = 0_u32; - - while schedule_idx < schedule.len() { - let checkpoint = schedule.get(schedule_idx).unwrap(); - if checkpoint.start_index > start_idx { - break; - } - current_share = checkpoint.share_bps; - schedule_idx = schedule_idx.saturating_add(1); - } - - while current_index < end_idx { - while schedule_idx < schedule.len() { - let checkpoint = schedule.get(schedule_idx).unwrap(); - if checkpoint.start_index > current_index { - break; - } - current_share = checkpoint.share_bps; - schedule_idx = schedule_idx.saturating_add(1); - } - - if current_share > 0 { - let acc_end = Self::get_acc_per_share_at_index( - env, - offering_id, - current_index.saturating_add(1), - ); - let acc_start = Self::get_acc_per_share_at_index(env, offering_id, current_index); - let delta = acc_end.saturating_sub(acc_start); - total = total.saturating_add( - delta.saturating_mul(current_share as i128) / ACCRUAL_SCALE_E18, - ); - } - - current_index = current_index.saturating_add(1); - } + // Migrate any vesting schedules corresponding to this offering before completing + // the issuer transfer. This preserves active schedules under the new issuer key + // and prevents orphaned pre-cliff schedules. + let vesting_offering_id = vesting::VestingOfferingId { + issuer: old_issuer.clone(), + token: offering_id.token.clone(), + }; + match vesting::migrate_offering_schedules( + &env, + &vesting_offering_id, + new_issuer.clone(), + current_timestamp, + ) { + Ok(beneficiaries) => { + for beneficiary in beneficiaries.iter() { + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_VESTING_MIGRATED, + offering_id.namespace.clone(), + offering_id.token.clone(), + beneficiary.clone(), + ), + (old_issuer.clone(), new_issuer.clone()), + ); + } + } + Err(vesting::VestingError::SchedulePreCliff) => { + return Err(RevoraError::VestingTransferBlocked); + } + Err(_) => { + // If the vesting index is empty or stale, ignore it and continue. + } + } + + // Register namespace metadata for the new issuer. + Self::ensure_issuer_registered(&env, &new_issuer); + Self::ensure_namespace_registered( + &env, + &new_issuer, + &offering_id.namespace, + ); + + // Copy the offering registration record to the new issuer's tenant list. + let tenant_id = TenantId { + issuer: new_issuer.clone(), + namespace: offering_id.namespace.clone(), + }; + let count_key = DataKey::OfferCount(tenant_id.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + let offering = Self::get_offering( + env.clone(), + old_issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let item_key = DataKey::OfferItem(tenant_id.clone(), count); + env.storage().persistent().set(&item_key, &offering); + env.storage().persistent().set(&count_key, &(count + 1)); + + // Update direct index for the new issuer's offering_id (#360). + env.storage().persistent().set( + &DataKey2::OfferingRecord(new_offering_id.clone()), + &offering, + ); + + // Update issuer lookups for the old and new offering IDs. + env.storage().persistent().set( + &DataKey::OfferingIssuer(offering_id.clone()), + &new_issuer.clone(), + ); + env.storage().persistent().set( + &DataKey::OfferingIssuer(new_offering_id.clone()), + &new_issuer.clone(), + ); + + // Migrate configuration state linked to the old OfferingId (#1344) + if let Some(config) = env + .storage() + .persistent() + .get::<_, ConcentrationLimitConfig>(&DataKey::ConcentrationLimit( + offering_id.clone(), + )) + { + env.storage().persistent().set( + &DataKey::ConcentrationLimit(new_offering_id.clone()), + &config, + ); + env + .storage() + .persistent() + .remove(&DataKey::ConcentrationLimit(offering_id.clone())); + } + if let Some(current) = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::CurrentConcentration(offering_id.clone())) + { + env.storage().persistent().set( + &DataKey::CurrentConcentration(new_offering_id.clone()), + ¤t, + ); + env + .storage() + .persistent() + .remove(&DataKey::CurrentConcentration(offering_id.clone())); + } + if let Some(mode) = env + .storage() + .persistent() + .get::<_, RoundingMode>(&DataKey::RoundingMode(offering_id.clone())) + { + env + .storage() + .persistent() + .set(&DataKey::RoundingMode(new_offering_id.clone()), &mode); + env + .storage() + .persistent() + .remove(&DataKey::RoundingMode(offering_id.clone())); + } + if let Some(constraints) = env + .storage() + .persistent() + .get::<_, InvestmentConstraintsConfig>( + &DataKey2::InvestmentConstraints(offering_id.clone()), + ) { + env.storage().persistent().set( + &DataKey2::InvestmentConstraints(new_offering_id.clone()), + &constraints, + ); + env + .storage() + .persistent() + .remove(&DataKey2::InvestmentConstraints(offering_id.clone())); + } + if let Some(delay) = env + .storage() + .persistent() + .get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone())) + { + env + .storage() + .persistent() + .set(&DataKey::ClaimDelaySecs(new_offering_id.clone()), &delay); + env + .storage() + .persistent() + .remove(&DataKey::ClaimDelaySecs(offering_id.clone())); + } + if let Some(snap_config) = env + .storage() + .persistent() + .get::<_, bool>(&DataKey::SnapshotConfig(offering_id.clone())) + { + env.storage().persistent().set( + &DataKey::SnapshotConfig(new_offering_id.clone()), + &snap_config, + ); + env + .storage() + .persistent() + .remove(&DataKey::SnapshotConfig(offering_id.clone())); + } + if let Some(snap_ref) = env + .storage() + .persistent() + .get::<_, u64>(&DataKey::LastSnapshotRef(offering_id.clone())) + { + env.storage().persistent().set( + &DataKey::LastSnapshotRef(new_offering_id.clone()), + &snap_ref, + ); + env + .storage() + .persistent() + .remove(&DataKey::LastSnapshotRef(offering_id.clone())); + } + + env + .storage() + .persistent() + .remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); + + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_ACCEPTED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (old_issuer, new_issuer.clone()), + ); + Ok(()) + } + + pub fn cancel_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::NotAuthorized); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - total + let key = DataKey::PendingIssuerTransfer(offering_id.clone()); + if !env.storage().persistent().has(&key) { + return Err(RevoraError::NoTransferPending); + } + + let pending: PendingTransfer = + env.storage().persistent().get(&key).unwrap(); + env.storage().persistent().remove(&key); + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_CANCELLED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (issuer, pending.new_issuer), + ); + Ok(()) + } + + pub fn reject_issuer_transfer( + env: Env, + new_issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + new_issuer.require_auth(); + + let offering_id = Self::find_pending_transfer_for_new_issuer( + &env, + &namespace, + &token, + &new_issuer, + ) + .ok_or(RevoraError::NoTransferPending)?; + + let pending: PendingTransfer = env + .storage() + .persistent() + .get(&DataKey::PendingIssuerTransfer(offering_id.clone())) + .ok_or(RevoraError::NoTransferPending)?; + + let old_issuer = offering_id.issuer.clone(); + + env + .storage() + .persistent() + .remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); + + env.events().publish( + ( + EVENT_ISSUER_TRANSFER_REJECTED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (old_issuer, new_issuer.clone()), + ); + Ok(()) + } + + /// Initialize admin and optional safety role for emergency pause (#7). + /// `event_only` configures the contract to skip persistent business state (#72). + /// Can only be called once; panics if already initialized. + pub fn initialize( + env: Env, + admin: Address, + safety: Option
, + event_only: Option, + ) { + if env.storage().persistent().has(&DataKey::Admin) { + return; // Already initialized, no-op + } + env.storage().persistent().set(&DataKey::Admin, &admin); + Self::emit_v2_event(&env, (EVENT_ADMIN_SET,), admin.clone()); + if let Some(ref s) = safety { + env.storage().persistent().set(&DataKey::Safety, &s); + } + env + .storage() + .persistent() + .set(&DataKey::Paused, &PauseState::NotPaused); + let eo = event_only.unwrap_or(false); + env + .storage() + .persistent() + .set(&DataKey2::ContractFlags, &(false, eo)); + // Stamp storage layout version for future compatibility checks. + env + .storage() + .persistent() + .set(&DataKey::StorageLayoutVersion, &STORAGE_LAYOUT_VERSION); + env + .events() + .publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); + + // Persist the initial contract version as the minimum supported version. + // Future WASM binaries with a lower CONTRACT_VERSION will be rejected at entry. + env + .storage() + .persistent() + .set(&DataKey::DeployedVersion, &CONTRACT_VERSION); + + env + .events() + .publish((EVENT_INIT, admin.clone()), (safety, eo)); + } + + /// Soft-pause the contract (Admin only). + /// + /// `SoftPaused` blocks reports and deposits but **allows** `claim`, so + /// holders can still withdraw their funds during incident response. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address of the admin (must match initialized admin). + pub fn pause_admin(env: Env, caller: Address) -> Result<(), RevoraError> { + caller.require_auth(); + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey::Paused, &PauseState::SoftPaused); + // Legacy compatibility event + env.events().publish((EVENT_PAUSED, caller.clone()), ()); + // Versioned tier event + env + .events() + .publish((EVENT_PAUSED2, caller.clone()), (PauseState::SoftPaused,)); + Ok(()) + } + + /// Unpause the contract (Admin only). + /// + /// Re-enables all operations after a pause. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address of the admin (must match initialized admin). + pub fn unpause_admin(env: Env, caller: Address) -> Result<(), RevoraError> { + caller.require_auth(); + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey::Paused, &PauseState::NotPaused); + env.events().publish((EVENT_UNPAUSED, caller.clone()), ()); + env + .events() + .publish((EVENT_PAUSED2, caller.clone()), (PauseState::NotPaused,)); + Ok(()) + } + + /// Hard-pause the contract (Admin only). + /// + /// `HardPaused` blocks **every** state-mutating operation including `claim`. + /// Use this tier only when funds must be fully locked (e.g. critical exploit). + /// Only the admin can escalate to HardPaused; the safety role is limited to SoftPaused. + /// + /// ### Parameters + /// - `caller`: The address of the admin (must match initialized admin). + pub fn hard_pause_admin( + env: Env, + caller: Address, + ) -> Result<(), RevoraError> { + caller.require_auth(); + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey::Paused, &PauseState::HardPaused); + env.events().publish((EVENT_PAUSED, caller.clone()), ()); + env + .events() + .publish((EVENT_PAUSED2, caller.clone()), (PauseState::HardPaused,)); + Ok(()) + } + + /// Soft-pause the contract (Safety role only). + /// + /// `SoftPaused` blocks reports and deposits but **allows** `claim`, so + /// holders can still withdraw their funds during incident response. + /// The safety role cannot escalate to `HardPaused`; only the admin can. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address of the safety role (must match initialized safety address). + pub fn pause_safety(env: Env, caller: Address) -> Result<(), RevoraError> { + caller.require_auth(); + let safety: Address = env + .storage() + .persistent() + .get(&DataKey::Safety) + .ok_or(RevoraError::NotInitialized)?; + if caller != safety { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey::Paused, &PauseState::SoftPaused); + env.events().publish((EVENT_PAUSED, caller.clone()), ()); + env + .events() + .publish((EVENT_PAUSED2, caller.clone()), (PauseState::SoftPaused,)); + Ok(()) + } + + /// Unpause the contract (Safety role only). + /// + /// Allows the safety role to resume contract operations. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address of the safety role (must match initialized safety address). + pub fn unpause_safety(env: Env, caller: Address) -> Result<(), RevoraError> { + caller.require_auth(); + let safety: Address = env + .storage() + .persistent() + .get(&DataKey::Safety) + .ok_or(RevoraError::NotInitialized)?; + if caller != safety { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey::Paused, &PauseState::NotPaused); + env.events().publish((EVENT_UNPAUSED, caller.clone()), ()); + env + .events() + .publish((EVENT_PAUSED2, caller.clone()), (PauseState::NotPaused,)); + Ok(()) + } + + /// Toggle the V2-compat downgrade flag. + /// + /// When enabled (`true`), V2-shaped indexed events (`EVENT_INDEXED_V2`) are + /// emitted alongside V3 events, allowing indexers pinned to V2 to continue + /// working during the deprecation window. When disabled (`false`), only V3 + /// events are emitted, and indexers must have migrated to V3. + /// + /// Defaults to `true` at initialization. + /// + /// ### Auth + /// Requires `caller` to match the contract admin. + /// + /// ### Events + /// Emits `EVENT_V2_COMPAT_SET` on success. + /// + /// ### Deprecation + /// This flag is intended for a limited deprecation window. Once all indexers + /// have migrated to V3, the flag should be set to `false` and eventually + /// the V2 emission path and this flag can be removed entirely. + pub fn set_emit_v2_compat( + env: Env, + caller: Address, + enabled: bool, + ) -> Result<(), RevoraError> { + caller.require_auth(); + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + env + .storage() + .persistent() + .set(&DataKey2::EmitV2Compat, &enabled); + env.events().publish((EVENT_V2_COMPAT_SET, caller), enabled); + Ok(()) + } + + /// Return the list of currently supported event index topics and their schema versions. + /// + /// Non-authorized, read-only query intended for indexers to negotiate topic + /// versions before subscribing. + /// + /// V3 topic (`ev_idx3`, version 3) is always present. + /// V2 topic (`ev_idx2`, version 2) is included when `EmitV2Compat` is enabled. + pub fn supported_event_versions(env: Env) -> Vec { + let mut list = Vec::new(&env); + // V3 topic is canonical and always active + list.push_back(EventVersionInfo { + topic: symbol_short!("ev_idx3"), + version: 3, + }); + // V2 topic is active if v2 compat shim is enabled + if Self::is_emit_v2_compat(&env) { + list.push_back(EventVersionInfo { + topic: symbol_short!("ev_idx2"), + version: 2, + }); + } + list + } + + /// Query the paused state of the contract. + /// + /// Returns `true` when the contract is in either `SoftPaused` or `HardPaused` state, + /// preserving backward compatibility with callers that only need a binary signal. + /// Use `get_pause_state` to distinguish between the two tiers. + pub fn is_paused(env: Env) -> bool { + matches!( + env + .storage() + .persistent() + .get::(&DataKey::Paused) + .unwrap_or(PauseState::NotPaused), + PauseState::SoftPaused | PauseState::HardPaused + ) + } + + /// Return the current `PauseState` tier. + /// + /// - `NotPaused` – all operations open. + /// - `SoftPaused` – reports/deposits blocked; `claim` allowed. + /// - `HardPaused` – all state-mutating operations blocked including `claim`. + pub fn get_pause_state(env: Env) -> PauseState { + env + .storage() + .persistent() + .get::(&DataKey::Paused) + .unwrap_or(PauseState::NotPaused) + } + + /// Helper: block if the contract is in SoftPaused or HardPaused state. + /// Used by reports, deposits, and all non-claim state-mutating entrypoints. + fn require_not_paused(env: &Env) -> Result<(), RevoraError> { + let state = env + .storage() + .persistent() + .get::(&DataKey::Paused) + .unwrap_or(PauseState::NotPaused); + if matches!(state, PauseState::SoftPaused | PauseState::HardPaused) { + return Err(RevoraError::ContractPaused); } - - fn record_holder_share_transition( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - old_share: u32, - new_share: u32, + Ok(()) + } + + // ── Offering management ─────────────────────────────────── + + /// Register a new revenue-share offering. + /// + /// Once registered, an offering's parameters are immutable. + /// + /// # Arguments + /// * `issuer` - The address of the offering issuer. Must provide authentication. + /// * `namespace` - A symbol identifying the namespace for this offering. + /// * `token` - The address of the token being offered. + /// * `revenue_share_bps` - The revenue share percentage in basis points (0-10,000). + /// Values above 10,000 are rejected unless testnet mode is enabled (admin-only, + /// never enable on mainnet - see `TESTNET_MODE.md`). + /// * `payout_asset` - The asset in which revenue will be paid out. + /// * `supply_cap` - Optional cap on the total amount of revenue that can be deposited (0 = no cap). + /// * `denomination_symbol` - Human-readable ticker for the payout denomination (e.g. `USDC`, `XLM`). + /// Stored as-is; not validated against on-chain token registries. + /// Maximum 9 characters (Soroban `Symbol` limit). + /// * `display_decimals` - Decimal precision wallets should use when displaying amounts. + /// Must satisfy `display_decimals <= MAX_TOKEN_DECIMALS (18)`. + /// Callers should also ensure `display_decimals <= payment_token_decimals`; verify + /// via `get_payment_token_decimals` before calling. + /// + /// # Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::InvalidRevenueShareBps)` if `revenue_share_bps` exceeds 10,000 + /// and testnet mode is disabled (the default). + /// - `Err(RevoraError::DisplayDecimalsOutOfRange)` if `display_decimals > 18`. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::ContractPaused)` if the contract is paused. + /// + /// # Events + /// Emits `EVENT_OFFER_REG_V2` (payload includes `denomination_symbol` and `display_decimals`) + /// and `EVENT_INDEXED_V2`. + /// + /// # Security note + /// `denomination_symbol` is informational only and does not affect payout math or transfers. + /// Issuers are responsible for providing values consistent with the actual `payout_asset`. + #[allow(clippy::too_many_arguments)] + pub fn register_offering( + env: Env, + primary_issuer: Address, + co_issuers: Vec
, + quorum: u32, + namespace: Symbol, + token: Address, + revenue_share_bps: u32, + payout_asset: Address, + supply_cap: i128, + denomination_symbol: Symbol, + display_decimals: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + primary_issuer.require_auth(); + + // Validate quorum: must be at least 1, and at most 1 + number of co-issuers + let total_issuers = 1 + co_issuers.len() as u32; + if quorum == 0 || quorum > total_issuers { + return Err(RevoraError::LimitReached); + } + + if let Some(ref cls_vec) = classes { + let mut sum_bps: u32 = 0; + for (_, config) in cls_vec.iter() { + sum_bps = sum_bps + .checked_add(config.bps) + .ok_or(RevoraError::InvalidShareClassBps)?; + } + if sum_bps != 10_000 { + return Err(RevoraError::InvalidShareClassBps); + } + } + + // Negative Amount Validation Matrix: SupplyCap requires >= 0 (#163) + if let Err((err, _)) = AmountValidationMatrix::validate( + supply_cap, + AmountValidationCategory::SupplyCap, ) { - if old_share == new_share { - return; - } - - let period_count = Self::get_period_count_internal(env, offering_id); - let existing = Self::get_holder_share_schedule(env, offering_id, holder); - let mut updated = Vec::new(env); - - for i in 0..existing.len() { - let checkpoint = existing.get(i).unwrap(); - if checkpoint.start_index == period_count { - continue; - } - updated.push_back(checkpoint); - } - - updated - .push_back(HolderShareCheckpoint { start_index: period_count, share_bps: new_share }); - env.storage() - .persistent() - .set(&DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), &updated); + return Err(err); } - fn get_holder_accrual_state( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - ) -> HolderAccrualState { - let last_claimed_idx: u32 = env - .storage() - .persistent() - .get::<_, u32>(&DataKey::LastClaimedIdx(offering_id.clone(), holder.clone())) - .unwrap_or(0); - - let mut state = env - .storage() - .persistent() - .get::<_, HolderAccrualState>(&DataKey2::HolderAccrualState( - offering_id.clone(), - holder.clone(), - )) - .unwrap_or(HolderAccrualState { - last_settled_idx: last_claimed_idx, - last_acc_per_share_e18: Self::get_acc_per_share_at_index( - env, - offering_id, - last_claimed_idx, - ), - accrued_owed: 0, - }); - - if state.last_settled_idx < last_claimed_idx { - state.last_settled_idx = last_claimed_idx; - state.last_acc_per_share_e18 = - Self::get_acc_per_share_at_index(env, offering_id, last_claimed_idx); - state.accrued_owed = 0; - } - - state + // display_decimals must not exceed the protocol-wide maximum of 18. + // Prevents callers from supplying nonsensical precision that confuses downstream display. + if display_decimals > MAX_TOKEN_DECIMALS { + return Err(RevoraError::DisplayDecimalsOutOfRange); } - fn compute_holder_payout_for_range( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - start_idx: u32, - end_idx: u32, - ) -> i128 { - if start_idx >= end_idx { - return 0; - } - - let schedule = Self::get_holder_share_schedule(env, offering_id, holder); - if schedule.is_empty() { - return 0; - } - - let mut total = 0_i128; - let mut current_index = start_idx; - let mut current_share = 0_u32; - let mut schedule_idx = 0_u32; - - while schedule_idx < schedule.len() { - let checkpoint = schedule.get(schedule_idx).unwrap(); - if checkpoint.start_index > start_idx { - break; - } - current_share = checkpoint.share_bps; - schedule_idx = schedule_idx.saturating_add(1); - } - - while current_index < end_idx { - while schedule_idx < schedule.len() { - let checkpoint = schedule.get(schedule_idx).unwrap(); - if checkpoint.start_index > current_index { - break; - } - current_share = checkpoint.share_bps; - schedule_idx = schedule_idx.saturating_add(1); - } - - if current_share > 0 { - let acc_end = Self::get_acc_per_share_at_index( - env, - offering_id, - current_index.saturating_add(1), - ); - let acc_start = Self::get_acc_per_share_at_index(env, offering_id, current_index); - let delta = acc_end.saturating_sub(acc_start); - total = total.saturating_add( - delta.saturating_mul(current_share as i128) / ACCRUAL_SCALE_E18, - ); - } - - current_index = current_index.saturating_add(1); - } - - total + // Decimals consistency guard (#612): query the on-chain payout_asset's + // decimals() and compare with display_decimals. If the token contract + // reverts on decimals() (e.g. non-StellarAsset tokens), skip the check + // to remain robust to exotic token implementations. + if let Ok(on_chain_decimals) = + token::Client::new(&env, &payout_asset).try_decimals() + { + if on_chain_decimals != display_decimals { + return Err(RevoraError::DecimalsMismatch); + } } - fn find_matured_claim_end_idx(env: &Env, offering_id: &OfferingId, start_idx: u32) -> u32 { - let period_count = Self::get_period_count_internal(env, offering_id); - if start_idx >= period_count { - return start_idx; - } - - let delay_secs: u64 = env - .storage() - .persistent() - .get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone())) - .unwrap_or(0); - let now = env.ledger().timestamp(); - let mut end_idx = start_idx; - - while end_idx < period_count { - let entry_key = DataKey::PeriodEntry(offering_id.clone(), end_idx); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); - if period_id == 0 { - end_idx = end_idx.saturating_add(1); - continue; - } - - let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - let deposit_time: u64 = env.storage().persistent().get(&time_key).unwrap_or(0); - if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { - break; - } - - end_idx = end_idx.saturating_add(1); - } - - end_idx + // Skip bps validation in testnet mode (reads the real flag from storage). + // In production mode (default) revenue_share_bps is always capped at 10 000 (100%). + // Testnet mode is admin-only and must never be enabled on mainnet - see TESTNET_MODE.md. + let testnet_mode = Self::is_testnet_mode(env.clone()); + if !testnet_mode && revenue_share_bps > 10_000 { + return Err(RevoraError::InvalidRevenueShareBps); } - fn cache_holder_accrual_through_matured(env: &Env, offering_id: &OfferingId, holder: &Address) { - let mut state = Self::get_holder_accrual_state(env, offering_id, holder); - let matured_end = - Self::find_matured_claim_end_idx(env, offering_id, state.last_settled_idx); - if matured_end <= state.last_settled_idx { - return; - } + let offering_id = OfferingId { + issuer: primary_issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let anchor_key = DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); - let anchor: Option = env.storage().persistent().get(&anchor_key); + // Duplicate prevention: check if offering already exists by its stable identity (issuer+namespace+token) + // This makes register_offering idempotent and prevents state inconsistencies in off-chain catalogs. + if env + .storage() + .persistent() + .has(&DataKey::OfferingIssuer(offering_id.clone())) + { + return Ok(()); + } + + // Register namespace for issuer if not already present + let ns_reg_key = + DataKey2::NamespaceRegistered(primary_issuer.clone(), namespace.clone()); + if !env.storage().persistent().has(&ns_reg_key) { + let ns_count_key = DataKey2::NamespaceCount(primary_issuer.clone()); + let count: u32 = + env.storage().persistent().get(&ns_count_key).unwrap_or(0); + env.storage().persistent().set( + &DataKey2::NamespaceItem(primary_issuer.clone(), count), + &namespace, + ); + env.storage().persistent().set(&ns_count_key, &(count + 1)); + env.storage().persistent().set(&ns_reg_key, &true); + } + + let tenant_id = TenantId { + issuer: primary_issuer.clone(), + namespace: namespace.clone(), + }; + let count_key = DataKey::OfferCount(tenant_id.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + + let offering = Offering { + issuers: Issuers { + primary: primary_issuer.clone(), + co: co_issuers.clone(), + quorum, + }, + namespace: namespace.clone(), + token: token.clone(), + revenue_share_bps, + payout_asset: payout_asset.clone(), + denomination_symbol: denomination_symbol.clone(), + display_decimals, + }; - let mut delta: i128 = 0; - let mut schedule_start = state.last_settled_idx; + let item_key = DataKey::OfferItem(tenant_id.clone(), count); + env.storage().persistent().set(&item_key, &offering); + env.storage().persistent().set(&count_key, &(count + 1)); + + // Direct index for O(1) get_offering (#360). + env + .storage() + .persistent() + .set(&DataKey2::OfferingRecord(offering_id.clone()), &offering); + + // Denomination metadata auxiliary index: O(1) read for display semantics. + env.storage().persistent().set( + &DataKey2::DenominationMetadata(offering_id.clone()), + &(denomination_symbol.clone(), display_decimals), + ); + + let issuer_lookup_key = DataKey::OfferingIssuer(offering_id.clone()); + env + .storage() + .persistent() + .set(&issuer_lookup_key, &primary_issuer); + + if supply_cap > 0 { + let cap_key = DataKey2::SupplyCap(offering_id.clone()); + env.storage().persistent().set(&cap_key, &supply_cap); + } + + // Primary registration event - denomination metadata included so indexers never + // need a second call to learn display semantics. + Self::emit_v2_event( + &env, + (EVENT_OFFER_REG_V2, issuer.clone(), namespace.clone()), + ( + token.clone(), + revenue_share_bps, + payout_asset.clone(), + denomination_symbol.clone(), + display_decimals, + ), + ); + + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_OFFER, + issuer: primary_issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id: 0, + }, + ), + (revenue_share_bps, payout_asset.clone()), + ); + + if false { + env.events().publish( + ( + EVENT_OFFER_REG_V1, + primary_issuer.clone(), + namespace.clone(), + ), + ( + EVENT_SCHEMA_VERSION, + token.clone(), + revenue_share_bps, + payout_asset.clone(), + ), + ); + } + // Versioned v2 event: always emitted (#RC26Q2-C31). + // Payload: (token, revenue_share_bps, payout_asset, denomination_symbol, display_decimals) + Self::emit_v2_event( + &env, + (EVENT_OFFER_REG_V2, issuer, namespace, token.clone()), + ( + token, + revenue_share_bps, + payout_asset, + denomination_symbol, + display_decimals, + ), + ); - if let Some(a) = anchor { - if state.last_settled_idx <= a.end_idx { - let anchor_start = state.last_settled_idx; - let anchor_end_incl = core::cmp::min(matured_end, a.end_idx.saturating_add(1)); - if anchor_start < anchor_end_incl { - delta = delta.saturating_add(a.claimable_sum); - } - schedule_start = core::cmp::max(schedule_start, a.end_idx.saturating_add(1)); - if matured_end > a.end_idx { - env.storage().persistent().remove(&anchor_key); + Ok(()) + } + + /// Return the denomination display metadata for an offering. + /// + /// This is a cheap O(1) read that does not require iterating offerings. + /// + /// ### Parameters + /// - `issuer`: The issuer address. + /// - `namespace`: The offering namespace. + /// - `token`: The offering token address. + /// + /// ### Returns + /// `Some((denomination_symbol, display_decimals))` if the offering exists, + /// `None` if no offering with that identity has been registered. + pub fn get_denomination_metadata( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option<(Symbol, u32)> { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env.storage().persistent().get::( + &DataKey2::DenominationMetadata(offering_id), + ) + } + + /// Fetch a single offering by issuer and token. + /// + /// This method scans the issuer's registered offerings to find the one matching the given token. + /// + /// ### Parameters + /// - `issuer`: The address that registered the offering. + /// - `token`: The token address associated with the offering. + /// + /// ### Returns + /// - `Some(Offering)` if found. + /// - `None` otherwise. + /// Fetch a single offering by issuer, namespace, and token. + /// + /// This method first attempts an O(1) direct lookup via the `OfferingRecord` index written + /// at registration (#360). Falls back to an O(n) scan for legacy offerings registered before + /// the index was introduced. + /// + /// ### Parameters + /// - `issuer`: The address that registered the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token address associated with the offering. + /// + /// ### Returns + /// - `Some(Offering)` if found. + /// - `None` otherwise. + pub fn get_offering( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + // O(1) direct lookup via index written at registration (#360). + if let Some(offering) = env + .storage() + .persistent() + .get::(&DataKey2::OfferingRecord(offering_id)) + { + return Some(offering); + } + // Fallback: O(n) scan for legacy offerings registered before the index was added. + let count = + Self::get_offering_count(env.clone(), issuer.clone(), namespace.clone()); + let tenant_id = TenantId { issuer, namespace }; + for i in 0..count { + let item_key = DataKey::OfferItem(tenant_id.clone(), i); + let offering: Offering = + env.storage().persistent().get(&item_key).unwrap(); + if offering.token == token { + return Some(offering); + } + } + None + } + + /// List all offering tokens for an issuer in a namespace. + pub fn list_offerings( + env: Env, + issuer: Address, + namespace: Symbol, + ) -> Vec
{ + let (page, _) = Self::get_offerings_page( + env.clone(), + issuer.clone(), + namespace, + 0, + MAX_PAGE_LIMIT, + ); + let mut tokens = Vec::new(&env); + for i in 0..page.len() { + tokens.push_back(page.get(i).unwrap().token); + } + tokens + } + + /// Return the locked payment token for an offering. + /// + /// Returns `None` when: + /// - the offering is unknown, or + /// - the offering exists but has not yet recorded a successful deposit. + /// + /// Once the first successful deposit persists the `PaymentToken` key, this returns + /// `Some(payment_token)` for that locked token. + pub fn get_payment_token( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + Self::get_locked_payment_token_for_offering(&env, &offering_id) + } + + /// Configure the FX oracle used to convert cross-currency revenue reports + /// into the offering payout asset before storing report and audit state. + /// + /// The issuer owns this configuration. `revenue_symbol` is passed to the + /// oracle as the quote source when `report_revenue` is called with a + /// non-payout asset; `payout_symbol` is the quote target for the registered + /// offering payout asset. + #[allow(clippy::too_many_arguments)] + pub fn set_fx_oracle( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + oracle: Address, + revenue_symbol: Symbol, + payout_symbol: Symbol, + max_oracle_age_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let config = FxOracleConfig { + oracle, + revenue_symbol, + payout_symbol, + max_oracle_age_secs, + }; + env + .storage() + .persistent() + .set(&DataKey2::FxOracleConfig(offering_id), &config); + Ok(()) + } + + /// Return the configured FX oracle for an offering, if one exists. + pub fn get_fx_oracle( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey2::FxOracleConfig(offering_id)) + } + + /// Configure an ordered oracle fallback chain for cross-currency FX conversion. + /// + /// When `report_revenue` needs an exchange rate, the contract iterates `entries` + /// in order and uses the **first** oracle that returns a fresh (non-stale) quote. + /// If every oracle in the chain is stale, the call returns + /// `RevoraError::AllOraclesStale`. + /// + /// An empty `entries` vec clears any previously stored chain. When a chain is + /// present it **takes priority** over the legacy `FxOracleConfig` single-oracle + /// setting; `set_fx_oracle` / `get_fx_oracle` continue to work as before for + /// offerings that have not configured a chain. + /// + /// # Security + /// Only the current offering issuer may configure this. Each entry is validated + /// to ensure `max_age_secs` is non-zero (enforced by caller; the contract stores + /// whatever is provided — a zero value disables staleness checks for that entry). + /// + /// # Limits + /// `entries.len()` must not exceed `MAX_ORACLE_CHAIN_LEN` (10). + pub fn set_oracle_chain( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + entries: Vec, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + if entries.len() > MAX_ORACLE_CHAIN_LEN { + return Err(RevoraError::LimitReached); + } + + let chain = OracleChain { entries }; + env + .storage() + .persistent() + .set(&DataKey2::OracleChain(offering_id), &chain); + Ok(()) + } + + /// Return the oracle fallback chain configured for an offering, if any. + pub fn get_oracle_chain( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey2::OracleChain(offering_id)) + } + + /// Register the off-chain ED25519 public key for an oracle. + /// Only the contract admin can register oracle keys. + pub fn register_oracle_pubkey( + env: Env, + oracle_id: Address, + pubkey: BytesN<32>, + ) -> Result<(), RevoraError> { + Self::require_admin(&env)?; + env + .storage() + .persistent() + .set(&DataKey2::OraclePubKey(oracle_id), &pubkey); + Ok(()) + } + + /// Try to obtain a fresh FX rate from a single `OracleEntry`. + /// + /// Returns `Ok(rate_bps)` when the quote is within the staleness window, + /// or `Err(RevoraError::OracleQuoteStale)` when it is too old. + fn try_oracle_entry( + env: &Env, + entry: &OracleEntry, + now: u64, + ) -> Result { + let (rate, quoted_at) = FxOracleClient::new(env, &entry.oracle) + .quote(&entry.revenue_symbol, &entry.payout_symbol); + if entry.max_age_secs > 0 + && now.saturating_sub(quoted_at) > entry.max_age_secs + { + return Err(RevoraError::OracleQuoteStale); + } + Ok(rate) + } + + fn convert_report_amount_if_needed( + env: &Env, + offering_id: &OfferingId, + offering: &Offering, + reported_asset: &Address, + amount: i128, + now: u64, + quote_bytes: Option, + signature: Option>, + ) -> Result<(i128, Address), RevoraError> { + if offering.payout_asset == *reported_asset { + return Ok((amount, reported_asset.clone())); + } + + // ── Oracle chain path (takes priority over legacy single-oracle config) ── + if let Some(chain) = env + .storage() + .persistent() + .get::(&DataKey2::OracleChain(offering_id.clone())) + { + let mut chain_idx: u32 = 0; + for entry in chain.entries.iter() { + match Self::try_oracle_entry(env, &entry, now) { + Ok(rate) => { + // Emit oracle_source_used event so indexers know which oracle won. + env.events().publish( + ( + EVENT_ORACLE_SOURCE_USED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + ( + entry.oracle.clone(), + entry.revenue_symbol.clone(), + entry.payout_symbol.clone(), + chain_idx, + ), + ); + let converted = + amount.saturating_mul(rate).saturating_div(BPS_DENOMINATOR); + return Ok((converted, offering.payout_asset.clone())); + } + Err(_) => { + // This entry is stale — try the next one. + chain_idx = chain_idx.saturating_add(1); + } + } + } + // Every entry in the chain was stale. + return Err(RevoraError::AllOraclesStale); + } + + // ── Legacy single-oracle path ── + let config: FxOracleConfig = env + .storage() + .persistent() + .get(&DataKey2::FxOracleConfig(offering_id.clone())) + .ok_or(RevoraError::PayoutAssetMismatch)?; + let (rate, quoted_at) = + if let (Some(q), Some(sig)) = (quote_bytes, signature) { + crate::security_assertions::oracle_validation::verify_oracle_signature( + env, + &q, + &sig, + &config.oracle, + )?; + let decoded: (i128, u64) = env + .from_xdr(&q) + .map_err(|_| RevoraError::MetadataInvalidFormat)?; + decoded + } else { + FxOracleClient::new(env, &config.oracle) + .quote(&config.revenue_symbol, &config.payout_symbol) + }; + if config.max_oracle_age_secs > 0 + && now.saturating_sub(quoted_at) > config.max_oracle_age_secs + { + return Err(RevoraError::OracleQuoteStale); + } + let converted_amount = + amount.saturating_mul(rate).saturating_div(BPS_DENOMINATOR); + Ok((converted_amount, offering.payout_asset.clone())) + } + + /// Record or correct a revenue report for an offering and emit audit events. + /// + /// Semantics: + /// - New periods persist `(amount, timestamp)`, emit `rev_init`, and update + /// `AuditSummary` by `(amount, +1)`. + /// - Existing periods with `override_existing=true` emit `rev_ovrd` and update + /// `AuditSummary` by `(new_amount - old_amount, +0)`. + /// - Existing periods with `override_existing=false` emit `rev_rej` and leave + /// persisted state unchanged. + /// - New periods below the configured minimum threshold emit `rev_below` and + /// leave both persisted report state and the report cursor unchanged. + /// + /// Validates amount using the Negative Amount Validation Matrix (#163). + #[allow(clippy::too_many_arguments)] + /// Report revenue for a specific period of an offering. + /// + /// # Arguments + /// * `issuer` - The address of the offering issuer. + /// * `namespace` - A symbol identifying the namespace. + /// * `token` - The address of the token. + /// * `payout_asset` - The asset being reported. + /// * `amount` - The amount of revenue. + /// * `period_id` - The identifier for the revenue period. + /// * `override_existing` - If true, replaces an existing report for the same period. + /// + /// # Events + /// Emits `EVENT_REV_REP_V2` and `EVENT_INDEXED_V2`. + pub fn report_revenue( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + payout_asset: Address, + amount: i128, + period_id: u64, + override_existing: bool, + ) -> Result<(), RevoraError> { + Self::report_revenue_internal( + env, + issuer, + namespace, + token, + payout_asset, + amount, + period_id, + override_existing, + None, + None, + ) + } + + /// Report revenue for a specific period of an offering using an off-chain signed FX quote. + pub fn report_revenue_with_attestation( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + payout_asset: Address, + amount: i128, + period_id: u64, + override_existing: bool, + quote_bytes: Bytes, + signature: BytesN<64>, + ) -> Result<(), RevoraError> { + Self::report_revenue_internal( + env, + issuer, + namespace, + token, + payout_asset, + amount, + period_id, + override_existing, + Some(quote_bytes), + Some(signature), + ) + } + + #[allow(clippy::too_many_arguments)] + fn report_revenue_internal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + payout_asset: Address, + amount: i128, + period_id: u64, + override_existing: bool, + quote_bytes: Option, + signature: Option>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + let mut amount = amount; + let mut payout_asset = payout_asset; + + // Input validation (#35): reject zero/invalid period_id + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + + // Negative Amount Validation Matrix: RevenueReport requires amount >= 0 (#163) + if let Err((err, reason)) = AmountValidationMatrix::validate( + amount, + AmountValidationCategory::RevenueReport, + ) { + env.events().publish( + ( + EVENT_AMOUNT_VALIDATION_FAILED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, err as u32, reason), + ); + return Err(err); + } + + let event_only = Self::is_event_only(&env); + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let last_report_period_key = + DataKey2::LastReportedPeriodId(offering_id.clone()); + let current_timestamp = env.ledger().timestamp(); + + Self::require_not_offering_frozen(&env, &offering_id)?; + Self::require_report_window_open(&env, &offering_id)?; + + if !event_only { + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let converted = Self::convert_report_amount_if_needed( + &env, + &offering_id, + &offering, + &payout_asset, + amount, + current_timestamp, + quote_bytes, + signature, + )?; + amount = converted.0; + payout_asset = converted.1; + + // Testnet mode bypass: if enabled, skip concentration limit enforcement + // to allow flexible testing of revenue flows without holder constraints. + let testnet_mode = Self::is_testnet_mode(env.clone()); + if !testnet_mode { + let limit_key = DataKey::ConcentrationLimit(offering_id.clone()); + if let Some(config) = env + .storage() + .persistent() + .get::(&limit_key) + { + // Concentration Enforcement: if enforce=true and max_bps > 0, + // reject report if current concentration exceeds the limit. + // Allowed: current <= max_bps. Rejected: current > max_bps. + if config.enforce && config.max_bps > 0 { + // Staleness guard: if max_staleness_secs > 0, require a fresh report. + if config.max_staleness_secs > 0 { + let reported_at: Option = env + .storage() + .persistent() + .get(&DataKey::ConcentrationReportedAt(offering_id.clone())); + match reported_at { + None => return Err(RevoraError::StaleConcentrationData), + Some(ts) => { + if current_timestamp.saturating_sub(ts) + > config.max_staleness_secs + { + return Err(RevoraError::StaleConcentrationData); + } } + } } - } - - if schedule_start < matured_end { - delta = delta.saturating_add(Self::compute_holder_payout_for_range( - env, - offering_id, - holder, - schedule_start, - matured_end, - )); - } - - state.accrued_owed = state.accrued_owed.saturating_add(delta); - state.last_settled_idx = matured_end; - state.last_acc_per_share_e18 = - Self::get_acc_per_share_at_index(env, offering_id, matured_end); - - env.storage() - .persistent() - .set(&DataKey2::HolderAccrualState(offering_id.clone(), holder.clone()), &state); - } - - fn normalize_jurisdictions(env: &Env, jurisdictions: Vec) -> Vec { - let mut normalized = Vec::new(env); - for i in 0..jurisdictions.len() { - let jurisdiction = jurisdictions.get(i).unwrap(); - if !Self::vec_contains_symbol(&normalized, &jurisdiction) { - normalized.push_back(jurisdiction); + let curr_key = DataKey::CurrentConcentration(offering_id.clone()); + let current: u32 = + env.storage().persistent().get(&curr_key).unwrap_or(0); + if current > config.max_bps { + return Err(RevoraError::ConcentrationLimitExceeded); } - } - normalized - } - - fn vec_contains_symbol(values: &Vec, target: &Symbol) -> bool { - for i in 0..values.len() { - if values.get(i).unwrap() == *target { - return true; - } - } - false - } - - fn get_allowed_jurisdictions_internal(env: &Env, offering_id: &OfferingId) -> Vec { - env.storage() - .persistent() - .get(&DataKey2::AllowedJurisdictions(offering_id.clone())) - .unwrap_or_else(|| Vec::new(env)) - } + } + } + } + } + + let threshold = + Self::get_min_revenue_threshold_for_offering(&env, &offering_id); + + // Use bounded read for event snapshots to avoid unbounded payloads + // Cap at MAX_PAGE_LIMIT (20) to prevent gas spikes from large blacklists + let blacklist = if event_only { + Vec::new(&env) + } else { + Self::get_blacklist_page( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + 0, + MAX_PAGE_LIMIT, + ) + .0 + }; - fn get_holder_jurisdiction_internal( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - ) -> Option { - env.storage() - .persistent() - .get(&DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone())) - } + let mut actual_override = false; + let mut actual_initial = false; - fn emit_jurisdiction_reject( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - jurisdiction: Symbol, - action: Symbol, - ) { + if event_only { + if threshold > 0 && amount < threshold { env.events().publish( - ( - Self::jurisdiction_reject_event(env), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (holder.clone(), jurisdiction, action), + (EVENT_REV_BELOW_THRESHOLD, issuer, namespace, token), + (amount, period_id, threshold), ); - } - - fn require_holder_jurisdiction_allowed( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - action: Symbol, - ) -> Result<(), RevoraError> { - let allowed = Self::get_allowed_jurisdictions_internal(env, offering_id); - if allowed.is_empty() { - return Ok(()); - } - - let jurisdiction = Self::get_holder_jurisdiction_internal(env, offering_id, holder) - .unwrap_or(EVENT_JUR_UNSET); - - if Self::vec_contains_symbol(&allowed, &jurisdiction) { + return Ok(()); + } + + actual_initial = true; + env.events().publish( + ( + EVENT_REVENUE_REPORT_INITIAL, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, blacklist.clone()), + ); + env.events().publish( + ( + EVENT_REVENUE_REPORT_INITIAL_ASSET, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payout_asset.clone(), amount, period_id, blacklist.clone()), + ); + Self::emit_v2_and_v3( + &env, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_INIT, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_REV_INIT, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + _reserved: 0, + }, + (amount, payout_asset.clone()), + ); + } else { + let reports_key = DataKey::RevenueReports(offering_id.clone()); + let mut reports: Map = env + .storage() + .persistent() + .get(&reports_key) + .unwrap_or_else(|| Map::new(&env)); + let idx_key = DataKey::RevenueIndex(offering_id.clone(), period_id); + + match reports.get(period_id) { + Some((existing_amount, _)) => { + if !override_existing { + env.events().publish( + ( + EVENT_REVENUE_REPORT_REJECTED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, existing_amount, blacklist.clone()), + ); + Self::emit_v2_and_v3( + &env, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_REJ, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_REV_REJ, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + _reserved: 0, + }, + (amount, existing_amount, payout_asset.clone()), + ); + env.events().publish( + ( + EVENT_REVENUE_REPORT_REJECTED_ASSET, + issuer, + namespace, + token, + ), + (payout_asset, amount, period_id, existing_amount, blacklist), + ); return Ok(()); - } + } - Self::emit_jurisdiction_reject(env, offering_id, holder, jurisdiction, action); - Err(RevoraError::JurisdictionDisallowed) - } - - /// Return the per-offering jurisdiction migration grace period in seconds. - /// Defaults to [`DEFAULT_JURISDICTION_GRACE_SECS`] (7 days) when not configured. - fn get_jurisdiction_grace_secs(env: &Env, offering_id: &OfferingId) -> u64 { - env.storage() - .persistent() - .get::(&DataKey2::JurisdictionGracePeriod(offering_id.clone())) - .unwrap_or(DEFAULT_JURISDICTION_GRACE_SECS) - } - - /// Check whether a pending jurisdiction migration has passed its deadline. - /// - /// If the holder has a pending migration whose deadline has elapsed, this function - /// checks whether the *new* (target) jurisdiction is allowed. If the new jurisdiction - /// is disallowed, returns `Err(JurisdictionMigrationDeadlineExceeded)`. - /// - /// When the new jurisdiction **is** allowed, the migration is finalized: the holder's - /// jurisdiction is updated and the pending migration entry is cleared. - /// - /// If there is no pending migration, or the deadline has not yet passed, returns `Ok(())`. - fn require_jurisdiction_migration_not_expired( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - action: Symbol, - ) -> Result<(), RevoraError> { - let mig_key = DataKey2::JurisdictionMigration(offering_id.clone(), holder.clone()); - let migration = - env.storage().persistent().get::(&mig_key); - - let Some(migration) = migration else { - return Ok(()); - }; + // Reject override if the period has been sealed by close_period. + let closed_key = + DataKey2::ClosedPeriod(offering_id.clone(), period_id); + if env.storage().persistent().has(&closed_key) { + return Err(RevoraError::PeriodAlreadyClosed); + } + + actual_override = true; + reports.set(period_id, (amount, current_timestamp)); + env.storage().persistent().set(&reports_key, &reports); + env.storage().persistent().set(&idx_key, &amount); + + let summary_key = DataKey::AuditSummary(offering_id.clone()); + let mut summary: AuditSummary = + env.storage().persistent().get(&summary_key).unwrap_or( + AuditSummary { + total_revenue: 0, + report_count: 0, + }, + ); + let delta = amount.s_sub(existing_amount).unwrap_or(0); + summary.total_revenue = + summary.total_revenue.s_add(delta).unwrap_or(i128::MAX); + env.storage().persistent().set(&summary_key, &summary); - let now = env.ledger().timestamp(); - if now < migration.deadline { - // Grace period still active - return Ok(()); + env.events().publish( + ( + EVENT_REVENUE_REPORT_OVERRIDE, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, existing_amount, blacklist.clone()), + ); + Self::emit_v2_and_v3( + &env, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_OVR, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_REV_OVR, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + _reserved: 0, + }, + (amount, existing_amount, payout_asset.clone()), + ); + env.events().publish( + ( + EVENT_REVENUE_REPORT_OVERRIDE_ASSET, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + ( + payout_asset.clone(), + amount, + period_id, + existing_amount, + blacklist.clone(), + ), + ); } - - // Deadline has passed — check if the new jurisdiction is allowed - let allowed = Self::get_allowed_jurisdictions_internal(env, offering_id); - - let new_ok = - allowed.is_empty() || Self::vec_contains_symbol(&allowed, &migration.new_jurisdiction); - - if new_ok { - // Migration is allowed — finalize it - env.storage().persistent().set( - &DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), - &migration.new_jurisdiction, + None => { + if override_existing { + env.events().publish( + ( + EVENT_REVENUE_REPORT_MISSING_OVERRIDE, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id), ); - env.storage().persistent().remove(&mig_key); - return Ok(()); - } - - Self::emit_jurisdiction_reject( - env, - offering_id, - holder, - migration.new_jurisdiction, - action, - ); - Err(RevoraError::JurisdictionMigrationDeadlineExceeded) - } - - /// Return the explicitly persisted payment token lock for an offering, if any. - /// - /// The `PaymentToken` key is written only after the first successful deposit. - /// Before that point, the offering has no locked payment token. - fn get_locked_payment_token_for_offering( - env: &Env, - offering_id: &OfferingId, - ) -> Option
{ - let pt_key = DataKey::PaymentToken(offering_id.clone()); - env.storage().persistent().get::(&pt_key) - } - - /// Internal helper for revenue deposits. - /// Validates amount using the Negative Amount Validation Matrix (#163). - fn do_deposit_revenue( - env: &Env, - issuer: Address, - namespace: Symbol, - token: Address, - payment_token: Address, - amount: i128, - period_id: u64, - ) -> Result<(), RevoraError> { - // Negative Amount Validation Matrix: RevenueDeposit requires amount > 0 (#163) - if let Err((err, reason)) = - AmountValidationMatrix::validate(amount, AmountValidationCategory::RevenueDeposit) - { + Self::emit_v2_and_v3( + &env, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_OMISS, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_REV_OMISS, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + _reserved: 0, + }, + (amount, period_id, payout_asset.clone()), + ); + return Err(RevoraError::MissingReportForOverride); + } + // preserve existing initial-report behavior when override_existing=false + Self::require_next_period_id( + &env, + last_report_period_key.clone(), + period_id, + )?; + if threshold > 0 && amount < threshold { env.events().publish( - (EVENT_AMOUNT_VALIDATION_FAILED, issuer.clone(), namespace.clone(), token.clone()), - (amount, err as u32, reason), + ( + EVENT_REV_BELOW_THRESHOLD, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, threshold), ); - return Err(err); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Validate inputs (#35) - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - Self::require_positive_amount(amount)?; - - // Verify offering exists - if Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .is_none() - { - return Err(RevoraError::OfferingNotFound); - } - - let last_period_key = DataKey2::LastDepositedPeriodId(offering_id.clone()); - - // Check period not already deposited - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - if env.storage().persistent().has(&rev_key) { - return Err(RevoraError::PeriodAlreadyDeposited); - } + return Ok(()); + } - // Enforce period ordering invariant only after duplicate detection so repeated - // deposits fail with the period-specific error rather than a generic sequence error. - Self::require_next_period_id(env, last_period_key.clone(), period_id)?; - - // Supply cap check (#96): reject if deposit would exceed cap - let cap_key = DataKey2::SupplyCap(offering_id.clone()); - let cap: i128 = env.storage().persistent().get(&cap_key).unwrap_or(0); - if cap > 0 { - let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); - let deposited: i128 = env.storage().persistent().get(&deposited_key).unwrap_or(0); - let new_total = deposited.s_add(amount)?; - if new_total > cap { - return Err(RevoraError::SupplyCapExceeded); - } - } + actual_initial = true; + reports.set(period_id, (amount, current_timestamp)); + env.storage().persistent().set(&reports_key, &reports); + env.storage().persistent().set(&idx_key, &amount); + Self::commit_period_id( + &env, + last_report_period_key.clone(), + period_id, + ); + + let summary_key = DataKey::AuditSummary(offering_id.clone()); + let mut summary: AuditSummary = + env.storage().persistent().get(&summary_key).unwrap_or( + AuditSummary { + total_revenue: 0, + report_count: 0, + }, + ); + summary.total_revenue = + summary.total_revenue.s_add(amount).unwrap_or(i128::MAX); + summary.report_count = + summary.report_count.s_add(1).unwrap_or(u64::MAX); + env.storage().persistent().set(&summary_key, &summary); - let pt_key = DataKey::PaymentToken(offering_id.clone()); - if let Some(locked_payment_token) = - Self::get_locked_payment_token_for_offering(env, &offering_id) - { - if locked_payment_token != payment_token { - return Err(RevoraError::PaymentTokenMismatch); - } - } - - // Transfer tokens from issuer to contract - let contract_addr = env.current_contract_address(); - if token::Client::new(env, &payment_token) - .try_transfer(&issuer, &contract_addr, &amount) - .is_err() - { - return Err(RevoraError::TransferFailed); - } - - // Store period revenue - env.storage().persistent().set(&rev_key, &amount); - - if !env.storage().persistent().has(&pt_key) { - env.storage().persistent().set(&pt_key, &payment_token); - } - - // Store deposit timestamp for time-delayed claims (#27) - let deposit_time = env.ledger().timestamp(); - let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - env.storage().persistent().set(&time_key, &deposit_time); - - // Append to indexed period list - let count_key = DataKey::PeriodCount(offering_id.clone()); - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - let entry_key = DataKey::PeriodEntry(offering_id.clone(), count); - env.storage().persistent().set(&entry_key, &period_id); - env.storage().persistent().set(&count_key, &(count + 1)); - Self::commit_period_id(env, last_period_key, period_id); - - let decimals = Self::get_payment_token_decimals( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - let normalized = Self::normalize_amount(amount, decimals); - let acc_delta_e18 = Self::accrual_delta_e18(normalized); - let global_acc_key = DataKey2::GlobalAccPerShareE18(offering_id.clone()); - let current_acc: i128 = env.storage().persistent().get(&global_acc_key).unwrap_or(0); - let next_acc = current_acc.saturating_add(acc_delta_e18); - env.storage().persistent().set(&global_acc_key, &next_acc); - env.storage() - .persistent() - .set(&DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), &next_acc); - - // Update cumulative deposited revenue and emit cap-reached event if applicable (#96) - let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); - let deposited: i128 = env.storage().persistent().get(&deposited_key).unwrap_or(0); - let new_deposited = deposited.s_add(amount)?; - env.storage().persistent().set(&deposited_key, &new_deposited); - - let cap_val: i128 = env.storage().persistent().get(&cap_key).unwrap_or(0); - if cap_val > 0 && new_deposited >= cap_val { - env.events().publish( - (EVENT_SUPPLY_CAP_REACHED, issuer.clone(), namespace.clone(), token.clone()), - (new_deposited, cap_val), - ); - } - - // Update the e18 accrual index - let decimals = Self::get_payment_token_decimals( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - ); - let normalized_amount = Self::normalize_amount(amount, decimals); - let total_share_bps_key = DataKey::HolderShareTotal(offering_id.clone()); - let total_share_bps: u32 = - env.storage().persistent().get(&total_share_bps_key).unwrap_or(0); - - if total_share_bps > 0 { - let accrual_delta = (normalized_amount.checked_mul(E18)) - .and_then(|x| x.checked_div(total_share_bps as i128)) - .unwrap_or(0); - let current_accrual_key = DataKey::AccrualIndexE18(offering_id.clone()); - let current_accrual: i128 = - env.storage().persistent().get(¤t_accrual_key).unwrap_or(0); - let new_accrual = current_accrual.checked_add(accrual_delta).unwrap_or(current_accrual); - env.storage().persistent().set(¤t_accrual_key, &new_accrual); - } - - // Versioned event v2: [version: u32, payment_token: Address, amount: i128, period_id: u64] - Self::emit_v2_event( - env, - (EVENT_REV_DEPOSIT_V2, issuer.clone(), namespace.clone(), token.clone()), - (payment_token, amount, period_id), - ); - env.events().publish( - (EVENT_ACC_UPD, issuer, namespace, token), - (period_id, count + 1, acc_delta_e18, next_acc), - ); - Ok(()) - } - - /// Return the supply cap for an offering (0 = no cap). (#96) - pub fn get_supply_cap(env: Env, issuer: Address, namespace: Symbol, token: Address) -> i128 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::SupplyCap(offering_id)).unwrap_or(0) - } - - pub fn set_max_total_supply_shares( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - max_total_supply_shares: i128, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - if let Err((err, _)) = AmountValidationMatrix::validate( - max_total_supply_shares, - AmountValidationCategory::MaxTotalSupplyShares, - ) { - return Err(err); - } - - let offering_id = OfferingId { issuer, namespace, token }; - let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id); - if max_total_supply_shares > 0 { - env.storage().persistent().set(&max_shares_key, &max_total_supply_shares); - } else { - env.storage().persistent().remove(&max_shares_key); - } - Ok(()) - } - - pub fn get_max_total_supply_shares( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> i128 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::MaxTotalSupplyShares(offering_id)).unwrap_or(0) - } - - pub fn get_total_shares_issued( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> i128 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id)).unwrap_or(0) - } - - pub fn get_total_class_shares_issued( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - share_class: ShareClass, - ) -> i128 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::TotalClassSharesIssued(offering_id, share_class)) - .unwrap_or(0) - } - - // ── Fee BPS Configuration (#98) ────────────────────────────────────────── - - /// Set the global platform fee in basis points. Admin-only. (#98) - /// - /// Emits `EVENT_PLATFORM_FEE_SET` with the new `fee_bps` value. - /// - /// ### Errors - /// - `NotInitialized` — contract not yet initialized. - /// - `InvalidRevenueShareBps` — `fee_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). - pub fn set_platform_fee(env: Env, fee_bps: u32) -> Result<(), RevoraError> { - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - admin.require_auth(); - if fee_bps > MAX_PLATFORM_FEE_BPS { - return Err(RevoraError::InvalidRevenueShareBps); - } - env.storage().persistent().set(&DataKey::PlatformFeeBps, &fee_bps); - env.events().publish((EVENT_PLATFORM_FEE_SET,), fee_bps); - Ok(()) - } - - /// Return the global platform fee in basis points (0 = no fee). (#98) - /// - /// O(1) — single persistent storage read. - pub fn get_platform_fee(env: Env) -> u32 { - env.storage().persistent().get(&DataKey::PlatformFeeBps).unwrap_or(0) - } - - /// Calculate the platform fee for `amount` using the stored global platform fee BPS. (#98) - /// - /// O(1) — one storage read plus integer arithmetic; no storage writes. - pub fn calculate_platform_fee(env: Env, amount: i128) -> i128 { - let fee_bps: i128 = - env.storage().persistent().get::(&DataKey::PlatformFeeBps).unwrap_or(0) - as i128; - (amount * fee_bps).checked_div(BPS_DENOMINATOR).unwrap_or(0) - } - - /// Set a per-offering per-asset fee override in basis points. Issuer-only. (#98) - /// - /// Emits `EVENT_FEE_CONFIG` with `(issuer, namespace, token, asset, fee_bps)`. - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist or caller is not the issuer. - /// - `InvalidRevenueShareBps` — `fee_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). - pub fn set_offering_fee_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - asset: Address, - fee_bps: u32, - ) -> Result<(), RevoraError> { - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - if fee_bps > MAX_PLATFORM_FEE_BPS { - return Err(RevoraError::InvalidRevenueShareBps); - } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage() - .persistent() - .set(&DataKey::OfferingFeeBps(offering_id, asset.clone()), &fee_bps); - env.events().publish((EVENT_FEE_CONFIG, issuer, namespace, token, asset), fee_bps); - Ok(()) - } - - /// Return the per-offering per-asset fee override in basis points (0 = use platform default). (#98) - /// - /// O(1) — single persistent storage read. - pub fn get_offering_fee_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - asset: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey::OfferingFeeBps(offering_id, asset)).unwrap_or(0) - } - - /// Set a per-offering per-asset secondary-market royalty in basis points. Issuer-only. (#562) - /// - /// Emits `EVENT_ROYALTY_CONFIG` with `(issuer, namespace, token, asset, royalty_bps)`. - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist or caller is not the issuer. - /// - `InvalidRevenueShareBps` — `royalty_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). - pub fn set_secondary_market_royalty_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - asset: Address, - royalty_bps: u32, - ) -> Result<(), RevoraError> { - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); - if royalty_bps > MAX_PLATFORM_FEE_BPS { - return Err(RevoraError::InvalidRevenueShareBps); - } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage() - .persistent() - .set(&DataKey::OfferingRoyaltyBps(offering_id, asset.clone()), &royalty_bps); - env.events().publish((EVENT_ROYALTY_CONFIG, issuer, namespace, token, asset), royalty_bps); - Ok(()) - } - - /// Return the per-offering per-asset secondary-market royalty in basis points. - /// 0 means no royalty is configured. - pub fn get_secondary_market_royalty_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - asset: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey::OfferingRoyaltyBps(offering_id, asset)) - .unwrap_or(0) - } - - /// Pay a configured secondary-market royalty on a transfer. - /// - /// The royalty amount is routed to the issuer and is computed as - /// `amount * royalty_bps / BPS_DENOMINATOR`. - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist. - /// - `InvalidAmount` — transfer amount is not positive. - /// - `TransferFailed` — token transfer to issuer failed. - pub fn pay_secondary_market_royalty( - env: Env, - payer: Address, - issuer: Address, - namespace: Symbol, - token: Address, - payment_asset: Address, - amount: i128, - seller: Address, - buyer: Address, - ) -> Result { - if amount <= 0 { - return Err(RevoraError::InvalidAmount); - } - - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - payer.require_auth(); - - let royalty_bps = Self::get_secondary_market_royalty_bps( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - payment_asset.clone(), - ); - let royalty_amount = - (amount * royalty_bps as i128).checked_div(BPS_DENOMINATOR).unwrap_or(0); - - if royalty_amount > 0 { - if token::Client::new(&env, &payment_asset) - .try_transfer(&payer, ¤t_issuer, &royalty_amount) - .is_err() - { - return Err(RevoraError::TransferFailed); - } - } - - Self::emit_v2_event( - &env, - (EVENT_ROYALTY_PAID, issuer.clone(), namespace.clone(), token.clone()), + env.events().publish( ( - payer.clone(), - seller.clone(), - buyer.clone(), - payment_asset.clone(), - amount, - royalty_amount, + EVENT_REVENUE_REPORT_INITIAL, + issuer.clone(), + namespace.clone(), + token.clone(), ), - ); - env.events().publish( - (EVENT_ROYALTY_PAID, issuer, namespace, token), - (payer, seller, buyer, payment_asset, amount, royalty_amount), - ); - - Ok(royalty_amount) - } - - /// Atomic swap for secondary market transfers. Transfers shares from seller to buyer - /// and settlement asset from buyer to seller, routing a royalty fee to the issuer. - /// - /// All three parties (issuer, seller, buyer) must authorize the swap via `require_auth`. - /// The swap executes both legs atomically: - /// 1. Royalty fee is routed from buyer to issuer (if configured). - /// 2. Remaining payment is transferred from buyer to seller. - /// 3. Shares (amount_bps) are transferred from seller to buyer via `transfer_with_attestation`. - /// - /// If any leg fails, all previous state changes are rolled back by the Soroban host - /// (transaction revert), guaranteeing atomicity. - /// - /// ### Auth - /// - `issuer` — offering issuer or co-issuer. - /// - `seller` — the address selling shares. - /// - `buyer` — the address purchasing shares and providing payment. - /// - /// ### Events - /// - `swap_v1` — emitted on successful completion. - /// - Inherits events from `pay_secondary_market_royalty` (roy_paid) and - /// `transfer_with_attestation` (sh_set2, rg_lim_d, xfer_att). - /// - /// ### Errors - /// - `InvalidAmount` — `payment_amount` is zero or negative. - /// - `TransferFailed` — token transfer for settlement failed. - /// - Inherits errors from `transfer_with_attestation` and `pay_secondary_market_royalty`. - pub fn atomic_swap( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - seller: Address, - buyer: Address, - amount_bps: u32, - category: Symbol, - payment_asset: Address, - payment_amount: i128, - ) -> Result<(), RevoraError> { - issuer.require_auth(); - seller.require_auth(); - buyer.require_auth(); - - if payment_amount <= 0 { - return Err(RevoraError::InvalidAmount); - } - - let royalty_amount = Self::pay_secondary_market_royalty( - env.clone(), - buyer.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - payment_asset.clone(), - payment_amount, - seller.clone(), - buyer.clone(), - )?; - - let seller_amount = payment_amount.checked_sub(royalty_amount).unwrap_or(0); - if seller_amount > 0 { - if token::Client::new(&env, &payment_asset) - .try_transfer(&buyer, &seller, &seller_amount) - .is_err() - { - return Err(RevoraError::TransferFailed); - } - } - - // Internal callers without attestation context pass expires_at=0 to - // skip attestation nonce/expiry validation. The attest_hash and network_id - // are zero-filled since atomic_swap does not require off-chain attestation. - let zero_hash = BytesN::from_array(&env, &[0u8; 32]); - let net_id = env.ledger().network_id(); - Self::transfer_with_attestation( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - seller.clone(), - buyer.clone(), - amount_bps, - category, - )?; - - // ── Emit swap_v1 event ───────────────────────────────────────────────── - Self::emit_v2_event( + (amount, period_id, blacklist.clone()), + ); + Self::emit_v2_and_v3( &env, - (EVENT_SWAP_V1, issuer.clone(), namespace.clone(), token.clone()), - ( - seller.clone(), - buyer.clone(), - amount_bps, - payment_asset.clone(), - payment_amount, - royalty_amount, - ), - ); - env.events().publish( - (EVENT_SWAP_V1, issuer, namespace, token), - (seller, buyer, amount_bps, payment_asset, payment_amount, royalty_amount), - ); - - Ok(()) - } - - /// Set a platform-level per-asset fee in basis points. Admin-only. (#98) - /// - /// Emits `EVENT_FEE_CONFIG` with `(asset, fee_bps)`. - /// - /// ### Errors - /// - `NotInitialized` — contract not yet initialized. - /// - `InvalidRevenueShareBps` — `fee_bps` exceeds `MAX_PLATFORM_FEE_BPS` (5 000). - pub fn set_platform_fee_per_asset( - env: Env, - asset: Address, - fee_bps: u32, - ) -> Result<(), RevoraError> { - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - admin.require_auth(); - if fee_bps > MAX_PLATFORM_FEE_BPS { - return Err(RevoraError::InvalidRevenueShareBps); - } - env.storage().persistent().set(&DataKey::PlatformFeePerAsset(asset.clone()), &fee_bps); - env.events().publish((EVENT_FEE_CONFIG, asset), fee_bps); - Ok(()) - } - - /// Return the platform-level per-asset fee in basis points (0 = no per-asset override). (#98) - /// - /// O(1) — single persistent storage read. - pub fn get_platform_fee_per_asset(env: Env, asset: Address) -> u32 { - env.storage().persistent().get(&DataKey::PlatformFeePerAsset(asset)).unwrap_or(0) - } - - // ── Platform Fee Model (#468) ────────────────────────────────── - - /// Configure the per-offering platform fee model: a programmable `fee_bps` cut routed - /// to `treasury` on each `report_revenue` call. Admin-only. (#468) - /// - /// The fee and the offering's holders share the same 100% (10_000 bps) budget, so this - /// rejects any configuration where `fee_bps` plus the offering's aggregate holder share - /// would exceed 10_000 bps. Setting `fee_bps = 0` disables the fee (no deduction and no - /// `plat_fee` event on subsequent reports) while still recording the `treasury` for clarity. - /// - /// Emits `EVENT_PLAT_FEE_SET` with topic `(issuer, namespace, token)` and data - /// `(fee_bps, treasury)`. - /// - /// ### Auth - /// Contract admin (`require_auth`). - /// - /// ### Errors - /// - `NotInitialized` — contract admin is not set. - /// - `OfferingNotFound` — offering does not exist. - /// - `FeeExceedsHolderShare` — `fee_bps` + aggregate holder share would exceed 10_000 bps. - pub fn set_offering_platform_fee( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - fee_bps: u32, - treasury: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - admin.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Offering must exist before a fee model can be attached to it. - if !env.storage().persistent().has(&DataKey::OfferingIssuer(offering_id.clone())) { - return Err(RevoraError::OfferingNotFound); - } - - // Fee bps + holder bps must always sum to at most 10_000 at the offering level. - // The aggregate holder share is maintained incrementally by `set_holder_share_internal`. - let holder_aggregate_bps: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShareTotal(offering_id.clone())) - .unwrap_or(0); - if fee_bps.saturating_add(holder_aggregate_bps) > 10_000 { - return Err(RevoraError::FeeExceedsHolderShare); - } - - let model = PlatformFeeModel { fee_bps, treasury: treasury.clone() }; - env.storage().persistent().set(&DataKey2::OfferingPlatformFee(offering_id), &model); - env.events().publish((EVENT_PLAT_FEE_SET, issuer, namespace, token), (fee_bps, treasury)); - Ok(()) - } - - /// Return the configured per-offering platform fee model, if any. (#468) - /// - /// O(1) — single persistent storage read. Returns `None` when no fee model is configured. - pub fn get_offering_platform_fee( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::OfferingPlatformFee(offering_id)) - } - - /// Apply the per-offering platform fee for a recorded revenue report. (#468) - /// - /// When a fee model is configured with a non-zero `fee_bps`, the programmable share of - /// `amount` is routed to the treasury and surfaced via `EVENT_PLAT_FEE`. A `fee_bps` of 0 - /// (or a computed fee of 0, e.g. zero-revenue reports) is a no-op and emits no event, so - /// indexers can rely on `plat_fee` being present only when a real fee was taken. - /// - /// Returns the fee amount routed to the treasury (0 when no fee applies). - fn apply_platform_fee( - env: &Env, - offering_id: &OfferingId, - issuer: &Address, - namespace: &Symbol, - token: &Address, - amount: i128, - period_id: u64, - ) -> i128 { - let model: PlatformFeeModel = match env - .storage() - .persistent() - .get(&DataKey2::OfferingPlatformFee(offering_id.clone())) - { - Some(m) => m, - None => return 0, - }; - - if model.fee_bps == 0 || amount <= 0 { - return 0; - } - - let fee_amount = - amount.saturating_mul(model.fee_bps as i128).checked_div(BPS_DENOMINATOR).unwrap_or(0); - if fee_amount <= 0 { - return 0; - } - - env.events().publish( - (EVENT_PLAT_FEE, issuer.clone(), namespace.clone(), token.clone()), - (model.treasury, model.fee_bps, fee_amount, period_id), - ); - fee_amount - } - - /// Return true if the contract is in event-only mode. - pub fn is_event_only(env: &Env) -> bool { - let (_, event_only): (bool, bool) = - env.storage().persistent().get(&DataKey2::ContractFlags).unwrap_or((false, false)); - event_only - } - - /// Input validation (#35): require amount > 0 for transfers/deposits. - #[allow(dead_code)] - fn require_positive_amount(amount: i128) -> Result<(), RevoraError> { - if amount <= 0 { - return Err(RevoraError::InvalidAmount); - } - Ok(()) - } - - /// Require `period_id` to be strictly greater than the last committed period for the key. - fn require_next_period_id(env: &Env, key: K, period_id: u64) -> Result<(), RevoraError> - where - K: IntoVal + Clone, - { - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - let last: u64 = env.storage().persistent().get(&key).unwrap_or(0); - if period_id != last + 1 { - return Err(RevoraError::InvalidPeriodId); - } - Ok(()) - } - - fn commit_period_id(env: &Env, key: K, period_id: u64) - where - K: IntoVal + Clone, - { - env.storage().persistent().set(&key, &period_id); - } - - fn get_min_revenue_threshold_for_offering(env: &Env, offering_id: &OfferingId) -> i128 { - env.storage() - .persistent() - .get(&DataKey2::MinRevenueThreshold(offering_id.clone())) - .unwrap_or(0) - } - - fn compute_audit_summary_from_reports( - env: &Env, - offering_id: &OfferingId, - ) -> (AuditSummary, bool) { - let reports_key = DataKey::RevenueReports(offering_id.clone()); - let reports: Map = - env.storage().persistent().get(&reports_key).unwrap_or_else(|| Map::new(env)); - - let mut total_revenue: i128 = 0; - let mut is_saturated = false; - let keys = reports.keys(); - for i in 0..keys.len() { - let period_id = keys.get(i).unwrap(); - if let Some((amount, _)) = reports.get(period_id) { - if let Ok(next) = total_revenue.s_add(amount) { - total_revenue = next; - } else { - is_saturated = true; - total_revenue = i128::MAX; - } - } - } - - (AuditSummary { total_revenue, report_count: reports.len() as u64 }, is_saturated) - } - - /// Initialize the contract with an admin and an optional safety role. - /// - /// This method follows the singleton pattern and can only be called once. - /// - /// ### Parameters - /// - `admin`: The primary administrative address with authority to pause/unpause and manage offerings. - /// - `safety`: Optional address allowed to trigger emergency pauses but not manage offerings. - /// - /// ### Panics - /// Panics if the contract has already been initialized. - /// Get the current issuer for an offering token (used for auth checks after transfers). - fn get_current_issuer( - env: &Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option
{ - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::OfferingIssuer(offering_id); - env.storage().persistent().get(&key) - } - - fn ensure_issuer_registered(env: &Env, issuer: &Address) { - let issuer_key = DataKey2::IssuerRegistered(issuer.clone()); - if !env.storage().persistent().has(&issuer_key) { - let count: u32 = env.storage().persistent().get(&DataKey2::IssuerCount).unwrap_or(0); - env.storage().persistent().set(&DataKey2::IssuerItem(count), issuer); - env.storage().persistent().set(&DataKey2::IssuerCount, &(count + 1)); - env.storage().persistent().set(&issuer_key, &true); - } - } - - fn ensure_namespace_registered(env: &Env, issuer: &Address, namespace: &Symbol) { - let ns_key = DataKey2::NamespaceRegistered(issuer.clone(), namespace.clone()); - if !env.storage().persistent().has(&ns_key) { - let ns_count: u32 = env - .storage() - .persistent() - .get(&DataKey2::NamespaceCount(issuer.clone())) - .unwrap_or(0); - env.storage() - .persistent() - .set(&DataKey2::NamespaceItem(issuer.clone(), ns_count), namespace); - env.storage() - .persistent() - .set(&DataKey2::NamespaceCount(issuer.clone()), &(ns_count + 1)); - env.storage().persistent().set(&ns_key, &true); - } - } - - /// Enable or disable testnet mode for the contract. - /// - /// ### Security Note - /// This mode MUST only be enabled on test networks. It relaxes critical - /// validation rules (like concentration limits) to facilitate automated - /// testing and integration flows. - pub fn set_testnet_mode(env: Env, enabled: bool) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - admin.require_auth(); - env.storage().persistent().set(&DataKey::TestnetMode, &enabled); - env.events().publish((EVENT_TESTNET_MODE,), enabled); - Ok(()) - } - - /// Read-only accessor for the on-chain storage layout version stamp. - pub fn storage_layout_version(env: Env) -> Option { - env.storage().persistent().get(&DataKey::StorageLayoutVersion) - } - - /// Admin-only setter to adjust the stored layout version (used by migrations/tests). - /// Emits `EVENT_LAYOUT_VERSION` when the stored value is changed. - pub fn set_storage_layout_version( - env: Env, - caller: Address, - v: u32, - ) -> Result<(), RevoraError> { - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - admin.require_auth(); - if caller != admin { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey::StorageLayoutVersion, &v); - env.events().publish((EVENT_LAYOUT_VERSION,), v); - Ok(()) - } - - pub fn get_pending_issuer_transfer( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option
{ - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey::PendingIssuerTransfer(offering_id)) - .map(|pending| pending.new_issuer) - } - - /// Return full details of a pending issuer transfer, including the proposed new issuer, - /// the proposal timestamp, and the effective expiry in seconds (0 = default 7 days). - pub fn get_pending_transfer_details( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey::PendingIssuerTransfer(offering_id)) - } - - fn find_pending_transfer_for_new_issuer( - env: &Env, - namespace: &Symbol, - token: &Address, - new_issuer: &Address, - ) -> Option { - let issuer_count: u32 = env.storage().persistent().get(&DataKey2::IssuerCount).unwrap_or(0); - for i in 0..issuer_count { - let issuer: Address = env.storage().persistent().get(&DataKey2::IssuerItem(i)).unwrap(); - let ns_count: u32 = env - .storage() - .persistent() - .get(&DataKey2::NamespaceCount(issuer.clone())) - .unwrap_or(0); - for j in 0..ns_count { - let namespace_item: Symbol = env - .storage() - .persistent() - .get(&DataKey2::NamespaceItem(issuer.clone(), j)) - .unwrap(); - if namespace_item != *namespace { - continue; - } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace_item.clone(), - token: token.clone(), - }; - if let Some(pending) = env.storage().persistent().get::( - &DataKey::PendingIssuerTransfer(offering_id.clone()), - ) { - if pending.new_issuer == *new_issuer { - return Some(offering_id); - } - } - } - } - None - } - - pub fn propose_issuer_transfer( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - new_issuer: Address, - ) -> Result<(), RevoraError> { - Self::do_propose_issuer_transfer(env, issuer, namespace, token, new_issuer, 0) - } - - /// Propose an issuer transfer with a custom expiry window. - /// - /// `expiry_secs` is clamped to `[MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS]`. - /// Pass `0` to use the default `ISSUER_TRANSFER_EXPIRY_SECS` (7 days). - #[allow(clippy::too_many_arguments)] - pub fn propose_transfer_with_expiry( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - new_issuer: Address, - expiry_secs: u64, - ) -> Result<(), RevoraError> { - Self::do_propose_issuer_transfer(env, issuer, namespace, token, new_issuer, expiry_secs) - } - - fn do_propose_issuer_transfer( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - new_issuer: Address, - expiry_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey::PendingIssuerTransfer(offering_id.clone()); - if env.storage().persistent().has(&key) { - return Err(RevoraError::IssuerTransferPending); - } - - // Clamp expiry: 0 means default; non-zero is clamped to [MIN, MAX]. - let effective_expiry = if expiry_secs == 0 { - 0 - } else { - expiry_secs.clamp(MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS) - }; - - let timestamp = env.ledger().timestamp(); - env.storage().persistent().set( - &key, - &PendingTransfer { - new_issuer: new_issuer.clone(), - timestamp, - expiry_secs: effective_expiry, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_INIT, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, }, - ); - env.events().publish( - (EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()), - (new_issuer.clone(), timestamp), - ); - Ok(()) - } - - pub fn replace_issuer_transfer( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - new_issuer: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::NotAuthorized); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey::PendingIssuerTransfer(offering_id.clone()); - if !env.storage().persistent().has(&key) { - return Err(RevoraError::NoTransferPending); - } - - let pending: PendingTransfer = env.storage().persistent().get(&key).unwrap(); - let timestamp = env.ledger().timestamp(); - // Preserve the original expiry_secs so the replacement inherits the same window. - env.storage().persistent().set( - &key, - &PendingTransfer { - new_issuer: new_issuer.clone(), - timestamp, - expiry_secs: pending.expiry_secs, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_REV_INIT, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + _reserved: 0, }, - ); - - env.events().publish( - (EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()), - (issuer.clone(), pending.new_issuer.clone()), - ); - env.events().publish( - (EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()), - (new_issuer.clone(), timestamp), - ); - Ok(()) - } - - pub fn accept_issuer_transfer( - env: Env, - new_issuer: Address, - namespace: Symbol, - token: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - new_issuer.require_auth(); - - let offering_id = - Self::find_pending_transfer_for_new_issuer(&env, &namespace, &token, &new_issuer) - .ok_or(RevoraError::NoTransferPending)?; - - let pending: PendingTransfer = env - .storage() - .persistent() - .get(&DataKey::PendingIssuerTransfer(offering_id.clone())) - .ok_or(RevoraError::NoTransferPending)?; - - let current_timestamp = env.ledger().timestamp(); - let effective_expiry = if pending.expiry_secs == 0 { - ISSUER_TRANSFER_EXPIRY_SECS - } else { - pending.expiry_secs - }; - if current_timestamp > pending.timestamp.saturating_add(effective_expiry) { - return Err(RevoraError::IssuerTransferExpired); - } - - let old_issuer = offering_id.issuer.clone(); - - if new_issuer == old_issuer { - env.storage().persistent().remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); - env.events().publish( - ( - EVENT_ISSUER_TRANSFER_ACCEPTED, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (old_issuer, new_issuer.clone()), - ); - return Ok(()); - } - - let new_offering_id = OfferingId { - issuer: new_issuer.clone(), - namespace: offering_id.namespace.clone(), - token: offering_id.token.clone(), - }; - - // Prevent duplicate offering entries for the same new issuer / namespace / token. - if Self::get_offering( - env.clone(), - new_issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ) - .is_some() - { - return Err(RevoraError::LimitReached); - } - - // Migrate any vesting schedules corresponding to this offering before completing - // the issuer transfer. This preserves active schedules under the new issuer key - // and prevents orphaned pre-cliff schedules. - let vesting_offering_id = vesting::VestingOfferingId { - issuer: old_issuer.clone(), - token: offering_id.token.clone(), - }; - match vesting::migrate_offering_schedules( + (amount, payout_asset.clone()), + ); + // Versioned v2 event: [2, amount, period_id, blacklist] — always emitted (#RC26Q2-C31) + Self::emit_v2_event( &env, - &vesting_offering_id, - new_issuer.clone(), - current_timestamp, - ) { - Ok(beneficiaries) => { - for beneficiary in beneficiaries.iter() { - env.events().publish( - ( - EVENT_ISSUER_TRANSFER_VESTING_MIGRATED, - offering_id.namespace.clone(), - offering_id.token.clone(), - beneficiary.clone(), - ), - (old_issuer.clone(), new_issuer.clone()), - ); - } - } - Err(vesting::VestingError::SchedulePreCliff) => { - return Err(RevoraError::VestingTransferBlocked); - } - Err(_) => { - // If the vesting index is empty or stale, ignore it and continue. - } - } - - // Register namespace metadata for the new issuer. - Self::ensure_issuer_registered(&env, &new_issuer); - Self::ensure_namespace_registered(&env, &new_issuer, &offering_id.namespace); - - // Copy the offering registration record to the new issuer's tenant list. - let tenant_id = - TenantId { issuer: new_issuer.clone(), namespace: offering_id.namespace.clone() }; - let count_key = DataKey::OfferCount(tenant_id.clone()); - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - let offering = Self::get_offering( - env.clone(), - old_issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ) - .ok_or(RevoraError::OfferingNotFound)?; - let item_key = DataKey::OfferItem(tenant_id.clone(), count); - env.storage().persistent().set(&item_key, &offering); - env.storage().persistent().set(&count_key, &(count + 1)); - - // Update direct index for the new issuer's offering_id (#360). - env.storage() - .persistent() - .set(&DataKey2::OfferingRecord(new_offering_id.clone()), &offering); - - // Update issuer lookups for the old and new offering IDs. - env.storage() - .persistent() - .set(&DataKey::OfferingIssuer(offering_id.clone()), &new_issuer.clone()); - env.storage() - .persistent() - .set(&DataKey::OfferingIssuer(new_offering_id.clone()), &new_issuer.clone()); - - // Migrate configuration state linked to the old OfferingId (#1344) - if let Some(config) = env - .storage() - .persistent() - .get::<_, ConcentrationLimitConfig>(&DataKey::ConcentrationLimit(offering_id.clone())) - { - env.storage() - .persistent() - .set(&DataKey::ConcentrationLimit(new_offering_id.clone()), &config); - env.storage().persistent().remove(&DataKey::ConcentrationLimit(offering_id.clone())); - } - if let Some(current) = env - .storage() - .persistent() - .get::<_, u32>(&DataKey::CurrentConcentration(offering_id.clone())) - { - env.storage() - .persistent() - .set(&DataKey::CurrentConcentration(new_offering_id.clone()), ¤t); - env.storage().persistent().remove(&DataKey::CurrentConcentration(offering_id.clone())); - } - if let Some(mode) = env - .storage() - .persistent() - .get::<_, RoundingMode>(&DataKey::RoundingMode(offering_id.clone())) - { - env.storage().persistent().set(&DataKey::RoundingMode(new_offering_id.clone()), &mode); - env.storage().persistent().remove(&DataKey::RoundingMode(offering_id.clone())); - } - if let Some(constraints) = env.storage().persistent().get::<_, InvestmentConstraintsConfig>( - &DataKey2::InvestmentConstraints(offering_id.clone()), - ) { - env.storage() - .persistent() - .set(&DataKey2::InvestmentConstraints(new_offering_id.clone()), &constraints); - env.storage() - .persistent() - .remove(&DataKey2::InvestmentConstraints(offering_id.clone())); - } - if let Some(delay) = - env.storage().persistent().get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone())) - { - env.storage() - .persistent() - .set(&DataKey::ClaimDelaySecs(new_offering_id.clone()), &delay); - env.storage().persistent().remove(&DataKey::ClaimDelaySecs(offering_id.clone())); - } - if let Some(snap_config) = - env.storage().persistent().get::<_, bool>(&DataKey::SnapshotConfig(offering_id.clone())) - { - env.storage() - .persistent() - .set(&DataKey::SnapshotConfig(new_offering_id.clone()), &snap_config); - env.storage().persistent().remove(&DataKey::SnapshotConfig(offering_id.clone())); - } - if let Some(snap_ref) = - env.storage().persistent().get::<_, u64>(&DataKey::LastSnapshotRef(offering_id.clone())) - { - env.storage() - .persistent() - .set(&DataKey::LastSnapshotRef(new_offering_id.clone()), &snap_ref); - env.storage().persistent().remove(&DataKey::LastSnapshotRef(offering_id.clone())); - } - - env.storage().persistent().remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); - - env.events().publish( - ( - EVENT_ISSUER_TRANSFER_ACCEPTED, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (old_issuer, new_issuer.clone()), - ); - Ok(()) - } - - pub fn cancel_issuer_transfer( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::NotAuthorized); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey::PendingIssuerTransfer(offering_id.clone()); - if !env.storage().persistent().has(&key) { - return Err(RevoraError::NoTransferPending); - } - - let pending: PendingTransfer = env.storage().persistent().get(&key).unwrap(); - env.storage().persistent().remove(&key); - env.events().publish( - (EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()), - (issuer, pending.new_issuer), - ); - Ok(()) - } - - pub fn reject_issuer_transfer( - env: Env, - new_issuer: Address, - namespace: Symbol, - token: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - new_issuer.require_auth(); - - let offering_id = - Self::find_pending_transfer_for_new_issuer(&env, &namespace, &token, &new_issuer) - .ok_or(RevoraError::NoTransferPending)?; - - let pending: PendingTransfer = env - .storage() - .persistent() - .get(&DataKey::PendingIssuerTransfer(offering_id.clone())) - .ok_or(RevoraError::NoTransferPending)?; - - let old_issuer = offering_id.issuer.clone(); - - env.storage().persistent().remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); - - env.events().publish( - ( - EVENT_ISSUER_TRANSFER_REJECTED, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (old_issuer, new_issuer.clone()), - ); - Ok(()) - } - - /// Initialize admin and optional safety role for emergency pause (#7). - /// `event_only` configures the contract to skip persistent business state (#72). - /// Can only be called once; panics if already initialized. - pub fn initialize(env: Env, admin: Address, safety: Option
, event_only: Option) { - if env.storage().persistent().has(&DataKey::Admin) { - return; // Already initialized, no-op - } - env.storage().persistent().set(&DataKey::Admin, &admin); - Self::emit_v2_event(&env, (EVENT_ADMIN_SET,), admin.clone()); - if let Some(ref s) = safety { - env.storage().persistent().set(&DataKey::Safety, &s); - } - env.storage().persistent().set(&DataKey::Paused, &PauseState::NotPaused); - let eo = event_only.unwrap_or(false); - env.storage().persistent().set(&DataKey2::ContractFlags, &(false, eo)); - // Stamp storage layout version for future compatibility checks. - env.storage().persistent().set(&DataKey::StorageLayoutVersion, &STORAGE_LAYOUT_VERSION); - env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); - - // Persist the initial contract version as the minimum supported version. - // Future WASM binaries with a lower CONTRACT_VERSION will be rejected at entry. - env.storage().persistent().set(&DataKey::DeployedVersion, &CONTRACT_VERSION); - - env.events().publish((EVENT_INIT, admin.clone()), (safety, eo)); - } - - /// Soft-pause the contract (Admin only). - /// - /// `SoftPaused` blocks reports and deposits but **allows** `claim`, so - /// holders can still withdraw their funds during incident response. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address of the admin (must match initialized admin). - pub fn pause_admin(env: Env, caller: Address) -> Result<(), RevoraError> { - caller.require_auth(); - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller != admin { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey::Paused, &PauseState::SoftPaused); - // Legacy compatibility event - env.events().publish((EVENT_PAUSED, caller.clone()), ()); - // Versioned tier event - env.events().publish((EVENT_PAUSED2, caller.clone()), (PauseState::SoftPaused,)); - Ok(()) - } - - /// Unpause the contract (Admin only). - /// - /// Re-enables all operations after a pause. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address of the admin (must match initialized admin). - pub fn unpause_admin(env: Env, caller: Address) -> Result<(), RevoraError> { - caller.require_auth(); - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller != admin { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey::Paused, &PauseState::NotPaused); - env.events().publish((EVENT_UNPAUSED, caller.clone()), ()); - env.events().publish((EVENT_PAUSED2, caller.clone()), (PauseState::NotPaused,)); - Ok(()) - } - - /// Hard-pause the contract (Admin only). - /// - /// `HardPaused` blocks **every** state-mutating operation including `claim`. - /// Use this tier only when funds must be fully locked (e.g. critical exploit). - /// Only the admin can escalate to HardPaused; the safety role is limited to SoftPaused. - /// - /// ### Parameters - /// - `caller`: The address of the admin (must match initialized admin). - pub fn hard_pause_admin(env: Env, caller: Address) -> Result<(), RevoraError> { - caller.require_auth(); - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller != admin { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey::Paused, &PauseState::HardPaused); - env.events().publish((EVENT_PAUSED, caller.clone()), ()); - env.events().publish((EVENT_PAUSED2, caller.clone()), (PauseState::HardPaused,)); - Ok(()) - } - - /// Soft-pause the contract (Safety role only). - /// - /// `SoftPaused` blocks reports and deposits but **allows** `claim`, so - /// holders can still withdraw their funds during incident response. - /// The safety role cannot escalate to `HardPaused`; only the admin can. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address of the safety role (must match initialized safety address). - pub fn pause_safety(env: Env, caller: Address) -> Result<(), RevoraError> { - caller.require_auth(); - let safety: Address = - env.storage().persistent().get(&DataKey::Safety).ok_or(RevoraError::NotInitialized)?; - if caller != safety { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey::Paused, &PauseState::SoftPaused); - env.events().publish((EVENT_PAUSED, caller.clone()), ()); - env.events().publish((EVENT_PAUSED2, caller.clone()), (PauseState::SoftPaused,)); - Ok(()) - } - - /// Unpause the contract (Safety role only). - /// - /// Allows the safety role to resume contract operations. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address of the safety role (must match initialized safety address). - pub fn unpause_safety(env: Env, caller: Address) -> Result<(), RevoraError> { - caller.require_auth(); - let safety: Address = - env.storage().persistent().get(&DataKey::Safety).ok_or(RevoraError::NotInitialized)?; - if caller != safety { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey::Paused, &PauseState::NotPaused); - env.events().publish((EVENT_UNPAUSED, caller.clone()), ()); - env.events().publish((EVENT_PAUSED2, caller.clone()), (PauseState::NotPaused,)); - Ok(()) - } - - /// Toggle the V2-compat downgrade flag. - /// - /// When enabled (`true`), V2-shaped indexed events (`EVENT_INDEXED_V2`) are - /// emitted alongside V3 events, allowing indexers pinned to V2 to continue - /// working during the deprecation window. When disabled (`false`), only V3 - /// events are emitted, and indexers must have migrated to V3. - /// - /// Defaults to `true` at initialization. - /// - /// ### Auth - /// Requires `caller` to match the contract admin. - /// - /// ### Events - /// Emits `EVENT_V2_COMPAT_SET` on success. - /// - /// ### Deprecation - /// This flag is intended for a limited deprecation window. Once all indexers - /// have migrated to V3, the flag should be set to `false` and eventually - /// the V2 emission path and this flag can be removed entirely. - pub fn set_emit_v2_compat(env: Env, caller: Address, enabled: bool) -> Result<(), RevoraError> { - caller.require_auth(); - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller != admin { - return Err(RevoraError::NotAuthorized); - } - env.storage().persistent().set(&DataKey2::EmitV2Compat, &enabled); - env.events().publish((EVENT_V2_COMPAT_SET, caller), enabled); - Ok(()) - } - - /// Return the list of currently supported event index topics and their schema versions. - /// - /// Non-authorized, read-only query intended for indexers to negotiate topic - /// versions before subscribing. - /// - /// V3 topic (`ev_idx3`, version 3) is always present. - /// V2 topic (`ev_idx2`, version 2) is included when `EmitV2Compat` is enabled. - pub fn supported_event_versions(env: Env) -> Vec { - let mut list = Vec::new(&env); - // V3 topic is canonical and always active - list.push_back(EventVersionInfo { topic: symbol_short!("ev_idx3"), version: 3 }); - // V2 topic is active if v2 compat shim is enabled - if Self::is_emit_v2_compat(&env) { - list.push_back(EventVersionInfo { topic: symbol_short!("ev_idx2"), version: 2 }); - } - list - } - - /// Query the paused state of the contract. - /// - /// Returns `true` when the contract is in either `SoftPaused` or `HardPaused` state, - /// preserving backward compatibility with callers that only need a binary signal. - /// Use `get_pause_state` to distinguish between the two tiers. - pub fn is_paused(env: Env) -> bool { - matches!( - env.storage() - .persistent() - .get::(&DataKey::Paused) - .unwrap_or(PauseState::NotPaused), - PauseState::SoftPaused | PauseState::HardPaused - ) - } - - /// Return the current `PauseState` tier. - /// - /// - `NotPaused` – all operations open. - /// - `SoftPaused` – reports/deposits blocked; `claim` allowed. - /// - `HardPaused` – all state-mutating operations blocked including `claim`. - pub fn get_pause_state(env: Env) -> PauseState { - env.storage() - .persistent() - .get::(&DataKey::Paused) - .unwrap_or(PauseState::NotPaused) - } - - /// Helper: block if the contract is in SoftPaused or HardPaused state. - /// Used by reports, deposits, and all non-claim state-mutating entrypoints. - fn require_not_paused(env: &Env) -> Result<(), RevoraError> { - let state = env - .storage() - .persistent() - .get::(&DataKey::Paused) - .unwrap_or(PauseState::NotPaused); - if matches!(state, PauseState::SoftPaused | PauseState::HardPaused) { - return Err(RevoraError::ContractPaused); - } - Ok(()) - } - - // ── Offering management ─────────────────────────────────── - - /// Register a new revenue-share offering. - /// - /// Once registered, an offering's parameters are immutable. - /// - /// # Arguments - /// * `issuer` - The address of the offering issuer. Must provide authentication. - /// * `namespace` - A symbol identifying the namespace for this offering. - /// * `token` - The address of the token being offered. - /// * `revenue_share_bps` - The revenue share percentage in basis points (0-10,000). - /// Values above 10,000 are rejected unless testnet mode is enabled (admin-only, - /// never enable on mainnet - see `TESTNET_MODE.md`). - /// * `payout_asset` - The asset in which revenue will be paid out. - /// * `supply_cap` - Optional cap on the total amount of revenue that can be deposited (0 = no cap). - /// * `denomination_symbol` - Human-readable ticker for the payout denomination (e.g. `USDC`, `XLM`). - /// Stored as-is; not validated against on-chain token registries. - /// Maximum 9 characters (Soroban `Symbol` limit). - /// * `display_decimals` - Decimal precision wallets should use when displaying amounts. - /// Must satisfy `display_decimals <= MAX_TOKEN_DECIMALS (18)`. - /// Callers should also ensure `display_decimals <= payment_token_decimals`; verify - /// via `get_payment_token_decimals` before calling. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::InvalidRevenueShareBps)` if `revenue_share_bps` exceeds 10,000 - /// and testnet mode is disabled (the default). - /// - `Err(RevoraError::DisplayDecimalsOutOfRange)` if `display_decimals > 18`. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::ContractPaused)` if the contract is paused. - /// - /// # Events - /// Emits `EVENT_OFFER_REG_V2` (payload includes `denomination_symbol` and `display_decimals`) - /// and `EVENT_INDEXED_V2`. - /// - /// # Security note - /// `denomination_symbol` is informational only and does not affect payout math or transfers. - /// Issuers are responsible for providing values consistent with the actual `payout_asset`. - #[allow(clippy::too_many_arguments)] - pub fn register_offering( - env: Env, - primary_issuer: Address, - co_issuers: Vec
, - quorum: u32, - namespace: Symbol, - token: Address, - revenue_share_bps: u32, - payout_asset: Address, - supply_cap: i128, - denomination_symbol: Symbol, - display_decimals: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - primary_issuer.require_auth(); - - // Validate quorum: must be at least 1, and at most 1 + number of co-issuers - let total_issuers = 1 + co_issuers.len() as u32; - if quorum == 0 || quorum > total_issuers { - return Err(RevoraError::LimitReached); - } - - if let Some(ref cls_vec) = classes { - let mut sum_bps: u32 = 0; - for (_, config) in cls_vec.iter() { - sum_bps = - sum_bps.checked_add(config.bps).ok_or(RevoraError::InvalidShareClassBps)?; - } - if sum_bps != 10_000 { - return Err(RevoraError::InvalidShareClassBps); - } - } - - // Negative Amount Validation Matrix: SupplyCap requires >= 0 (#163) - if let Err((err, _)) = - AmountValidationMatrix::validate(supply_cap, AmountValidationCategory::SupplyCap) - { - return Err(err); - } - - // display_decimals must not exceed the protocol-wide maximum of 18. - // Prevents callers from supplying nonsensical precision that confuses downstream display. - if display_decimals > MAX_TOKEN_DECIMALS { - return Err(RevoraError::DisplayDecimalsOutOfRange); - } - - // Decimals consistency guard (#612): query the on-chain payout_asset's - // decimals() and compare with display_decimals. If the token contract - // reverts on decimals() (e.g. non-StellarAsset tokens), skip the check - // to remain robust to exotic token implementations. - if let Ok(on_chain_decimals) = token::Client::new(&env, &payout_asset).try_decimals() { - if on_chain_decimals != display_decimals { - return Err(RevoraError::DecimalsMismatch); - } - } - - // Skip bps validation in testnet mode (reads the real flag from storage). - // In production mode (default) revenue_share_bps is always capped at 10 000 (100%). - // Testnet mode is admin-only and must never be enabled on mainnet - see TESTNET_MODE.md. - let testnet_mode = Self::is_testnet_mode(env.clone()); - if !testnet_mode && revenue_share_bps > 10_000 { - return Err(RevoraError::InvalidRevenueShareBps); - } - - let offering_id = OfferingId { - issuer: primary_issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Duplicate prevention: check if offering already exists by its stable identity (issuer+namespace+token) - // This makes register_offering idempotent and prevents state inconsistencies in off-chain catalogs. - if env.storage().persistent().has(&DataKey::OfferingIssuer(offering_id.clone())) { - return Ok(()); - } - - // Register namespace for issuer if not already present - let ns_reg_key = DataKey2::NamespaceRegistered(primary_issuer.clone(), namespace.clone()); - if !env.storage().persistent().has(&ns_reg_key) { - let ns_count_key = DataKey2::NamespaceCount(primary_issuer.clone()); - let count: u32 = env.storage().persistent().get(&ns_count_key).unwrap_or(0); - env.storage() - .persistent() - .set(&DataKey2::NamespaceItem(primary_issuer.clone(), count), &namespace); - env.storage().persistent().set(&ns_count_key, &(count + 1)); - env.storage().persistent().set(&ns_reg_key, &true); - } - - let tenant_id = TenantId { issuer: primary_issuer.clone(), namespace: namespace.clone() }; - let count_key = DataKey::OfferCount(tenant_id.clone()); - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let offering = Offering { - issuers: Issuers { primary: primary_issuer.clone(), co: co_issuers.clone(), quorum }, - namespace: namespace.clone(), - token: token.clone(), - revenue_share_bps, - payout_asset: payout_asset.clone(), - denomination_symbol: denomination_symbol.clone(), - display_decimals, - }; - - let item_key = DataKey::OfferItem(tenant_id.clone(), count); - env.storage().persistent().set(&item_key, &offering); - env.storage().persistent().set(&count_key, &(count + 1)); - - // Direct index for O(1) get_offering (#360). - env.storage().persistent().set(&DataKey2::OfferingRecord(offering_id.clone()), &offering); - - // Denomination metadata auxiliary index: O(1) read for display semantics. - env.storage().persistent().set( - &DataKey2::DenominationMetadata(offering_id.clone()), - &(denomination_symbol.clone(), display_decimals), - ); - - let issuer_lookup_key = DataKey::OfferingIssuer(offering_id.clone()); - env.storage().persistent().set(&issuer_lookup_key, &primary_issuer); - - if supply_cap > 0 { - let cap_key = DataKey2::SupplyCap(offering_id.clone()); - env.storage().persistent().set(&cap_key, &supply_cap); - } - - // Primary registration event - denomination metadata included so indexers never - // need a second call to learn display semantics. - Self::emit_v2_event( - &env, - (EVENT_OFFER_REG_V2, issuer.clone(), namespace.clone()), - ( - token.clone(), - revenue_share_bps, - payout_asset.clone(), - denomination_symbol.clone(), - display_decimals, - ), - ); - - env.events().publish( - ( - EVENT_INDEXED_V2, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_OFFER, - issuer: primary_issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id: 0, - }, - ), - (revenue_share_bps, payout_asset.clone()), - ); - - if false { - env.events().publish( - (EVENT_OFFER_REG_V1, primary_issuer.clone(), namespace.clone()), - (EVENT_SCHEMA_VERSION, token.clone(), revenue_share_bps, payout_asset.clone()), - ); - } - // Versioned v2 event: always emitted (#RC26Q2-C31). - // Payload: (token, revenue_share_bps, payout_asset, denomination_symbol, display_decimals) - Self::emit_v2_event( - &env, - (EVENT_OFFER_REG_V2, issuer, namespace, token.clone()), - (token, revenue_share_bps, payout_asset, denomination_symbol, display_decimals), - ); - - Ok(()) - } - - /// Return the denomination display metadata for an offering. - /// - /// This is a cheap O(1) read that does not require iterating offerings. - /// - /// ### Parameters - /// - `issuer`: The issuer address. - /// - `namespace`: The offering namespace. - /// - `token`: The offering token address. - /// - /// ### Returns - /// `Some((denomination_symbol, display_decimals))` if the offering exists, - /// `None` if no offering with that identity has been registered. - pub fn get_denomination_metadata( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option<(Symbol, u32)> { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey2::DenominationMetadata(offering_id)) - } - - /// Fetch a single offering by issuer and token. - /// - /// This method scans the issuer's registered offerings to find the one matching the given token. - /// - /// ### Parameters - /// - `issuer`: The address that registered the offering. - /// - `token`: The token address associated with the offering. - /// - /// ### Returns - /// - `Some(Offering)` if found. - /// - `None` otherwise. - /// Fetch a single offering by issuer, namespace, and token. - /// - /// This method first attempts an O(1) direct lookup via the `OfferingRecord` index written - /// at registration (#360). Falls back to an O(n) scan for legacy offerings registered before - /// the index was introduced. - /// - /// ### Parameters - /// - `issuer`: The address that registered the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token address associated with the offering. - /// - /// ### Returns - /// - `Some(Offering)` if found. - /// - `None` otherwise. - pub fn get_offering( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - // O(1) direct lookup via index written at registration (#360). - if let Some(offering) = env - .storage() - .persistent() - .get::(&DataKey2::OfferingRecord(offering_id)) - { - return Some(offering); - } - // Fallback: O(n) scan for legacy offerings registered before the index was added. - let count = Self::get_offering_count(env.clone(), issuer.clone(), namespace.clone()); - let tenant_id = TenantId { issuer, namespace }; - for i in 0..count { - let item_key = DataKey::OfferItem(tenant_id.clone(), i); - let offering: Offering = env.storage().persistent().get(&item_key).unwrap(); - if offering.token == token { - return Some(offering); - } - } - None - } - - /// List all offering tokens for an issuer in a namespace. - pub fn list_offerings(env: Env, issuer: Address, namespace: Symbol) -> Vec
{ - let (page, _) = - Self::get_offerings_page(env.clone(), issuer.clone(), namespace, 0, MAX_PAGE_LIMIT); - let mut tokens = Vec::new(&env); - for i in 0..page.len() { - tokens.push_back(page.get(i).unwrap().token); - } - tokens - } - - /// Return the locked payment token for an offering. - /// - /// Returns `None` when: - /// - the offering is unknown, or - /// - the offering exists but has not yet recorded a successful deposit. - /// - /// Once the first successful deposit persists the `PaymentToken` key, this returns - /// `Some(payment_token)` for that locked token. - pub fn get_payment_token( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option
{ - let offering_id = OfferingId { issuer, namespace, token }; - Self::get_locked_payment_token_for_offering(&env, &offering_id) - } - - /// Configure the FX oracle used to convert cross-currency revenue reports - /// into the offering payout asset before storing report and audit state. - /// - /// The issuer owns this configuration. `revenue_symbol` is passed to the - /// oracle as the quote source when `report_revenue` is called with a - /// non-payout asset; `payout_symbol` is the quote target for the registered - /// offering payout asset. - #[allow(clippy::too_many_arguments)] - pub fn set_fx_oracle( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - oracle: Address, - revenue_symbol: Symbol, - payout_symbol: Symbol, - max_oracle_age_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - let config = FxOracleConfig { oracle, revenue_symbol, payout_symbol, max_oracle_age_secs }; - env.storage().persistent().set(&DataKey2::FxOracleConfig(offering_id), &config); - Ok(()) - } - - /// Return the configured FX oracle for an offering, if one exists. - pub fn get_fx_oracle( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey2::FxOracleConfig(offering_id)) - } - - /// Configure an ordered oracle fallback chain for cross-currency FX conversion. - /// - /// When `report_revenue` needs an exchange rate, the contract iterates `entries` - /// in order and uses the **first** oracle that returns a fresh (non-stale) quote. - /// If every oracle in the chain is stale, the call returns - /// `RevoraError::AllOraclesStale`. - /// - /// An empty `entries` vec clears any previously stored chain. When a chain is - /// present it **takes priority** over the legacy `FxOracleConfig` single-oracle - /// setting; `set_fx_oracle` / `get_fx_oracle` continue to work as before for - /// offerings that have not configured a chain. - /// - /// # Security - /// Only the current offering issuer may configure this. Each entry is validated - /// to ensure `max_age_secs` is non-zero (enforced by caller; the contract stores - /// whatever is provided — a zero value disables staleness checks for that entry). - /// - /// # Limits - /// `entries.len()` must not exceed `MAX_ORACLE_CHAIN_LEN` (10). - pub fn set_oracle_chain( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - entries: Vec, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - if entries.len() > MAX_ORACLE_CHAIN_LEN { - return Err(RevoraError::LimitReached); - } - - let chain = OracleChain { entries }; - env.storage().persistent().set(&DataKey2::OracleChain(offering_id), &chain); - Ok(()) - } - - /// Return the oracle fallback chain configured for an offering, if any. - pub fn get_oracle_chain( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get::(&DataKey2::OracleChain(offering_id)) - } - - /// Register the off-chain ED25519 public key for an oracle. - /// Only the contract admin can register oracle keys. - pub fn register_oracle_pubkey( - env: Env, - oracle_id: Address, - pubkey: BytesN<32>, - ) -> Result<(), RevoraError> { - Self::require_admin(&env)?; - env.storage().persistent().set(&DataKey2::OraclePubKey(oracle_id), &pubkey); - Ok(()) - } - - /// Try to obtain a fresh FX rate from a single `OracleEntry`. - /// - /// Returns `Ok(rate_bps)` when the quote is within the staleness window, - /// or `Err(RevoraError::OracleQuoteStale)` when it is too old. - fn try_oracle_entry(env: &Env, entry: &OracleEntry, now: u64) -> Result { - let (rate, quoted_at) = FxOracleClient::new(env, &entry.oracle) - .quote(&entry.revenue_symbol, &entry.payout_symbol); - if entry.max_age_secs > 0 && now.saturating_sub(quoted_at) > entry.max_age_secs { - return Err(RevoraError::OracleQuoteStale); - } - Ok(rate) - } - - fn convert_report_amount_if_needed( - env: &Env, - offering_id: &OfferingId, - offering: &Offering, - reported_asset: &Address, - amount: i128, - now: u64, - quote_bytes: Option, - signature: Option>, - ) -> Result<(i128, Address), RevoraError> { - if offering.payout_asset == *reported_asset { - return Ok((amount, reported_asset.clone())); - } - - // ── Oracle chain path (takes priority over legacy single-oracle config) ── - if let Some(chain) = env - .storage() - .persistent() - .get::(&DataKey2::OracleChain(offering_id.clone())) - { - let mut chain_idx: u32 = 0; - for entry in chain.entries.iter() { - match Self::try_oracle_entry(env, &entry, now) { - Ok(rate) => { - // Emit oracle_source_used event so indexers know which oracle won. - env.events().publish( - ( - EVENT_ORACLE_SOURCE_USED, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - ( - entry.oracle.clone(), - entry.revenue_symbol.clone(), - entry.payout_symbol.clone(), - chain_idx, - ), - ); - let converted = amount.saturating_mul(rate).saturating_div(BPS_DENOMINATOR); - return Ok((converted, offering.payout_asset.clone())); - } - Err(_) => { - // This entry is stale — try the next one. - chain_idx = chain_idx.saturating_add(1); - } - } - } - // Every entry in the chain was stale. - return Err(RevoraError::AllOraclesStale); - } - - // ── Legacy single-oracle path ── - let config: FxOracleConfig = env - .storage() - .persistent() - .get(&DataKey2::FxOracleConfig(offering_id.clone())) - .ok_or(RevoraError::PayoutAssetMismatch)?; - let (rate, quoted_at) = if let (Some(q), Some(sig)) = (quote_bytes, signature) { - crate::security_assertions::oracle_validation::verify_oracle_signature( - env, - &q, - &sig, - &config.oracle, - )?; - let decoded: (i128, u64) = - env.from_xdr(&q).map_err(|_| RevoraError::MetadataInvalidFormat)?; - decoded - } else { - FxOracleClient::new(env, &config.oracle) - .quote(&config.revenue_symbol, &config.payout_symbol) - }; - if config.max_oracle_age_secs > 0 - && now.saturating_sub(quoted_at) > config.max_oracle_age_secs - { - return Err(RevoraError::OracleQuoteStale); - } - let converted_amount = amount.saturating_mul(rate).saturating_div(BPS_DENOMINATOR); - Ok((converted_amount, offering.payout_asset.clone())) - } - - /// Record or correct a revenue report for an offering and emit audit events. - /// - /// Semantics: - /// - New periods persist `(amount, timestamp)`, emit `rev_init`, and update - /// `AuditSummary` by `(amount, +1)`. - /// - Existing periods with `override_existing=true` emit `rev_ovrd` and update - /// `AuditSummary` by `(new_amount - old_amount, +0)`. - /// - Existing periods with `override_existing=false` emit `rev_rej` and leave - /// persisted state unchanged. - /// - New periods below the configured minimum threshold emit `rev_below` and - /// leave both persisted report state and the report cursor unchanged. - /// - /// Validates amount using the Negative Amount Validation Matrix (#163). - #[allow(clippy::too_many_arguments)] - /// Report revenue for a specific period of an offering. - /// - /// # Arguments - /// * `issuer` - The address of the offering issuer. - /// * `namespace` - A symbol identifying the namespace. - /// * `token` - The address of the token. - /// * `payout_asset` - The asset being reported. - /// * `amount` - The amount of revenue. - /// * `period_id` - The identifier for the revenue period. - /// * `override_existing` - If true, replaces an existing report for the same period. - /// - /// # Events - /// Emits `EVENT_REV_REP_V2` and `EVENT_INDEXED_V2`. - pub fn report_revenue( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - payout_asset: Address, - amount: i128, - period_id: u64, - override_existing: bool, - ) -> Result<(), RevoraError> { - Self::report_revenue_internal( - env, - issuer, - namespace, - token, - payout_asset, - amount, - period_id, - override_existing, - None, - None, - ) - } - - /// Report revenue for a specific period of an offering using an off-chain signed FX quote. - pub fn report_revenue_with_attestation( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - payout_asset: Address, - amount: i128, - period_id: u64, - override_existing: bool, - quote_bytes: Bytes, - signature: BytesN<64>, - ) -> Result<(), RevoraError> { - Self::report_revenue_internal( - env, - issuer, - namespace, - token, - payout_asset, - amount, - period_id, - override_existing, - Some(quote_bytes), - Some(signature), - ) - } - - #[allow(clippy::too_many_arguments)] - fn report_revenue_internal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - payout_asset: Address, - amount: i128, - period_id: u64, - override_existing: bool, - quote_bytes: Option, - signature: Option>, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - let mut amount = amount; - let mut payout_asset = payout_asset; - - // Input validation (#35): reject zero/invalid period_id - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - - // Negative Amount Validation Matrix: RevenueReport requires amount >= 0 (#163) - if let Err((err, reason)) = - AmountValidationMatrix::validate(amount, AmountValidationCategory::RevenueReport) - { - env.events().publish( - (EVENT_AMOUNT_VALIDATION_FAILED, issuer.clone(), namespace.clone(), token.clone()), - (amount, err as u32, reason), - ); - return Err(err); - } - - let event_only = Self::is_event_only(&env); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let last_report_period_key = DataKey2::LastReportedPeriodId(offering_id.clone()); - let current_timestamp = env.ledger().timestamp(); - - Self::require_not_offering_frozen(&env, &offering_id)?; - Self::require_report_window_open(&env, &offering_id)?; - - if !event_only { - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let converted = Self::convert_report_amount_if_needed( - &env, - &offering_id, - &offering, - &payout_asset, - amount, - current_timestamp, - quote_bytes, - signature, - )?; - amount = converted.0; - payout_asset = converted.1; - - // Testnet mode bypass: if enabled, skip concentration limit enforcement - // to allow flexible testing of revenue flows without holder constraints. - let testnet_mode = Self::is_testnet_mode(env.clone()); - if !testnet_mode { - let limit_key = DataKey::ConcentrationLimit(offering_id.clone()); - if let Some(config) = - env.storage().persistent().get::(&limit_key) - { - // Concentration Enforcement: if enforce=true and max_bps > 0, - // reject report if current concentration exceeds the limit. - // Allowed: current <= max_bps. Rejected: current > max_bps. - if config.enforce && config.max_bps > 0 { - // Staleness guard: if max_staleness_secs > 0, require a fresh report. - if config.max_staleness_secs > 0 { - let reported_at: Option = env - .storage() - .persistent() - .get(&DataKey::ConcentrationReportedAt(offering_id.clone())); - match reported_at { - None => return Err(RevoraError::StaleConcentrationData), - Some(ts) => { - if current_timestamp.saturating_sub(ts) - > config.max_staleness_secs - { - return Err(RevoraError::StaleConcentrationData); - } - } - } - } - let curr_key = DataKey::CurrentConcentration(offering_id.clone()); - let current: u32 = env.storage().persistent().get(&curr_key).unwrap_or(0); - if current > config.max_bps { - return Err(RevoraError::ConcentrationLimitExceeded); - } - } - } - } - } - - let threshold = Self::get_min_revenue_threshold_for_offering(&env, &offering_id); - - // Use bounded read for event snapshots to avoid unbounded payloads - // Cap at MAX_PAGE_LIMIT (20) to prevent gas spikes from large blacklists - let blacklist = if event_only { - Vec::new(&env) - } else { - Self::get_blacklist_page( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - 0, - MAX_PAGE_LIMIT, - ) - .0 - }; - - let mut actual_override = false; - let mut actual_initial = false; - - if event_only { - if threshold > 0 && amount < threshold { - env.events().publish( - (EVENT_REV_BELOW_THRESHOLD, issuer, namespace, token), - (amount, period_id, threshold), - ); - return Ok(()); - } - - actual_initial = true; - env.events().publish( - (EVENT_REVENUE_REPORT_INITIAL, issuer.clone(), namespace.clone(), token.clone()), - (amount, period_id, blacklist.clone()), - ); - env.events().publish( - ( - EVENT_REVENUE_REPORT_INITIAL_ASSET, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (payout_asset.clone(), amount, period_id, blacklist.clone()), - ); - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_REV_INIT, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_REV_INIT, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - _reserved: 0, - }, - (amount, payout_asset.clone()), - ); - } else { - let reports_key = DataKey::RevenueReports(offering_id.clone()); - let mut reports: Map = - env.storage().persistent().get(&reports_key).unwrap_or_else(|| Map::new(&env)); - let idx_key = DataKey::RevenueIndex(offering_id.clone(), period_id); - - match reports.get(period_id) { - Some((existing_amount, _)) => { - if !override_existing { - env.events().publish( - ( - EVENT_REVENUE_REPORT_REJECTED, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (amount, period_id, existing_amount, blacklist.clone()), - ); - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_REV_REJ, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_REV_REJ, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - _reserved: 0, - }, - (amount, existing_amount, payout_asset.clone()), - ); - env.events().publish( - (EVENT_REVENUE_REPORT_REJECTED_ASSET, issuer, namespace, token), - (payout_asset, amount, period_id, existing_amount, blacklist), - ); - return Ok(()); - } - - // Reject override if the period has been sealed by close_period. - let closed_key = DataKey2::ClosedPeriod(offering_id.clone(), period_id); - if env.storage().persistent().has(&closed_key) { - return Err(RevoraError::PeriodAlreadyClosed); - } - - actual_override = true; - reports.set(period_id, (amount, current_timestamp)); - env.storage().persistent().set(&reports_key, &reports); - env.storage().persistent().set(&idx_key, &amount); - - let summary_key = DataKey::AuditSummary(offering_id.clone()); - let mut summary: AuditSummary = env - .storage() - .persistent() - .get(&summary_key) - .unwrap_or(AuditSummary { total_revenue: 0, report_count: 0 }); - let delta = amount.s_sub(existing_amount).unwrap_or(0); - summary.total_revenue = summary.total_revenue.s_add(delta).unwrap_or(i128::MAX); - env.storage().persistent().set(&summary_key, &summary); - - env.events().publish( - ( - EVENT_REVENUE_REPORT_OVERRIDE, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (amount, period_id, existing_amount, blacklist.clone()), - ); - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_REV_OVR, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_REV_OVR, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - _reserved: 0, - }, - (amount, existing_amount, payout_asset.clone()), - ); - env.events().publish( - ( - EVENT_REVENUE_REPORT_OVERRIDE_ASSET, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - ( - payout_asset.clone(), - amount, - period_id, - existing_amount, - blacklist.clone(), - ), - ); - } - None => { - if override_existing { - env.events().publish( - ( - EVENT_REVENUE_REPORT_MISSING_OVERRIDE, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (amount, period_id), - ); - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_REV_OMISS, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_REV_OMISS, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - _reserved: 0, - }, - (amount, period_id, payout_asset.clone()), - ); - return Err(RevoraError::MissingReportForOverride); - } - // preserve existing initial-report behavior when override_existing=false - Self::require_next_period_id(&env, last_report_period_key.clone(), period_id)?; - if threshold > 0 && amount < threshold { - env.events().publish( - ( - EVENT_REV_BELOW_THRESHOLD, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (amount, period_id, threshold), - ); - return Ok(()); - } - - actual_initial = true; - reports.set(period_id, (amount, current_timestamp)); - env.storage().persistent().set(&reports_key, &reports); - env.storage().persistent().set(&idx_key, &amount); - Self::commit_period_id(&env, last_report_period_key.clone(), period_id); - - let summary_key = DataKey::AuditSummary(offering_id.clone()); - let mut summary: AuditSummary = env - .storage() - .persistent() - .get(&summary_key) - .unwrap_or(AuditSummary { total_revenue: 0, report_count: 0 }); - summary.total_revenue = - summary.total_revenue.s_add(amount).unwrap_or(i128::MAX); - summary.report_count = summary.report_count.s_add(1).unwrap_or(u64::MAX); - env.storage().persistent().set(&summary_key, &summary); - - env.events().publish( - ( - EVENT_REVENUE_REPORT_INITIAL, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (amount, period_id, blacklist.clone()), - ); - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_REV_INIT, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_REV_INIT, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - _reserved: 0, - }, - (amount, payout_asset.clone()), - ); - // Versioned v2 event: [2, amount, period_id, blacklist] — always emitted (#RC26Q2-C31) - Self::emit_v2_event( - &env, - (EVENT_REV_INIT_V2, issuer.clone(), namespace.clone(), token.clone()), - (amount, period_id, blacklist.clone()), - ); - - env.events().publish( - ( - EVENT_REVENUE_REPORT_INITIAL_ASSET, - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (payout_asset.clone(), amount, period_id, blacklist.clone()), - ); - } - } - } - - env.events().publish( - (EVENT_REVENUE_REPORTED, issuer.clone(), namespace.clone(), token.clone()), - (amount, period_id, blacklist.clone()), - ); - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_REV_REP, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_REV_REP, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id, - _reserved: 0, - }, - (amount, payout_asset.clone(), actual_override), - ); - env.events().publish( - (EVENT_REVENUE_REPORTED_ASSET, issuer.clone(), namespace.clone(), token.clone()), - (payout_asset.clone(), amount, period_id), - ); - // Versioned v2 events: always emitted regardless of feature flags (#RC26Q2-C31) - // rv_rep2: [2, amount, period_id, blacklist] - Self::emit_v2_event( - &env, - (EVENT_REV_REP_V2, issuer.clone(), namespace.clone(), token.clone()), - (amount, period_id, blacklist.clone()), - ); - // rv_repa2: [2, payout_asset, amount, period_id] - Self::emit_v2_event( - &env, - (EVENT_REV_REPA_V2, issuer.clone(), namespace.clone(), token.clone()), - (payout_asset.clone(), amount, period_id), - ); - // rv_inia2: [2, payout_asset, amount, period_id, blacklist] - Self::emit_v2_event( - &env, - (EVENT_REV_INIA_V2, issuer.clone(), namespace.clone(), token.clone()), - (payout_asset.clone(), amount, period_id, blacklist.clone()), - ); - - // Platform fee model (#468): once a report is recorded, route the configured - // platform cut to the treasury and surface it via `plat_fee`. Reaching this point - // means a report was actually recorded (initial or override); the below-threshold - // and rejected paths return early above, so no fee is taken on those. - Self::apply_platform_fee( - &env, - &offering_id, - &issuer, - &namespace, - &token, - amount, - period_id, - ); - - if Self::is_event_versioning_enabled(env.clone()) { - env.events().publish( - (EVENT_REV_INIA_V1, issuer.clone(), namespace.clone(), token.clone()), - (EVENT_SCHEMA_VERSION, payout_asset.clone(), amount, period_id, blacklist.clone()), - ); - env.events().publish( - (EVENT_REV_REP_V2, issuer.clone(), namespace.clone(), token.clone()), - (EVENT_SCHEMA_VERSION, amount, period_id, blacklist.clone()), - ); - env.events().publish( - (EVENT_REV_REPA_V1, issuer, namespace, token), - (EVENT_SCHEMA_VERSION, payout_asset, amount, period_id), - ); - } - - // Advance the cumulative accrual index. Skipped in event-only mode (no persistent state) - // and when amount == 0. Rejected duplicates (rv_rej) never reach this point (early return). - if !event_only { - Self::update_and_emit_accrual_index(&env, &offering_id, amount, period_id); - } - - Ok(()) - } - - /// Repair the `AuditSummary` cache for an offering by recomputing it from the - /// authoritative `RevenueReports` map and writing the corrected value. - /// - /// ### Auth - /// Only the current issuer or the contract admin may call this. This prevents - /// arbitrary callers from triggering unnecessary storage writes. - /// - /// ### Security notes - /// - This function is idempotent: calling it when the summary is already correct - /// is safe and produces no observable side-effects beyond the storage write. - /// - If `RevenueReports` is empty (no reports ever filed), the summary is reset - /// to `{total_revenue: 0, report_count: 0}`. - /// - Overflow during recomputation is handled with saturation; the resulting - /// summary will have `total_revenue == i128::MAX` in that case. - /// - /// ### Returns - /// The corrected `AuditSummary` that was written to storage. - pub fn repair_audit_summary( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Result { - Self::require_not_frozen(&env)?; - caller.require_auth(); - - // Auth: caller must be current issuer or admin. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; - if caller != current_issuer && caller != admin { - return Err(RevoraError::NotAuthorized); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let (corrected, _) = Self::compute_audit_summary_from_reports(&env, &offering_id); - - let summary_key = DataKey::AuditSummary(offering_id); - env.storage().persistent().set(&summary_key, &corrected); - - Self::emit_v2_event( - &env, - (EVENT_AUDIT_REPAIRED, issuer, namespace, token), - (corrected.total_revenue, corrected.report_count), - ); - - Ok(corrected) - } - - /// Read-only comparison between the stored `AuditSummary` cache and the - /// authoritative `RevenueReports` map for an offering. - pub fn reconcile_audit_summary( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> AuditReconciliationResult { - let offering_id = OfferingId { issuer, namespace, token }; - let stored = env - .storage() - .persistent() - .get::(&DataKey::AuditSummary(offering_id.clone())) - .unwrap_or(AuditSummary { total_revenue: 0, report_count: 0 }); - let (computed, is_saturated) = Self::compute_audit_summary_from_reports(&env, &offering_id); - let is_consistent = !is_saturated - && stored.total_revenue == computed.total_revenue - && stored.report_count == computed.report_count; - - AuditReconciliationResult { - stored_total_revenue: stored.total_revenue, - stored_report_count: stored.report_count, - computed_total_revenue: computed.total_revenue, - computed_report_count: computed.report_count, - is_consistent, - is_saturated, - } - } - - pub fn get_revenue_by_period( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - period_id: u64, - ) -> i128 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::RevenueIndex(offering_id, period_id); - env.storage().persistent().get(&key).unwrap_or(0) - } - - /// Sum reported revenue for all period IDs in `[from_period, to_period]` (inclusive). - /// - /// **Warning:** unbounded range — for large ranges prefer [`get_revenue_range_chunk`]. - /// - /// ### Auth - /// None — read-only. - pub fn get_revenue_range( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - from_period: u64, - to_period: u64, - ) -> i128 { - let mut total: i128 = 0; - for period in from_period..=to_period { - let amount = Self::get_revenue_by_period( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - period, - ); - total = total.s_add(amount).unwrap_or(i128::MAX); - } - total - } - - /// Read-only: sum revenue for a numeric period range but bounded by `max_periods` per call. - /// - /// Returns `(sum, next_start)` where `next_start` is `Some(period)` if there are remaining - /// periods to process and a subsequent call can continue from that period. - /// - /// ### Features & Security - /// - **Determinism**: The query is read-only and uses capped iterations to prevent CPU/Gas exhaustion. - /// - **Input Validation**: Automatically handles `from_period > to_period` by returning an empty result. - /// - **Capping**: `max_periods` of 0 or > `MAX_CHUNK_PERIODS` will be capped to `MAX_CHUNK_PERIODS`. - pub fn get_revenue_range_chunk( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - from_period: u64, - to_period: u64, - max_periods: u32, - ) -> (i128, Option) { - if from_period > to_period { - return (0, None); - } - - let mut total: i128 = 0; - let mut processed: u32 = 0; - let cap = if max_periods == 0 || max_periods > MAX_CHUNK_PERIODS { - MAX_CHUNK_PERIODS - } else { - max_periods - }; - - let mut p = from_period; - while p <= to_period { - if processed >= cap { - return (total, Some(p)); - } - let amount = Self::get_revenue_by_period( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - p, - ); - total = total.s_add(amount).unwrap_or(i128::MAX); - processed = processed.s_add(1).unwrap_or(u32::MAX); - p = p.s_add(1).unwrap_or(u64::MAX); - } - (total, None) - } - /// Return the total number of offerings registered by `issuer` in `namespace`. - pub fn get_offering_count(env: Env, issuer: Address, namespace: Symbol) -> u32 { - let tenant_id = TenantId { issuer, namespace }; - let count_key = DataKey::OfferCount(tenant_id); - env.storage().persistent().get(&count_key).unwrap_or(0) - } - - /// Return a page of offerings for `issuer`. Limit capped at MAX_PAGE_LIMIT (20). - /// Ordering: by registration index (creation order), deterministic (#38). - /// Return a page of offerings for `issuer` in `namespace`. Limit capped at MAX_PAGE_LIMIT (20). - /// Ordering: by registration index (creation order), deterministic (#38). - pub fn get_offerings_page( - env: Env, - issuer: Address, - namespace: Symbol, - start: u32, - limit: u32, - ) -> (Vec, Option) { - let count = Self::get_offering_count(env.clone(), issuer.clone(), namespace.clone()); - let tenant_id = TenantId { issuer, namespace }; - - let effective_limit = - if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; - - if start >= count { - return (Vec::new(&env), None); - } - - let end = core::cmp::min(start + effective_limit, count); - let mut results = Vec::new(&env); - - for i in start..end { - let item_key = DataKey::OfferItem(tenant_id.clone(), i); - let offering: Offering = env.storage().persistent().get(&item_key).unwrap(); - results.push_back(offering); - } - - let next_cursor = if end < count { Some(end) } else { None }; - (results, next_cursor) - } - - /// Helper function to add an investor to the blacklist with attestation. - /// - /// Accepts an optional `BlacklistEntryMeta` that, when provided, is persisted - /// in a separate `BlacklistMeta` map keyed by `(offering_id, investor)`. - /// This enables compliance verification by linking blacklist entries to - /// signed off-chain OFAC snapshot hashes. - fn do_blacklist_add( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - attestation: SanctionsAttestation, - entry_meta: Option, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - caller.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - // Verify auth: caller must be issuer or admin - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; - - if caller != current_issuer && caller != admin { - return Err(RevoraError::NotAuthorized); - } - - // Validate attestation timestamp: attested_at must not be in the future - if attestation.attested_at > env.ledger().timestamp() { - return Err(RevoraError::InvalidAmount); // Wait, let's check error codes - // Wait, let's use a proper error? Wait let's check RevoraError - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if !Self::is_event_only(&env) { - let key = DataKey::Blacklist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - - let was_present = map.contains_key(investor.clone()); - if !was_present { - // Guard: reject if the blacklist is already at capacity. - let limit = Self::get_effective_blacklist_limit(&env, &offering_id); - if map.len() >= limit { - return Err(RevoraError::BlacklistSizeLimitExceeded); - } - map.set(investor.clone(), attestation.clone()); - env.storage().persistent().set(&key, &map); - - // If BlacklistEntryMeta was provided, persist it in the BlacklistMeta map - if let Some(ref meta) = entry_meta { - let meta_key = DataKey::BlacklistMeta(offering_id.clone()); - let mut meta_map: Map = - env.storage().persistent().get(&meta_key).unwrap_or_else(|| Map::new(&env)); - meta_map.set(investor.clone(), meta.clone()); - env.storage().persistent().set(&meta_key, &meta_map); - } - - // Maintain insertion order for deterministic get_blacklist (#38) - let order_key = DataKey::BlacklistOrder(offering_id.clone()); - let mut order: Vec
= - env.storage().persistent().get(&order_key).unwrap_or_else(|| Vec::new(&env)); - order.push_back(investor.clone()); - env.storage().persistent().set(&order_key, &order); - } - } - - env.events() - .publish((EVENT_BL_ADD, issuer, namespace, token), (caller, investor, attestation)); - Ok(()) - } - - /// Add an investor to the per-offering blacklist with a sanctions attestation. - /// - /// Blacklisted addresses are prohibited from claiming revenue for the specified token. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer of the offering. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `investor`: The address to be blacklisted. - /// - `attestation`: The sanctions attestation containing source, reference ID, and timestamp. - /// - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering or the contract admin. - /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering to prevent - /// unbounded storage growth and keep distribution gas predictable. - /// - Idempotent adds (address already present) do not count against the size limit. - /// - `attestation.attested_at` must not be in the future. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. - /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. - /// - `Err(RevoraError::InvalidAmount)` if attestation timestamp is in the future. - #[allow(clippy::too_many_arguments)] - pub fn blacklist_add_with_attestation( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - attestation: SanctionsAttestation, - ) -> Result<(), RevoraError> { - Self::do_blacklist_add(env, caller, issuer, namespace, token, investor, attestation, None) - } - - /// Add an investor to the per-offering blacklist, pinned to a signed OFAC snapshot hash. - /// - /// Compliance requires proving which OFAC list version informed each blacklist entry. - /// This variant binds every `blacklist_add` call to a signed off-chain snapshot hash - /// referencing the source list. The snapshot hash and addition timestamp are persisted - /// alongside the attestation for verifiable compliance audits. - /// - /// ### Parameters - /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer or admin. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `investor`: The address to be blacklisted. - /// - `attestation`: The sanctions attestation containing source, reference ID, and timestamp. - /// - `ofac_snapshot_hash`: SHA-256 hash of the signed off-chain OFAC list snapshot. - /// - /// ### Events - /// Publishes `(EVENT_BL_ADD_PINNED, issuer, namespace, token)` with - /// `(caller, investor, attestation, snapshot_hash)` as the event data. - /// - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering or the contract admin. - /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering. - /// - Idempotent adds (address already present) do not count against the size limit. - /// - `attestation.attested_at` must not be in the future. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. - /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. - /// - `Err(RevoraError::InvalidAmount)` if attestation timestamp is in the future. - #[allow(clippy::too_many_arguments)] - pub fn blacklist_add_pinned( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - attestation: SanctionsAttestation, - ofac_snapshot_hash: BytesN<32>, - ) -> Result<(), RevoraError> { - // Build the entry meta with the snapshot hash and current timestamp - let entry_meta = BlacklistEntryMeta { - snapshot_hash: ofac_snapshot_hash, - added_ts: env.ledger().timestamp(), - }; - Self::do_blacklist_add( - env, - caller, - issuer, - namespace, - token, - investor, - attestation, - Some(entry_meta), - ) - } - - /// Add an investor to the per-offering blacklist (legacy, uses Source::Manual). - /// - /// Blacklisted addresses are prohibited from claiming revenue for the specified token. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer of the offering. - /// - `token`: The token representing the offering. - /// - `investor`: The address to be blacklisted. - /// - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering or the contract admin. - /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering to prevent - /// unbounded storage growth and keep distribution gas predictable. - /// - Idempotent adds (address already present) do not count against the size limit. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. - /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. - pub fn blacklist_add( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> Result<(), RevoraError> { - let attestation = SanctionsAttestation { - source: Source::Manual, - ref_id: symbol_short!("manual"), - attested_at: env.ledger().timestamp(), - }; - Self::do_blacklist_add(env, caller, issuer, namespace, token, investor, attestation, None) - } - - /// Add multiple investors to the per-offering blacklist in a single transaction (uses Source::Manual). - /// - /// Enables efficient bulk compliance updates by processing up to MAX_BATCH_SIZE (50) - /// addresses atomically. The operation is idempotent: addresses already blacklisted - /// are skipped without error. Events are emitted only for addresses that result in - /// actual state changes. - /// - /// ### Parameters - /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer or admin. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `investors`: Vector of addresses to blacklist (max 50). - /// - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering or the contract admin. - /// - All-or-nothing semantics: if any validation fails, no addresses are added. - /// - Batch size is capped at MAX_BATCH_SIZE to keep gas costs predictable. - /// - Blacklist size is capped per-offering (configurable via set_blacklist_size_limit, default MAX_BLACKLIST_SIZE). - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::ContractPaused)` if the contract is paused. - /// - `Err(RevoraError::OfferingNotFound)` if the offering does not exist. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. - /// - `Err(RevoraError::LimitReached)` if batch size exceeds MAX_BATCH_SIZE. - /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if adding the batch would exceed the per-offering limit. - #[allow(clippy::too_many_arguments)] - pub fn blacklist_add_many( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investors: Vec
, - ) -> Result<(), RevoraError> { - // Task 2.1: Authorization checks - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - caller.require_auth(); - - // Task 2.2: Batch size validation - if investors.len() > MAX_BATCH_SIZE { - return Err(RevoraError::LimitReached); - } - - // Handle empty batch case (idempotent no-op) - if investors.is_empty() { - return Ok(()); - } - - // Task 2.3: Offering existence check and authorization - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; - - if caller != current_issuer && caller != admin { - return Err(RevoraError::NotAuthorized); - } - - // Task 2.3: Load storage - let key = DataKey::Blacklist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - let order_key = DataKey::BlacklistOrder(offering_id.clone()); - let mut order: Vec
= - env.storage().persistent().get(&order_key).unwrap_or_else(|| Vec::new(&env)); - - // Task 2.4: Deduplication logic - let mut seen = Map::new(&env); - let mut unique_investors = Vec::new(&env); - for i in 0..investors.len() { - let investor = investors.get(i).unwrap(); - if !seen.contains_key(investor.clone()) { - seen.set(investor.clone(), true); - unique_investors.push_back(investor); - } - } - - // Task 2.5: Capacity validation - let limit = Self::get_effective_blacklist_limit(&env, &offering_id); - let current_size = map.len(); - let mut new_count = 0u32; - for i in 0..unique_investors.len() { - let investor = unique_investors.get(i).unwrap(); - if !map.contains_key(investor.clone()) { - new_count += 1; - } - } - - if current_size + new_count > limit { - return Err(RevoraError::BlacklistSizeLimitExceeded); - } - - // Task 2.6: Batch add logic with storage updates - let now = env.ledger().timestamp(); - for i in 0..unique_investors.len() { - let investor = unique_investors.get(i).unwrap(); - let was_present = map.contains_key(investor.clone()); - - if !was_present { - let attestation = SanctionsAttestation { - source: Source::Manual, - ref_id: symbol_short!("manual"), - attested_at: now, - }; - // Add to map and order vec - if !Self::is_event_only(&env) { - map.set(investor.clone(), attestation.clone()); - order.push_back(investor.clone()); - } - - // Emit event for actual state change - env.events().publish( - (EVENT_BL_ADD, issuer.clone(), namespace.clone(), token.clone()), - (caller.clone(), investor, attestation), - ); - } - // If already blacklisted, skip without error or event (idempotent) - } - - // Save updated storage - if !Self::is_event_only(&env) { - env.storage().persistent().set(&key, &map); - env.storage().persistent().set(&order_key, &order); - } - - Ok(()) - } - - /// Remove an investor from the per-offering blacklist. - /// - /// Re-enables the address to claim revenue for the specified token. - /// This operation is idempotent. - /// - /// ### Parameters - /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer of the offering. - /// - `token`: The token representing the offering. - /// - `investor`: The address to be removed from the blacklist. - /// - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering or the contract admin. - /// - `namespace` isolation ensures that removing from one blacklist does not affect others. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. - pub fn blacklist_remove( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - caller.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - Self::require_not_frozen(&env)?; - - // Verify auth: caller must be issuer or admin. - // Security assumption: only the current issuer or contract admin may remove - // addresses from the blacklist. This mirrors the add-side guard and prevents - // unauthorized actors from re-enabling blacklisted investors. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; - if caller != current_issuer && caller != admin { - return Err(RevoraError::NotAuthorized); - } - - let key = DataKey::Blacklist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - map.remove(investor.clone()); - env.storage().persistent().set(&key, &map); - - // Also clean up the BlacklistMeta map if this entry had a pinned snapshot hash - let meta_key = DataKey::BlacklistMeta(offering_id.clone()); - let mut meta_map: Map = - env.storage().persistent().get(&meta_key).unwrap_or_else(|| Map::new(&env)); - meta_map.remove(investor.clone()); - env.storage().persistent().set(&meta_key, &meta_map); - - // Rebuild order vec so get_blacklist stays deterministic (#38) - let order_key = DataKey::BlacklistOrder(offering_id.clone()); - let old_order: Vec
= - env.storage().persistent().get(&order_key).unwrap_or_else(|| Vec::new(&env)); - let mut new_order = Vec::new(&env); - for i in 0..old_order.len() { - let addr = old_order.get(i).unwrap(); - if map.contains_key(addr.clone()) { - new_order.push_back(addr); - } - } - env.storage().persistent().set(&order_key, &new_order); - - env.events().publish((EVENT_BL_REM, issuer, namespace, token), (caller, investor)); - Ok(()) - } - - /// Remove multiple investors from the per-offering blacklist in a single transaction. - /// - /// Enables efficient bulk compliance updates by processing up to MAX_BATCH_SIZE (50) - /// addresses atomically. The operation is idempotent: addresses not currently blacklisted - /// are skipped without error. Events are emitted only for addresses that result in - /// actual state changes. - /// - /// ### Parameters - /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer or admin. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `investors`: Vector of addresses to remove from blacklist (max 50). - /// - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering or the contract admin. - /// - All-or-nothing semantics: if any validation fails, no addresses are removed. - /// - Batch size is capped at MAX_BATCH_SIZE to keep gas costs predictable. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::ContractPaused)` if the contract is paused. - /// - `Err(RevoraError::OfferingNotFound)` if the offering does not exist. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. - /// - `Err(RevoraError::LimitReached)` if batch size exceeds MAX_BATCH_SIZE. - #[allow(clippy::too_many_arguments)] - pub fn blacklist_remove_many( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investors: Vec
, - ) -> Result<(), RevoraError> { - // Task 3.1: Authorization checks - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - caller.require_auth(); - - // Task 3.2: Batch size validation - if investors.len() > MAX_BATCH_SIZE { - return Err(RevoraError::LimitReached); - } - - // Handle empty batch case (idempotent no-op) - if investors.is_empty() { - return Ok(()); - } - - // Task 3.3: Offering existence check and authorization - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; - - if caller != current_issuer && caller != admin { - return Err(RevoraError::NotAuthorized); - } - - // Task 3.3: Load storage - let key = DataKey::Blacklist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - - // Also load the BlacklistMeta map for cleanup - let meta_key = DataKey::BlacklistMeta(offering_id.clone()); - let mut meta_map: Map = - env.storage().persistent().get(&meta_key).unwrap_or_else(|| Map::new(&env)); - - // Task 3.4: Deduplication logic - let mut seen = Map::new(&env); - let mut unique_investors = Vec::new(&env); - for i in 0..investors.len() { - let investor = investors.get(i).unwrap(); - if !seen.contains_key(investor.clone()) { - seen.set(investor.clone(), true); - unique_investors.push_back(investor); - } - } - - // Task 3.5: Batch remove logic - for i in 0..unique_investors.len() { - let investor = unique_investors.get(i).unwrap(); - let was_present = map.contains_key(investor.clone()); - - if was_present { - // Remove from map - map.remove(investor.clone()); - - // Also clean up the BlacklistMeta entry if present - meta_map.remove(investor.clone()); - - // Emit event for actual state change - env.events().publish( - (EVENT_BL_REM, issuer.clone(), namespace.clone(), token.clone()), - (caller.clone(), investor), - ); - } - // If not blacklisted, skip without error or event (idempotent) - } - - // Task 3.5: Rebuild order vec to maintain consistency - let order_key = DataKey::BlacklistOrder(offering_id.clone()); - let old_order: Vec
= - env.storage().persistent().get(&order_key).unwrap_or_else(|| Vec::new(&env)); - let mut new_order = Vec::new(&env); - for i in 0..old_order.len() { - let addr = old_order.get(i).unwrap(); - if map.contains_key(addr.clone()) { - new_order.push_back(addr); - } - } - - // Save updated storage - env.storage().persistent().set(&key, &map); - env.storage().persistent().set(&order_key, &new_order); - env.storage().persistent().set(&meta_key, &meta_map); - - Ok(()) - } - - /// Returns `true` if `investor` is blacklisted for an offering. - pub fn is_blacklisted( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> bool { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Blacklist(offering_id); - env.storage() - .persistent() - .get::>(&key) - .map(|m| m.contains_key(investor)) - .unwrap_or(false) - } - - /// Returns the sanctions attestation for a blacklisted investor, if any. - pub fn get_blacklist_attestation( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Blacklist(offering_id); - env.storage() - .persistent() - .get::>(&key) - .and_then(|m| m.get(investor)) - } - - /// Return all blacklisted addresses for an offering. - /// Ordering: by insertion order, deterministic and stable across calls (#38). - /// - /// ## Legacy/Bounded Warning - /// - /// This method returns the entire blacklist in a single call, which can exceed gas limits - /// for large lists. It is retained for backward compatibility but should be avoided in - /// production code. Use `get_blacklist_page` instead for pagination with deterministic cursors. - /// - /// The blacklist size is bounded by MAX_BLACKLIST_SIZE (200) per offering, so this method - /// will never return more than 200 addresses. However, for off-chain tooling and event - /// processing, the paginated form is preferred to avoid gas spikes. - pub fn get_blacklist( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Vec
{ - let offering_id = OfferingId { issuer, namespace, token }; - let order_key = DataKey::BlacklistOrder(offering_id); - env.storage() - .persistent() - .get::>(&order_key) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Return a page of blacklisted addresses for an offering. - /// - /// ## Pagination Behavior - /// - /// - `start`: Zero-based cursor position in the insertion-ordered blacklist - /// - `limit`: Maximum number of addresses to return (capped at MAX_PAGE_LIMIT = 20) - /// - Returns: (page of addresses, next_cursor) - /// - `next_cursor = Some(n)` indicates more data is available at position `n` - /// - `next_cursor = None` indicates end of list - /// - /// The cursor is deterministic and stable: it corresponds to the index in the - /// insertion-ordered blacklist. Pagination preserves insertion order (#38). - /// - /// ## Usage Pattern - /// - /// ```ignore - /// let mut cursor = 0; - /// loop { - /// let (page, next) = get_blacklist_page(env, issuer, ns, token, cursor, 20); - /// // process page... - /// match next { - /// Some(n) => cursor = n, - /// None => break, - /// } - /// } - /// ``` - pub fn get_blacklist_page( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - start: u32, - limit: u32, - ) -> (Vec
, Option) { - let offering_id = OfferingId { issuer, namespace, token }; - let order_key = DataKey::BlacklistOrder(offering_id); - let all: Vec
= env - .storage() - .persistent() - .get::>(&order_key) - .unwrap_or_else(|| Vec::new(&env)); - - let count = all.len(); - let effective_limit = - if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; - - if start >= count { - return (Vec::new(&env), None); - } - - let end = core::cmp::min(start + effective_limit, count); - let mut results = Vec::new(&env); - for i in start..end { - results.push_back(all.get(i).unwrap()); - } - - let next_cursor = if end < count { Some(end) } else { None }; - (results, next_cursor) - } - - /// Return the current number of blacklisted addresses for an offering. - /// - /// This is a cheap O(1) read of the underlying map length and can be used - /// by off-chain tooling to monitor proximity to the per-offering blacklist limit - /// (default MAX_BLACKLIST_SIZE = 200, configurable via set_blacklist_size_limit) - /// before attempting an add. - /// - /// Returns 0 when no blacklist exists yet for the offering. - pub fn get_blacklist_size(env: Env, issuer: Address, namespace: Symbol, token: Address) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Blacklist(offering_id); - env.storage() - .persistent() - .get::>(&key) - .map(|m| m.len()) - .unwrap_or(0) - } - - /// Returns the `BlacklistEntryMeta` for a blacklisted investor, if one exists. - /// - /// Only entries added via `blacklist_add_pinned` will have metadata. - /// Returns `None` if the investor is not blacklisted or was added without a snapshot hash. - /// - /// ### Parameters - /// - `env`: The Soroban environment. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `investor`: The blacklisted address to query. - /// - /// ### Returns - /// - `Some(BlacklistEntryMeta)` containing the snapshot hash and addition timestamp. - /// - `None` if no pinned metadata exists for the investor. - pub fn get_blacklist_entry_meta( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let meta_key = DataKey::BlacklistMeta(offering_id); - env.storage() - .persistent() - .get::>(&meta_key) - .and_then(|m| m.get(investor)) - } - - // ── Whitelist management ────────────────────────────────── - /// Get the effective blacklist size limit for a per-offering. - /// - /// Returns the per-offering limit if set, otherwise defaults to MAX_BLACKLIST_SIZE. - /// This is a private helper used by blacklist_add and blacklist_add_many. - /// - /// ### Parameters - /// - `env`: The Soroban environment. - /// - `offering_id`: The offering identifier. - /// - /// ### Returns - /// The maximum allowed blacklist size for the offering. - fn get_effective_blacklist_limit(env: &Env, offering_id: &OfferingId) -> u32 { - let key = DataKey2::BlacklistSizeLimit(offering_id.clone()); - env.storage().persistent().get::(&key).unwrap_or(MAX_BLACKLIST_SIZE) - } - - /// Set the per-offering blacklist size limit. - /// - /// Allows the issuer to configure a maximum number of addresses that can be - /// blacklisted for a specific offering. This limit affects both `blacklist_add` - /// and `blacklist_add_many` operations. If not set, the default is MAX_BLACKLIST_SIZE (200). - /// - /// ### Parameters - /// - `env`: The Soroban environment. - /// - `caller`: The address making the request. Must be the current issuer. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `max_size`: The new maximum blacklist size (must be > 0). - /// - /// Idempotent — calling with an already-whitelisted address is safe. - /// When a whitelist exists (non-empty), only whitelisted addresses - /// are eligible for revenue distribution (subject to blacklist override). - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering. - /// - Caller must be authorized (require_auth). - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// - `Err(RevoraError::OfferingNotFound)` if the offering does not exist. - /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. - /// - `Err(RevoraError::LimitReached)` if max_size is 0. - pub fn set_blacklist_size_limit( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - max_size: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - caller.require_auth(); - - // Verify the offering exists and caller is the issuer - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - - if caller != current_issuer { - return Err(RevoraError::NotAuthorized); - } - - // Validate: max_size must be at least 1 - if max_size == 0 { - return Err(RevoraError::LimitReached); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey2::BlacklistSizeLimit(offering_id); - env.storage().persistent().set(&key, &max_size); - - Ok(()) - } - - // ── Whitelist management ────────────────────────────────── - - /// Set per-offering concentration limit. Caller must be the offering issuer. - /// `max_bps`: max allowed single-holder share in basis points (0 = disable). - /// Add `investor` to the per-offering whitelist for `token`. - /// - /// Idempotent — calling with an already-whitelisted address is safe. - /// When a whitelist exists (non-empty), only whitelisted addresses - /// are eligible for revenue distribution (subject to blacklist override). - /// ### Security Assumptions - /// - `caller` must be the current issuer of the offering. - /// - `namespace` partitioning prevents whitelists from leaking across tenants. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::OfferingNotFound)` if the offering is not registered. - /// - `Err(RevoraError::NotAuthorized)` if the caller is not authorized. - pub fn whitelist_add( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - caller.require_auth(); - - // Verify offering exists and get current issuer for auth check - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } - - let offering_id = OfferingId { issuer, namespace, token }; - Self::require_not_frozen(&env)?; - - if !Self::is_event_only(&env) { - let key = DataKey::Whitelist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - map.set(investor.clone(), true); - env.storage().persistent().set(&key, &map); - } - - env.events().publish( - ( - EVENT_WL_ADD, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (caller, investor), - ); - Ok(()) - } - - /// Remove `investor` from the per-offering whitelist for `token`. - /// - /// Idempotent — calling when the address is not listed is safe. - /// Remove `investor` from the per-offering whitelist. - pub fn whitelist_remove( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - caller.require_auth(); - - // Verify offering exists and get current issuer for auth check - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } - - let offering_id = OfferingId { issuer, namespace, token }; - Self::require_not_frozen(&env)?; - let key = DataKey::Whitelist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - - if !Self::is_event_only(&env) { - let key = DataKey::Whitelist(offering_id.clone()); - if let Some(mut map) = - env.storage().persistent().get::>(&key) - { - if map.remove(investor.clone()).is_some() { - env.storage().persistent().set(&key, &map); - } - } - } - - env.events().publish( - ( - EVENT_WL_REM, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (caller, investor), - ); - Ok(()) - } - - /// Returns `true` if `investor` is whitelisted for `token`'s offering. - /// - /// Note: If the whitelist is empty (disabled), this returns `false`. - /// Use `is_whitelist_enabled` to check if whitelist enforcement is active. - pub fn is_whitelisted( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - investor: Address, - ) -> bool { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Whitelist(offering_id); - env.storage() - .persistent() - .get::>(&key) - .map(|m| m.get(investor).unwrap_or(false)) - .unwrap_or(false) - } - - /// Return all whitelisted addresses for an offering. - pub fn get_whitelist( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Vec
{ - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Whitelist(offering_id); - env.storage() - .persistent() - .get::>(&key) - .map(|m| m.keys()) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Return a page of whitelisted addresses for an offering. - /// Limit capped at MAX_PAGE_LIMIT (20). - pub fn get_whitelist_page( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - start: u32, - limit: u32, - ) -> (Vec
, Option) { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Whitelist(offering_id); - let all: Vec
= env - .storage() - .persistent() - .get::>(&key) - .map(|m| m.keys()) - .unwrap_or_else(|| Vec::new(&env)); - - let count = all.len(); - let effective_limit = - if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; - - if start >= count { - return (Vec::new(&env), None); - } - - let end = core::cmp::min(start + effective_limit, count); - let mut results = Vec::new(&env); - for i in start..end { - results.push_back(all.get(i).unwrap()); - } - - let next_cursor = if end < count { Some(end) } else { None }; - (results, next_cursor) - } - - /// Returns `true` if whitelist enforcement is enabled for an offering. - pub fn is_whitelist_enabled( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> bool { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::Whitelist(offering_id); - let map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); - !map.is_empty() - } - - // ── Holder concentration guardrail (#26) ─────────────────── - - /// Set the concentration limit for an offering. - /// - /// Configures the maximum share a single holder can own and whether it is enforced. - /// - /// ### Parameters - /// - `issuer`: The offering issuer. Must provide authentication. - /// - `namespace`: The namespace the offering belongs to. - /// - `token`: The token representing the offering. - /// - `max_bps`: The maximum allowed single-holder share in basis points (0-10000, 0 = disabled). - /// - `enforce`: If true, `report_revenue` will fail if current concentration exceeds `max_bps`. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::LimitReached)` if the offering is not found. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// Configure the concentration limit for an offering. - /// - /// ### Parameters - /// - `max_bps`: The maximum allowed share for a single holder in basis points. - /// - `enforce`: If true, `report_revenue` will fail if current concentration > `max_bps`. - /// - `max_staleness_secs`: When > 0 and `enforce` is true, `report_revenue` rejects if no - /// concentration has been reported or the last report is older than this many seconds. - /// Set to 0 to disable the staleness check. - /// - /// ### Constraints - /// - `max_bps` must be <= 10,000. - pub fn set_concentration_limit( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - max_bps: u32, - enforce: bool, - max_staleness_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - if max_bps > 10_000 { - return Err(RevoraError::InvalidShareBps); - } - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::LimitReached)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::LimitReached); - } - - // Auth-first: authenticate before any state reads or side effects. - // This prevents unauthenticated callers from probing offering existence - // and ensures event-only mode never silently skips authorization. - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - // Verify offering exists and issuer is current - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if !Self::is_event_only(&env) { - let key = DataKey::ConcentrationLimit(offering_id); - env.storage() - .persistent() - .set(&key, &ConcentrationLimitConfig { max_bps, enforce, max_staleness_secs }); - } - - Self::emit_v2_event( - &env, - (EVENT_CONC_LIMIT_SET, issuer, namespace, token), - (max_bps, enforce), - ); - - Ok(()) - } - - pub fn set_transfer_restrictions( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - category: Symbol, - max_holders: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey2::TransferRestrictions(offering_id, category.clone()); - let restrictions = TransferRestrictions { category, max_holders }; - env.storage().persistent().set(&key, &restrictions); - Ok(()) - } - - 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); - } - } - } - } - } - - // ── Per-jurisdiction transfer cooldown check ── - // Look up the `from` holder's jurisdiction and check whether a cooldown - // is configured for that jurisdiction. If so, verify the required time - // has elapsed since the holder's last transfer. - let jurisdiction = Self::get_holder_jurisdiction_internal(&env, &offering_id, &from); - if let Some(jur) = jurisdiction { - if jur != EVENT_JUR_UNSET { - let cooldown_key = - DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); - if let Some(cooldown_secs) = - env.storage().persistent().get::(&cooldown_key) - { - if cooldown_secs > 0 { - let last_xfer_key = - DataKey2::HolderLastTransferTime(offering_id.clone(), from.clone()); - let last_xfer: u64 = - env.storage().persistent().get(&last_xfer_key).unwrap_or(0); - let now = env.ledger().timestamp(); - if now < last_xfer.saturating_add(cooldown_secs) { - return Err(RevoraError::TransferCooldownActive); - } - } - } - } - } - - Ok(()) - } - - /// Check all transfer eligibility gates in one place. - /// - /// Validates that a transfer between `from` and `to` within the given - /// offering and category is permitted. This gate is called by - /// `transfer_with_attestation`. Note that `estimate_transfer` duplicates - /// these checks inline (including the cooldown check) as a public query - /// endpoint so callers can dry-run without issuer auth. - /// - /// # Checks performed - /// - Contract not frozen - /// - Non-zero amount - /// - Neither party blacklisted - /// - `to` jurisdiction allowed - /// - Lockup schedule not active - /// - Sender has enough shares - /// - Category capacity not exceeded - /// - Per-jurisdiction transfer cooldown has elapsed - fn check_transfer_eligibility( - env: &Env, - issuer: &Address, - namespace: &Symbol, - token: &Address, - from: &Address, - to: &Address, - amount_bps: u32, - category: &Symbol, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(env)?; - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if from == to { - return Ok(()); - } - - // 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"))?; - - // 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); - } - } - - 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); - - 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); - } - } - } - } - } - - // ── Per-jurisdiction transfer cooldown check ── - // Look up the `from` holder's jurisdiction and check whether a cooldown - // is configured for that jurisdiction. If so, verify the required time - // has elapsed since the holder's last transfer. - let jurisdiction = Self::get_holder_jurisdiction_internal(env, &offering_id, from); - if let Some(jur) = jurisdiction { - if jur != EVENT_JUR_UNSET { - let cooldown_key = - DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); - if let Some(cooldown_secs) = - env.storage().persistent().get::(&cooldown_key) - { - if cooldown_secs > 0 { - let last_xfer_key = - DataKey2::HolderLastTransferTime(offering_id.clone(), from.clone()); - let last_xfer: u64 = - env.storage().persistent().get(&last_xfer_key).unwrap_or(0); - let now = env.ledger().timestamp(); - if now < last_xfer.saturating_add(cooldown_secs) { - return Err(RevoraError::TransferCooldownActive); - } - } - } - } - } - - Ok(()) - } - - pub fn transfer_with_attestation( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - from: Address, - to: Address, - amount_bps: u32, - category: Symbol, - ) -> Result<(), RevoraError> { - Self::check_transfer_eligibility( - &env, &issuer, &namespace, &token, &from, &to, amount_bps, &category, - )?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if from == to { - return Ok(()); - } - - let from_share: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShare(offering_id.clone(), from.clone())) - .unwrap_or(0); - - 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); - } - } - } - } - } - - // ── Per-jurisdiction transfer cooldown check ── - // Look up the `from` holder's jurisdiction and check whether a cooldown - // is configured for that jurisdiction. If so, verify the required time - // has elapsed since the holder's last transfer. - let jurisdiction = Self::get_holder_jurisdiction_internal(&env, &offering_id, &from); - if let Some(jur) = jurisdiction { - if jur != EVENT_JUR_UNSET { - let cooldown_key = - DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); - if let Some(cooldown_secs) = - env.storage().persistent().get::(&cooldown_key) - { - if cooldown_secs > 0 { - let last_xfer_key = - DataKey2::HolderLastTransferTime(offering_id.clone(), from.clone()); - let last_xfer: u64 = - env.storage().persistent().get(&last_xfer_key).unwrap_or(0); - let now = env.ledger().timestamp(); - if now < last_xfer.saturating_add(cooldown_secs) { - return Err(RevoraError::TransferCooldownActive); - } - } - } - } - } - - Ok(()) - } - - /// Check all transfer eligibility gates in one place. - /// - /// Validates that a transfer between `from` and `to` within the given - /// offering and category is permitted. This gate is called by - /// `transfer_with_attestation`. Note that `estimate_transfer` duplicates - /// these checks inline (including the cooldown check) as a public query - /// endpoint so callers can dry-run without issuer auth. - /// - /// # Checks performed - /// - Contract not frozen - /// - Non-zero amount - /// - Neither party blacklisted - /// - `to` jurisdiction allowed - /// - Lockup schedule not active - /// - Sender has enough shares - /// - Category capacity not exceeded - /// - Per-jurisdiction transfer cooldown has elapsed - fn check_transfer_eligibility( - env: &Env, - issuer: &Address, - namespace: &Symbol, - token: &Address, - from: &Address, - to: &Address, - amount_bps: u32, - category: &Symbol, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(env)?; - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if from == to { - return Ok(()); - } - - // 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"))?; - - // 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); - } - } - - 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); - - 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); - } - } - } - } - } - - // ── Per-jurisdiction transfer cooldown check ── - // Look up the `from` holder's jurisdiction and check whether a cooldown - // is configured for that jurisdiction. If so, verify the required time - // has elapsed since the holder's last transfer. - let jurisdiction = Self::get_holder_jurisdiction_internal(env, &offering_id, from); - if let Some(jur) = jurisdiction { - if jur != EVENT_JUR_UNSET { - let cooldown_key = - DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); - if let Some(cooldown_secs) = - env.storage().persistent().get::(&cooldown_key) - { - if cooldown_secs > 0 { - let last_xfer_key = - DataKey2::HolderLastTransferTime(offering_id.clone(), from.clone()); - let last_xfer: u64 = - env.storage().persistent().get(&last_xfer_key).unwrap_or(0); - let now = env.ledger().timestamp(); - if now < last_xfer.saturating_add(cooldown_secs) { - return Err(RevoraError::TransferCooldownActive); - } - } - } - } - } - - Ok(()) - } - - pub fn transfer_with_attestation( - 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>, - nonce: u64, - expires_at: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let active_network_id = env.ledger().network_id(); - if network_id != active_network_id { - return Err(RevoraError::NetworkIdMismatch); - } - - // Guard 3 — self-transfer is never permitted. - if from == to { - return Err(RevoraError::InvalidTransferParticipants); - } - - // Guard 10 — transferring zero shares is nonsensical and likely a caller bug. - if amount_bps == 0 { - return Err(RevoraError::InvalidShareBps); - } - - // Guard 4 — offering must exist and the supplied issuer must be its primary issuer. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Offering-level freeze check - if env - .storage() - .persistent() - .get::(&DataKey2::FrozenOffering(offering_id.clone())) - .unwrap_or(false) - { - return Err(RevoraError::OfferingFrozen); - } - - // Dual-party authorization: both from and to must sign - from.require_auth(); - to.require_auth(); - - // Attestation nonce/expiry validation: check but do NOT consume yet. - // Consumption happens only after all guards pass (see mark at the end). - // When expires_at is 0 (internal callers without attestation context, e.g. atomic_swap) - // the nonce/expiry check is skipped. - if expires_at > 0 { - if env.ledger().timestamp() > expires_at { - return Err(RevoraError::SignatureExpired); - } - let nonce_key = DataKey2::AttestationNonceUsed(from.clone(), nonce); - if env.storage().persistent().has(&nonce_key) { - return Err(RevoraError::SignatureReplay); - } - } - - if from == to { - return Ok(()); - } - - // Lockup violation check - 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); - } - - // Whitelist enforcement - if Self::is_whitelist_enabled(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - { - if !Self::is_whitelisted( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - from.clone(), - ) || !Self::is_whitelisted( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - to.clone(), - ) { - return Err(RevoraError::NotAuthorized); - } - } - - // Jurisdiction block - Self::require_holder_jurisdiction_allowed( - env.clone(), - &offering_id, - to.clone(), - 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); - } - - // Guard 9 — recipient share cap: `to`'s resulting share must not exceed 10 000 bps. - let to_share: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShare(offering_id.clone(), to.clone())) - .unwrap_or(0); - if to_share.checked_add(amount_bps).unwrap_or(u32::MAX) > 10_000 { - return Err(RevoraError::InvalidShareBps); - } - - // All guards passed — apply the share transfer atomically. - Self::set_holder_share_internal( - &env, - issuer.clone(), - namespace.clone(), - token.clone(), - from.clone(), - from_share - amount_bps, - None, - None, - )?; - Self::set_holder_share_internal( - &env, - issuer, - namespace, - token, - to, - to_share + amount_bps, - None, - None, - )?; - - // ── Record last transfer timestamp for cooldown enforcement ── - // Update the `from` holder's last transfer timestamp so that subsequent - // transfers by the same holder are gated by the jurisdiction cooldown. - // Only record when `from` retains shares (moved some, kept some) or - // when `from` still has a jurisdiction set, so that the cooldown state - // is meaningful. - let now = env.ledger().timestamp(); - let jur = Self::get_holder_jurisdiction_internal(&env, &offering_id, &from); - if let Some(j) = jur { - if j != EVENT_JUR_UNSET { - let cooldown_key = DataKey2::TransferCooldownConfig(offering_id.clone(), j.clone()); - if env.storage().persistent().has(&cooldown_key) { - let last_xfer_key = - DataKey2::HolderLastTransferTime(offering_id.clone(), from.clone()); - env.storage().persistent().set(&last_xfer_key, &now); - } - } - } - - Ok(()) - } - - /// Compute the canonical domain-separated attestation digest for a share transfer. - /// - /// Off-chain signers use this protocol to produce the `attest_hash` that must be - /// passed to `transfer_with_attestation`. The digest commits to the current - /// **network's identity** (`network_id`) so an attestation signed for testnet is - /// **cryptographically incompatible** with mainnet (closes #578). - /// - /// ## Digest construction - /// - /// ```text - /// digest = sha256( - /// network_id (32 bytes — sha256 of 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) - /// ) - /// ``` - /// - /// ## Parameters - /// - All parameters mirror `transfer_with_attestation`. - /// - /// ## Returns - /// - `Ok(BytesN<32>)` — the expected digest for the current chain. - /// - /// ## Usage - /// - /// Off-chain: - /// 1. Collect the transfer parameters. - /// 2. Call this function (read-only) to retrieve the expected digest. - /// 3. Have the authorised signer sign / produce the digest. - /// 4. Pass the returned hash as `attest_hash` to `transfer_with_attestation`. - /// - /// On-chain validation: - /// Call `verify_attestation_digest` with a `SignedAttestation` to assert that a - /// previously produced hash is still valid for the current network. - pub fn compute_attestation_digest( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - from: Address, - to: Address, - amount_bps: u32, - ) -> BytesN<32> { - Self::build_attestation_digest(&env, &issuer, &namespace, &token, &from, &to, amount_bps) - } - - /// Verify that a `SignedAttestation`'s embedded `network_id` matches the current chain. - /// - /// Returns `Ok(())` when the attestation is valid for this chain and the digest matches - /// the expected domain-separated hash of the transfer parameters. - /// Returns `Err(NetworkIdMismatch)` when the attestation was produced for a different - /// network (e.g. testnet attestation replayed on mainnet). - /// - /// ## Security note - /// - /// This function is **read-only** — it does not transfer shares or write any state. - /// It is intended as a pre-flight check before calling `transfer_with_attestation`. - /// - /// ## Parameters - /// - `attestation`: A `SignedAttestation` containing the signer's `network_id` and `digest`. - /// - `issuer` / `namespace` / `token` / `from` / `to` / `amount_bps`: Transfer parameters - /// whose canonical digest the attestation must cover. - /// - /// ## Returns - /// - `Ok(())` if `attestation.network_id == env.ledger().network_id()` AND - /// `attestation.digest == sha256(network_id || issuer || ... || amount_bps)`. - /// - `Err(RevoraError::NetworkIdMismatch)` otherwise. - pub fn verify_attestation_digest( - env: Env, - attestation: SignedAttestation, - issuer: Address, - namespace: Symbol, - token: Address, - from: Address, - to: Address, - amount_bps: u32, - ) -> Result<(), RevoraError> { - // Step 1 — the network_id embedded in the attestation must match the current ledger. - let chain_network_id = env.ledger().network_id(); - if attestation.network_id != chain_network_id { - return Err(RevoraError::NetworkIdMismatch); - } - - // Step 2 — the digest must match the canonical preimage for these parameters. - let expected = Self::build_attestation_digest( - &env, &issuer, &namespace, &token, &from, &to, amount_bps, - ); - if attestation.digest != expected { - return Err(RevoraError::NetworkIdMismatch); - } - - Ok(()) - } - - /// Internal helper: compute the canonical attestation digest. - /// - /// `sha256(network_id || XDR(issuer) || XDR(namespace) || XDR(token) - /// || XDR(from) || XDR(to) || XDR(amount_bps))` - fn build_attestation_digest( - env: &Env, - issuer: &Address, - namespace: &Symbol, - token: &Address, - from: &Address, - to: &Address, - amount_bps: u32, - ) -> BytesN<32> { - let chain_network_id = env.ledger().network_id(); - - let mut preimage = Bytes::new(env); - // Prefix with the 32-byte network id so the digest is chain-specific. - for b in chain_network_id.iter() { - preimage.push_back(b); - } - preimage.append(&issuer.to_xdr(env)); - preimage.append(&namespace.to_xdr(env)); - preimage.append(&token.to_xdr(env)); - preimage.append(&from.to_xdr(env)); - preimage.append(&to.to_xdr(env)); - // Encode amount_bps as 4 big-endian bytes (canonical u32 serialisation). - let bps_bytes = amount_bps.to_be_bytes(); - for b in bps_bytes.iter() { - preimage.push_back(*b); - } - - env.crypto().sha256(&preimage) - } - - /// Report the current top-holder concentration for an offering. - /// - /// a `conc_warn` event is emitted. The stored value is used for enforcement in `report_revenue`. - /// - /// ### Enforcement Boundary - /// - If `enforce` is true in `ConcentrationLimitConfig`: - /// - `concentration_bps <= max_bps`: `report_revenue` is allowed. - /// - `concentration_bps > max_bps`: `report_revenue` is rejected. - /// - /// ### Parameters - /// - `issuer`: The offering issuer. Must provide authentication. - /// - `token`: The token representing the offering. - /// - `concentration_bps`: The current top-holder share in basis points. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - pub fn report_concentration( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - concentration_bps: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - if concentration_bps > 10_000 { - return Err(RevoraError::InvalidShareBps); - } - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let limit_key = DataKey::ConcentrationLimit(offering_id.clone()); - if let Some(config) = - env.storage().persistent().get::(&limit_key) - { - if config.max_bps > 0 && concentration_bps > config.max_bps { - env.events().publish( - (EVENT_CONCENTRATION_WARNING, issuer.clone(), namespace.clone(), token.clone()), - (concentration_bps, config.max_bps), - ); - } - } - - if !Self::is_event_only(&env) { - env.storage() - .persistent() - .set(&DataKey::CurrentConcentration(offering_id.clone()), &concentration_bps); - env.storage().persistent().set( - &DataKey::ConcentrationReportedAt(offering_id.clone()), - &env.ledger().timestamp(), - ); - env.events().publish( - (EVENT_CONCENTRATION_REPORTED, issuer, namespace, token), - concentration_bps, - ); - } - Ok(()) - } - - /// Get concentration limit config for an offering. - pub fn get_concentration_limit( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::ConcentrationLimit(offering_id); - env.storage().persistent().get(&key) - } - - /// Get last reported concentration in bps for an offering. - pub fn get_current_concentration( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::CurrentConcentration(offering_id); - env.storage().persistent().get(&key) - } - - // ── Audit log summary (#34) ──────────────────────────────── - - /// Get per-offering audit summary (total revenue and report count). - pub fn get_audit_summary( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::AuditSummary(offering_id); - env.storage().persistent().get(&key) - } - - /// Set rounding mode for an offering. Default is truncation. - /// - /// ### Auth ordering - /// `issuer.require_auth()` is called immediately after the frozen guard so that - /// unauthenticated callers cannot probe offering existence or trigger side effects. - pub fn set_rounding_mode( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - mode: RoundingMode, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - - // Auth-first: authenticate before any state reads. - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let key = DataKey::RoundingMode(offering_id); - env.storage().persistent().set(&key, &mode); - Self::emit_v2_event(&env, (EVENT_ROUNDING_MODE_SET, issuer, namespace, token), mode); - Ok(()) - } - - /// Get rounding mode for an offering. - pub fn get_rounding_mode( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> RoundingMode { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::RoundingMode(offering_id); - env.storage().persistent().get(&key).unwrap_or(RoundingMode::Truncation) - } - - // ── Per-offering investment constraints (#97) ───────────── - - /// Set min and max stake per investor for an offering. Issuer/admin only. Constraints are read by off-chain systems for enforcement. - /// Validates amounts using the Negative Amount Validation Matrix (#163). - /// - /// ### Auth ordering - /// `issuer.require_auth()` is called immediately after the frozen guard, before any state reads. - pub fn set_investment_constraints( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - min_stake: i128, - max_stake: i128, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - - // Auth-first: authenticate before any state reads. - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Negative Amount Validation Matrix: InvestmentMinStake requires >= 0 (#163) - if let Err((err, _)) = AmountValidationMatrix::validate( - min_stake, - AmountValidationCategory::InvestmentMinStake, - ) { - return Err(err); - } - - // Negative Amount Validation Matrix: InvestmentMaxStake requires >= 0 (#163) - if let Err((err, _)) = AmountValidationMatrix::validate( - max_stake, - AmountValidationCategory::InvestmentMaxStake, - ) { - return Err(err); - } - - // Validate range: max_stake >= min_stake when max_stake > 0 - AmountValidationMatrix::validate_stake_range(min_stake, max_stake)?; - - let key = DataKey2::InvestmentConstraints(offering_id); - let previous = - env.storage().persistent().get::(&key); - env.storage().persistent().set(&key, &InvestmentConstraintsConfig { min_stake, max_stake }); - Self::emit_v2_event( - &env, - (EVENT_INV_CONSTRAINTS, issuer, namespace, token), - (min_stake, max_stake, previous.is_some()), - ); - Ok(()) - } - - /// Get per-offering investment constraints. Returns None if not set. - pub fn get_investment_constraints( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::InvestmentConstraints(offering_id); - env.storage().persistent().get(&key) - } - - // ── Per-offering minimum revenue threshold (#25) ───────────────────── - - /// Set minimum revenue per period below which no distribution is triggered. - /// Only the offering issuer may set this. Emits event when configured or changed. - /// Pass 0 to disable the threshold. - /// Validates amount using the Negative Amount Validation Matrix (#163). - /// - /// ### Auth ordering - /// `issuer.require_auth()` is called immediately after the frozen guard, before any state reads. - pub fn set_min_revenue_threshold( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - min_amount: i128, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - - // Auth-first: authenticate before any state reads. - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Negative Amount Validation Matrix: MinRevenueThreshold requires >= 0 (#163) - if let Err((err, _)) = AmountValidationMatrix::validate( - min_amount, - AmountValidationCategory::MinRevenueThreshold, - ) { - return Err(err); - } - - let key = DataKey2::MinRevenueThreshold(offering_id); - let previous: i128 = env.storage().persistent().get(&key).unwrap_or(0); - env.storage().persistent().set(&key, &min_amount); - - Self::emit_v2_event( - &env, - (EVENT_MIN_REV_THRESHOLD_SET, issuer, namespace, token), - (previous, min_amount), - ); - Ok(()) - } - - /// Get minimum revenue threshold for an offering. 0 means no threshold. - pub fn get_min_revenue_threshold( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> i128 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::MinRevenueThreshold(offering_id); - env.storage().persistent().get(&key).unwrap_or(0) - } - - /// Set the per-offering checkpoint compression threshold. - /// - /// When a holder's share schedule length exceeds `threshold` the - /// oldest entries are folded into an `AccrualAnchor` and pruned - /// from the schedule. The anchor stores a lossless pre-computed - /// sum of claimable amounts for the compressed period range. - /// - /// Pass `0` to disable compression (the schedule will never be pruned). - /// - /// ### Auth - /// Requires `issuer.require_auth()`. The caller must be the current - /// issuer of the offering. - /// - /// ### Errors - /// - [`RevoraError::OfferingNotFound`] â the offering does not exist - /// or the caller is not the current issuer. - /// - [`RevoraError::NotAuthorized`] â the caller is not the issuer. - pub fn set_checkpoint_threshold( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - threshold: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey2::CheckpointThreshold(offering_id); - let previous: u32 = - env.storage().persistent().get(&key).unwrap_or(CHECKPOINT_THRESHOLD_DEFAULT); - env.storage().persistent().set(&key, &threshold); - - Self::emit_v2_event( - &env, - (symbol_short!("chk_pt"), issuer, namespace, token), - (previous, threshold), - ); - Ok(()) - } - - /// Get the checkpoint compression threshold for an offering. - /// - /// Returns the configured threshold, or [`CHECKPOINT_THRESHOLD_DEFAULT`] - /// when no explicit threshold has been set for this offering. - pub fn get_checkpoint_threshold( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::CheckpointThreshold(offering_id); - env.storage().persistent().get(&key).unwrap_or(CHECKPOINT_THRESHOLD_DEFAULT) - } - - /// Compute share of `amount` at `revenue_share_bps` using the given rounding mode. - /// Security assumptions: - /// - Callers should pass `revenue_share_bps` in [0, 10_000]. Values above 10_000 are rejected by returning 0. - /// - Revenue flows in this contract are non-negative, but this helper is total over signed `amount` for testability. - /// - /// Guarantees: - /// - Overflow-resistant arithmetic without panic. - /// - Result is clamped to [min(0, amount), max(0, amount)] to avoid over-distribution. - /// - /// ## Decomposition Bound - /// - /// The function decomposes `amount` as `amount = q * 10_000 + r` where: - /// - `q = amount / 10_000` (quotient) - /// - `r = amount % 10_000` (remainder, bounded to `|r| < 10_000`) - /// - /// This ensures: - /// - `|r * bps| < 10_000 * 10_000 = 10^8` (well within i128 range) - /// - The remainder product uses `checked_mul` with saturating fallback for defense-in-depth - /// - Even if the bound assumption is violated by refactors, saturation prevents overflow - pub fn compute_share( - _env: Env, - amount: i128, - revenue_share_bps: u32, - mode: RoundingMode, - ) -> i128 { - if revenue_share_bps > 10_000 { - return 0; - } - if amount == 0 || revenue_share_bps == 0 { - return 0; - } - - // Decompose `amount` to avoid `amount * bps` overflow: - // amount = q * 10_000 + r, so (amount * bps) / 10_000 = q * bps + (r * bps) / 10_000. - // `r` is bounded to (-10_000, 10_000), so `r * bps` is always safe in i128. - // Defense-in-depth: use s_mul with saturating fallback to guard against refactors. - let q = amount.s_div(10_000).unwrap_or(0); - let r = amount % 10_000; - let bps = revenue_share_bps as i128; - let base = q.s_mul(bps).unwrap_or_else(|_| { - if (q >= 0 && bps >= 0) || (q < 0 && bps < 0) { - i128::MAX - } else { - i128::MIN - } - }); - - let remainder_product = r.s_mul(bps).unwrap_or_else(|_| { - if (r >= 0 && bps >= 0) || (r < 0 && bps < 0) { - i128::MAX - } else { - i128::MIN - } - }); - let remainder_share = match mode { - RoundingMode::Truncation => remainder_product.s_div(10_000).unwrap_or(0), - RoundingMode::RoundHalfUp => { - let half = 5_000_i128; - if remainder_product >= 0 { - remainder_product.s_add(half).unwrap_or(i128::MAX).s_div(10_000).unwrap_or(0) - } else { - remainder_product.s_sub(half).unwrap_or(i128::MIN).s_div(10_000).unwrap_or(0) - } - } - }; - - let share = base.s_add(remainder_share).unwrap_or_else(|_| { - if (base >= 0 && remainder_share >= 0) || (base < 0 && remainder_share < 0) { - if base >= 0 { - i128::MAX - } else { - i128::MIN - } - } else { - 0 - } - }); - - // Clamp to [min(0, amount), max(0, amount)] to avoid overflow semantics affecting bounds - let lo = core::cmp::min(0, amount); - let hi = core::cmp::max(0, amount); - core::cmp::min(core::cmp::max(share, lo), hi) - } - - /// Normalize `amount` from the token's native decimal precision to Stellar's canonical 7-decimal - /// (stroop) precision used internally by this contract. - /// - /// - If `from_decimals == 7`: returns `amount` unchanged. - /// - If `from_decimals < 7`: scales **up** by `10^(7 - from_decimals)` (e.g., 6-decimal USDC → 7). - /// - If `from_decimals > 7`: scales **down** by `10^(from_decimals - 7)` using integer truncation. - /// - /// Returns `0` if intermediate arithmetic overflows to prevent fund inflation bugs. - fn normalize_amount(amount: i128, from_decimals: u32) -> i128 { - if from_decimals == STELLAR_CANONICAL_DECIMALS { - return amount; - } - if from_decimals < STELLAR_CANONICAL_DECIMALS { - let exp = STELLAR_CANONICAL_DECIMALS - from_decimals; - let factor: i128 = match 10_i128.checked_pow(exp) { - Some(f) => f, - None => return 0, - }; - amount.checked_mul(factor).unwrap_or(0) - } else { - let exp = from_decimals - STELLAR_CANONICAL_DECIMALS; - let factor: i128 = match 10_i128.checked_pow(exp) { - Some(f) => f, - None => return 0, - }; - amount.checked_div(factor).unwrap_or(0) - } - } - - /// Set the decimal precision of the payout asset for an offering. - /// - /// Must be called by the offering `issuer`. Accepted range is `0..=18`. - /// If not set, the contract defaults to `7` (Stellar canonical stroops). - /// - /// ### Security - /// - Only the offering issuer may configure decimals. - /// - Misconfigured decimals directly affect payout arithmetic; issuers must supply - /// the on-chain token's actual decimal value. - /// - /// ### Errors - /// - `RevoraError::NotAuthorized` if caller is not the issuer. - /// - `RevoraError::LimitReached` if `decimals > 18`. - pub fn set_payment_token_decimals( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - decimals: u32, - ) -> Result<(), RevoraError> { - if decimals > MAX_TOKEN_DECIMALS { - return Err(RevoraError::LimitReached); - } - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage().persistent().set(&DataKey2::PaymentTokenDecimals(offering_id), &decimals); - env.events().publish((EVENT_DECIMAL_SET, issuer, namespace, token), decimals); - Ok(()) - } - - /// Get the configured decimal precision of the payout asset for an offering. - /// Defaults to `7` (Stellar canonical stroops) if not explicitly set. - pub fn get_payment_token_decimals( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::PaymentTokenDecimals(offering_id)) - .unwrap_or(STELLAR_CANONICAL_DECIMALS) - } - - // ── Multi-period aggregated claims ─────────────────────────── - - /// Deposit revenue for a specific period of an offering. - /// - /// # Arguments - /// * `issuer` - The address of the offering issuer. - /// * `namespace` - A symbol identifying the namespace. - /// * `token` - The address of the token. - /// * `payment_token` - The address of the token used for payment. - /// * `amount` - The amount of revenue to deposit. - /// * `period_id` - The identifier for the revenue period. - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::OfferingNotFound)` if the offering is not found. - /// - `Err(RevoraError::PeriodAlreadyDeposited)` if revenue has already been deposited for this `period_id`. - /// - `Err(RevoraError::PaymentTokenMismatch)` if `payment_token` differs from the token locked by the first successful deposit. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - pub fn deposit_revenue( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - payment_token: Address, - amount: i128, - period_id: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - // Input validation (#35): reject zero/invalid period_id and non-positive amounts. - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - Self::require_positive_amount(amount)?; - - // Verify offering exists and issuer is current - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - Self::require_not_frozen(&env)?; - - Self::do_deposit_revenue(&env, issuer, namespace, token, payment_token, amount, period_id) - } - - /// any previously recorded snapshot for this offering to prevent duplication. - /// Validates amount and snapshot reference using the Negative Amount Validation Matrix (#163). - #[allow(clippy::too_many_arguments)] - pub fn deposit_revenue_with_snapshot( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - payment_token: Address, - amount: i128, - period_id: u64, - snapshot_reference: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - // 0. Validate snapshot reference using Negative Amount Validation Matrix (#163) - // SnapshotReference requires > 0 and strictly increasing - if let Err((err, _)) = AmountValidationMatrix::validate( - snapshot_reference as i128, - AmountValidationCategory::SnapshotReference, - ) { - return Err(err); - } - - // 1. Verify snapshots are enabled - if !Self::get_snapshot_config(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - { - return Err(RevoraError::SnapshotNotEnabled); - } - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if Self::snapshot_finalization_required(env.clone()) - && !Self::is_snapshot_finalized(&env, &offering_id, snapshot_reference) - { - return Err(RevoraError::SnapshotNotFinalized); - } - - Self::require_not_frozen(&env)?; - - // 2. Validate snapshot reference is strictly monotonic using matrix helper - let snap_key = DataKey::LastSnapshotRef(offering_id.clone()); - let last_snap: u64 = env.storage().persistent().get(&snap_key).unwrap_or(0); - AmountValidationMatrix::validate_snapshot_monotonic( - snapshot_reference as i128, - last_snap as i128, - )?; - - // 3. Delegate to core deposit logic (includes RevenueDeposit validation) - Self::do_deposit_revenue( - &env, - issuer.clone(), - namespace.clone(), - token.clone(), - payment_token.clone(), - amount, - period_id, - )?; - - // 4. Update last snapshot and emit specialized event - env.storage().persistent().set(&snap_key, &snapshot_reference); - // Versioned event v2: [version: u32, payment_token: Address, amount: i128, period_id: u64, snapshot_reference: u64] - Self::emit_v2_event( - &env, - (EVENT_REV_DEP_SNAP_V2, issuer.clone(), namespace.clone(), token.clone()), - (payment_token, amount, period_id, snapshot_reference), - ); - - Ok(()) - } - - /// Enable or disable snapshot-based distribution for an offering. - pub fn set_snapshot_config( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - enabled: bool, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { issuer, namespace, token }; - Self::require_not_frozen(&env)?; - let key = DataKey::SnapshotConfig(offering_id.clone()); - env.storage().persistent().set(&key, &enabled); - env.events().publish( - (EVENT_SNAP_CONFIG, offering_id.issuer, offering_id.namespace, offering_id.token), - enabled, - ); - Ok(()) - } - - /// Check if snapshot-based distribution is enabled for an offering. - pub fn get_snapshot_config( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> bool { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::SnapshotConfig(offering_id); - env.storage().persistent().get(&key).unwrap_or(false) - } - - /// Get the latest recorded snapshot reference for an offering. - pub fn get_last_snapshot_ref( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u64 { - let offering_id = OfferingId { issuer, namespace, token }; - let deposit_ref: u64 = env - .storage() - .persistent() - .get(&DataKey::LastSnapshotRef(offering_id.clone())) - .unwrap_or(0); - let commit_ref: u64 = env - .storage() - .persistent() - .get(&DataKey::LastSnapshotCommitRef(offering_id)) - .unwrap_or(0); - if deposit_ref > commit_ref { - deposit_ref - } else { - commit_ref - } - } - - // ── Deterministic Snapshot Expansion (#054) ────────────────────────────── - // - // Design: - // A "snapshot" is an immutable, write-once record that captures the - // canonical holder-share distribution at a specific point in time. - // - // Workflow: - // 1. Issuer calls `commit_snapshot` with a strictly-increasing `snapshot_ref` - // and a 32-byte `content_hash` of the off-chain holder dataset. - // The contract stores a `SnapshotEntry` and emits `snap_com`. - // 2. Issuer calls `apply_snapshot_shares` (one or more times) to write - // holder shares for this snapshot into persistent storage. - // Each call appends a bounded batch of (holder, share_bps) pairs. - // Emits `snap_shr` per batch. - // 3. Issuer calls `deposit_revenue_with_snapshot` (existing) to deposit - // revenue tied to this snapshot_ref. - // - // Security assumptions: - // - `content_hash` is caller-supplied and stored verbatim. The contract - // does NOT verify it matches the on-chain holder entries. Off-chain - // consumers MUST recompute and compare the hash. - // - Snapshot refs are strictly monotonic per offering; replay is impossible. - // - `apply_snapshot_shares` is idempotent per (snapshot_ref, index): writing - // the same index twice overwrites with the same value (no double-credit). - // - Only the current offering issuer may commit or apply snapshots. - // - Frozen/paused contract blocks all snapshot writes. - - /// Maximum holders per `apply_snapshot_shares` batch. - /// Keeps per-call compute bounded within Soroban limits. - const MAX_SNAPSHOT_BATCH: u32 = 50; - - /// Commit a new snapshot entry for an offering. - /// - /// Records an immutable `SnapshotEntry` keyed by `(offering_id, snapshot_ref)`. - /// `snapshot_ref` must be strictly greater than the last committed ref for this - /// offering (monotonicity invariant). The `content_hash` is a 32-byte digest of - /// the off-chain holder-share dataset; it is stored verbatim and not verified - /// on-chain. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. Only the current offering issuer may commit. - /// - /// ### Errors - /// - `OfferingNotFound`: offering does not exist or caller is not current issuer. - /// - `SnapshotNotEnabled`: snapshot distribution is not enabled for this offering. - /// - `OutdatedSnapshot`: `snapshot_ref` ≤ last committed ref (replay / stale). - /// - `ContractFrozen` / paused: contract is not operational. - /// - /// ### Events - /// Emits `snap_com` with `(issuer, namespace, token)` topics and - /// `(snapshot_ref, content_hash, committed_at)` data. - pub fn commit_snapshot( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - snapshot_ref: u64, - content_hash: BytesN<32>, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - // Verify offering exists and caller is current issuer. - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - // Snapshot distribution must be enabled for this offering. - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - if !env - .storage() - .persistent() - .get::(&DataKey::SnapshotConfig(offering_id.clone())) - .unwrap_or(false) - { - return Err(RevoraError::SnapshotNotEnabled); - } - - // Enforce strict monotonicity: snapshot_ref must exceed the last committed ref. - let last_ref_key = DataKey::LastSnapshotCommitRef(offering_id.clone()); - let last_ref: u64 = env.storage().persistent().get(&last_ref_key).unwrap_or(0); - if snapshot_ref <= last_ref { - return Err(RevoraError::OutdatedSnapshot); - } - - let committed_at = env.ledger().timestamp(); - let entry = SnapshotEntry { - snapshot_ref, - committed_at, - content_hash: content_hash.clone(), - holder_count: 0, - total_bps: 0, - }; - - // Write-once: store the entry and advance the last-ref pointer atomically. - env.storage() - .persistent() - .set(&DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref), &entry); - env.storage().persistent().set(&last_ref_key, &snapshot_ref); - - env.events().publish( - (EVENT_SNAP_COMMIT, issuer, namespace, token), - (snapshot_ref, content_hash, committed_at), - ); - Ok(()) - } - - /// Retrieve a committed snapshot entry. - /// - /// Returns `None` if no snapshot with `snapshot_ref` has been committed for this offering. - pub fn get_snapshot_entry( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - snapshot_ref: u64, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey::SnapshotEntry(offering_id, snapshot_ref)) - } - - /// Apply a batch of holder shares for a committed snapshot. - /// - /// Writes `(holder, share_bps)` pairs into persistent storage indexed by - /// `(offering_id, snapshot_ref, sequential_index)`. Batches are bounded by - /// `MAX_SNAPSHOT_BATCH` (50) per call. Updates `HolderShare` for each holder. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. Only the current offering issuer may apply. - /// - /// ### Errors - /// - `OfferingNotFound`, `SnapshotNotEnabled`, `OutdatedSnapshot`, - /// `LimitReached`, `InvalidShareBps`, `ContractFrozen`. - pub fn apply_snapshot_shares( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - snapshot_ref: u64, - start_index: u32, - holders: Vec<(Address, u32)>, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if !env - .storage() - .persistent() - .get::(&DataKey::SnapshotConfig(offering_id.clone())) - .unwrap_or(false) - { - return Err(RevoraError::SnapshotNotEnabled); - } - - // Snapshot must have been committed first. - let entry_key = DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref); - let mut entry: SnapshotEntry = - env.storage().persistent().get(&entry_key).ok_or(RevoraError::OutdatedSnapshot)?; - - let batch_len = holders.len(); - if batch_len > Self::MAX_SNAPSHOT_BATCH { - return Err(RevoraError::LimitReached); - } - - // Validate all share_bps and jurisdiction rules before writing anything (fail-fast). - for i in 0..batch_len { - let (holder, share_bps) = holders.get(i).unwrap(); - if share_bps > 10_000 { - return Err(RevoraError::InvalidShareBps); - } - Self::require_holder_jurisdiction_allowed( - &env, - &offering_id, - &holder, - EVENT_JUR_ACTION_SNAPSHOT, - )?; - } - - let mut added_bps: u32 = 0; - - // Maintain per-offering running total and validate aggregate cap. - let total_key = DataKey::HolderShareTotal(offering_id.clone()); - let mut current_total: u32 = env.storage().persistent().get(&total_key).unwrap_or(0); - let mut slot_count: u32 = env - .storage() - .persistent() - .get(&DataKey::SnapshotHolderCount(offering_id.clone(), snapshot_ref)) - .unwrap_or(0); - - // Check max total supply shares cap first - let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); - let max_shares: i128 = env.storage().persistent().get(&max_shares_key).unwrap_or(0); - let mut temp_total_shares: i128 = if max_shares > 0 { - env.storage() - .persistent() - .get(&DataKey2::TotalSharesIssued(offering_id.clone())) - .unwrap_or(0) - } else { - 0 - }; - let mut temp_deltas: Vec<(Address, i128)> = Vec::new(&env); - - // First pass: calculate deltas and check cap - if max_shares > 0 { - for i in 0..batch_len { - let (holder, share_bps) = holders.get(i).unwrap(); - let old_share: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) - .unwrap_or(0); - let delta = (share_bps as i128) - (old_share as i128); - temp_total_shares = temp_total_shares.saturating_add(delta); - temp_deltas.push_back((holder.clone(), delta)); - } - if temp_total_shares > max_shares { - return Err(RevoraError::MaxTotalSupplySharesExceeded); - } - if temp_total_shares == max_shares { - env.events().publish( - (EVENT_SUPPLY_CAP_SATURATED, offering_id.issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()), - (temp_total_shares, max_shares), - ); - } - } - - // Now apply the changes - for i in 0..batch_len { - let (holder, share_bps) = holders.get(i).unwrap(); - let slot = start_index.saturating_add(i); - - // Write indexed slot for deterministic enumeration. - env.storage().persistent().set( - &DataKey::SnapshotHolder(offering_id.clone(), snapshot_ref, slot), - &(holder.clone(), share_bps), - ); - - // Write address-keyed entry for O(1) vote-weight lookup (issue #557). - env.storage().persistent().set( - &DataKey::SnapshotHolderShare(offering_id.clone(), snapshot_ref, holder.clone()), - &share_bps, - ); - - if slot.saturating_add(1) > slot_count { - slot_count = slot.saturating_add(1); - } - - // Compute delta against previously persisted holder share. - let old_share: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) - .unwrap_or(0); - - let new_total = current_total.saturating_sub(old_share).saturating_add(share_bps); - if new_total > 10_000 { - return Err(RevoraError::InvalidShareBps); - } - - Self::cache_holder_accrual_through_matured(&env, &offering_id, &holder); - - // Update live holder share so claim() works immediately. - env.storage() - .persistent() - .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); - Self::record_holder_share_transition(&env, &offering_id, &holder, old_share, share_bps); - - current_total = new_total; - added_bps = added_bps.saturating_add(share_bps); - } - - // Update total shares issued - if max_shares > 0 { - env.storage() - .persistent() - .set(&DataKey2::TotalSharesIssued(offering_id.clone()), &temp_total_shares); - } else { - // If no cap, still track total shares - let mut total_shares: i128 = env - .storage() - .persistent() - .get(&DataKey2::TotalSharesIssued(offering_id.clone())) - .unwrap_or(0); - for i in 0..batch_len { - let (holder, share_bps) = holders.get(i).unwrap(); - let old_share: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) - .unwrap_or(0); - total_shares = total_shares - .saturating_sub(old_share as i128) - .saturating_add(share_bps as i128); - } - env.storage() - .persistent() - .set(&DataKey2::TotalSharesIssued(offering_id.clone()), &total_shares); - } - - // Update snapshot metadata. - if slot_count > entry.holder_count { - entry.holder_count = slot_count; - } - let new_total_bps = entry.total_bps.saturating_add(added_bps); - entry.total_bps = new_total_bps; - env.storage().persistent().set(&entry_key, &entry); - env.storage() - .persistent() - .set(&DataKey::SnapshotHolderCount(offering_id.clone(), snapshot_ref), &slot_count); - - // Persist updated per-offering running total. - env.storage() - .persistent() - .set(&DataKey::HolderShareTotal(offering_id.clone()), ¤t_total); - - env.events().publish( - (EVENT_SNAP_SHARES_APPLIED, issuer, namespace, token), - (snapshot_ref, start_index, batch_len, new_total_bps), - ); - Ok(()) - } - - /// Verify a snapshot proof while enforcing the maximum depth permitted by the - /// contract. Deep proofs can exhaust contract memory and gas, so the contract - /// rejects them early with [`RevoraError::ProofTooDeep`] and emits - /// [`EVENT_PROOF_REJECT_DEPTH`] when the bound is violated. - /// - /// The implementation is intentionally lightweight: it only validates the proof - /// length and returns `Ok(())` for proofs that fit within the hard limit. - /// Off-chain callers remain responsible for the actual proof verification and - /// root comparison logic. - pub fn verify_snapshot_proof( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - snapshot_ref: u64, - proof: Vec>, - ) -> Result<(), RevoraError> { - let proof_len = proof.len(); - if proof_len > MAX_PROOF_DEPTH { - env.events().publish( - ( - EVENT_PROOF_REJECT_DEPTH, - issuer.clone(), - namespace.clone(), - token.clone(), - snapshot_ref, - ), - (proof_len, MAX_PROOF_DEPTH), - ); - return Err(RevoraError::ProofTooDeep); - } - - Ok(()) - } - - /// Return the total number of holder entries recorded for a snapshot. - /// - /// Returns 0 if the snapshot has not been committed or no shares have been applied. - pub fn get_snapshot_holder_count( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - snapshot_ref: u64, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey::SnapshotHolderCount(offering_id, snapshot_ref)) - .unwrap_or(0) - } - - /// Read a single holder entry from a committed snapshot by its sequential index. - /// - /// Returns `None` if the slot has not been written. - pub fn get_snapshot_holder_at( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - snapshot_ref: u64, - index: u32, - ) -> Option<(Address, u32)> { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index)) - } - - /// Set a holder's revenue share in basis points for an offering. - /// - /// The `nonce` must be strictly greater than the last accepted nonce for this - /// `(offering_id, holder)` pair. This monotonicity guard prevents stale - /// off-chain updates from silently overwriting newer on-chain share state. - /// Use `nonce = 1` on the first call; increment by at least 1 on every - /// subsequent call. The contract persists the last accepted value so the - /// caller can derive the next valid nonce from `get_holder_share_nonce`. - pub fn set_holder_share( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - share_bps: u32, - nonce: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Delegate to internal writer which maintains the aggregate running total - // and enforces the per-offering sum invariant (≤ 10_000 bps). - Self::set_holder_share_internal( - &env, - issuer, - namespace, - token, - holder, - share_bps, - None, - Some(nonce), - ) - } - - /// Set a holder's revenue share in basis points for a specific class of an offering. - pub fn set_holder_share_class( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - share_bps: u32, - share_class: ShareClass, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - Self::get_current_issuer( - &env, - issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ) - .ok_or(RevoraError::OfferingNotFound)?; - - Self::set_holder_share_internal( - &env, - issuer, - namespace, - token, - holder, - share_bps, - Some(share_class), - None, - ) - } - /// - /// The dispute ID is deterministic: `sha256(issuer || namespace || token || holder || meta_hash)`. - /// A holder may have at most [`MAX_OPEN_DISPUTES_PER_HOLDER`] open disputes per offering. - /// - /// ### Arguments - /// * `holder` — The address opening the dispute. Must authenticate. - /// * `issuer` — The offering's issuer address. - /// * `namespace` — The offering's namespace symbol. - /// * `token` — The offering's token address. - /// * `meta_hash` — A 32-byte hash pointing to off-chain dispute evidence (e.g. IPFS CID). - /// - /// ### Errors - /// - [`RevoraError::DisputeZeroShare`] if the holder holds zero shares. - /// - [`RevoraError::DisputeAlreadyOpen`] if an identical dispute already exists. - /// - [`RevoraError::MaxDisputesReached`] if the per-holder cap is exceeded. - pub fn open_dispute( - env: Env, - holder: Address, - issuer: Address, - namespace: Symbol, - token: Address, - meta_hash: BytesN<32>, - ) -> Result, RevoraError> { - holder.require_auth(); - Self::require_not_frozen(&env)?; - - let offering_id = OfferingId { issuer, namespace, token }; - - // Reject holders with zero shares (not a participant) - let share = Self::get_holder_share( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ); - if share == 0 { - return Err(RevoraError::DisputeZeroShare); - } - - // Deterministic dispute ID: sha256(issuer || namespace || token || holder || meta_hash) - let mut input = Bytes::new(&env); - input.append(&offering_id.issuer.to_xdr(&env)); - input.append(&offering_id.namespace.to_xdr(&env)); - 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).into(); - - // Reject duplicate - if env.storage().persistent().has(&DataKey2::Dispute(dispute_id.clone())) { - return Err(RevoraError::DisputeAlreadyOpen); - } - - // Enforce spam cap per (offering_id, holder) - let count_key = DataKey2::DisputeCount(offering_id.clone(), holder.clone()); - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - if count >= MAX_OPEN_DISPUTES_PER_HOLDER { - return Err(RevoraError::MaxDisputesReached); - } - - let opened_at = env.ledger().timestamp(); - - let dispute = Dispute { - id: dispute_id.clone(), - holder: holder.clone(), - offering_id: offering_id.clone(), - opened_at, - meta_hash: meta_hash.clone(), - status: DisputeStatus::Open, - }; - - env.storage().persistent().set(&DataKey2::Dispute(dispute_id.clone()), &dispute); - env.storage().persistent().set(&count_key, &(count + 1)); - - env.events().publish( - (Symbol::new(&env, "dispute_open"),), - (dispute_id.clone(), offering_id, holder.clone(), meta_hash), - ); - - Ok(dispute_id) - } - - /// Read an on-chain dispute record by its deterministic ID. - /// - /// Returns `None` if no dispute with the given ID exists. - pub fn get_dispute(env: Env, dispute_id: BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey2::Dispute(dispute_id)) - } - - /// Get a holder's revenue share in basis points for an offering. - pub fn get_holder_share( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - if let Some(cls_vec) = - env.storage().persistent().get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key) - { - let mut total_share = 0; - for (sc, _) in cls_vec.iter() { - let share: u32 = env - .storage() - .persistent() - .get(&DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), sc)) - .unwrap_or(0); - total_share += share; - } - total_share - } else { - env.storage().persistent().get(&DataKey::HolderShare(offering_id, holder)).unwrap_or(0) - } - } - - /// Return the last accepted nonce for a holder's share in an offering. - /// - /// Returns `0` when no `set_holder_share` call has ever been accepted for - /// this `(offering_id, holder)` pair. The next valid nonce for a - /// `set_holder_share` call is any value strictly greater than this. - pub fn get_holder_share_nonce( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> u64 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::HolderShareNonce(offering_id, holder)) - .unwrap_or(0) - } - - /// Get a holder's revenue share in basis points for a specific class of an offering. - pub fn get_holder_share_class( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - share_class: ShareClass, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::HolderShareClass(offering_id, holder, share_class)) - .unwrap_or(0) - } - - /// Set the conversion ratio (in bps) for rolling from one class to another. - pub fn set_class_conversion_ratio( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - from_class: ShareClass, - to_class: ShareClass, - ratio_bps: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - if ratio_bps == 0 { - return Err(RevoraError::InvalidConversionRatio); - } - - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::ClassConversionRatio(offering_id, from_class, to_class); - env.storage().persistent().set(&key, &ratio_bps); - Ok(()) - } - - /// Convert a holder's share from one class to another using the issuer-approved ratio. - pub fn convert_class( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - from_class: ShareClass, - to_class: ShareClass, - amount_bps: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - holder.require_auth(); - - let offering_id = OfferingId { issuer, namespace, token }; - - if let Some(schedule) = - env.storage().persistent().get::<_, crate::vesting::VestingSchedule>( - &crate::vesting::VestingKey::Schedule(holder.clone()), - ) - { - let vested = - crate::vesting::VestingContract::get_vested_amount(env.clone(), holder.clone()) - .unwrap_or(0); - if schedule.total_amount > vested { - return Err(RevoraError::UnvestedConversionBlocked); - } - } - - let ratio_key = DataKey2::ClassConversionRatio( - offering_id.clone(), - from_class.clone(), - to_class.clone(), - ); - let ratio_bps: u32 = - env.storage().persistent().get(&ratio_key).ok_or(RevoraError::ConversionNotApproved)?; - - if ratio_bps == 0 { - return Err(RevoraError::InvalidConversionRatio); - } - - let from_key = - DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), from_class.clone()); - let to_key = - DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), to_class.clone()); - - let from_balance: u32 = env.storage().persistent().get(&from_key).unwrap_or(0); - if from_balance < amount_bps { - return Err(RevoraError::InsufficientClassBalance); - } - - let converted_amount_bps = - ((amount_bps as u64).saturating_mul(ratio_bps as u64) / 10000) as u32; - - let to_balance: u32 = env.storage().persistent().get(&to_key).unwrap_or(0); - - let new_from = from_balance.saturating_sub(amount_bps); - let new_to = to_balance.saturating_add(converted_amount_bps); - - env.storage().persistent().set(&from_key, &new_from); - env.storage().persistent().set(&to_key, &new_to); - - let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - if let Some(mut cls_vec) = - env.storage().persistent().get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key) - { - let mut from_idx = None; - let mut to_idx = None; - for (i, (sc, _)) in cls_vec.iter().enumerate() { - if sc == from_class { - from_idx = Some(i as u32); - } - if sc == to_class { - to_idx = Some(i as u32); - } - } - if let (Some(f_idx), Some(t_idx)) = (from_idx, to_idx) { - let (_, mut f_cfg) = cls_vec.get(f_idx).unwrap(); - let (_, mut t_cfg) = cls_vec.get(t_idx).unwrap(); - - f_cfg.bps = - f_cfg.bps.checked_sub(amount_bps).ok_or(RevoraError::InvalidShareBps)?; - t_cfg.bps = - t_cfg.bps.checked_add(amount_bps).ok_or(RevoraError::InvalidShareBps)?; - - cls_vec.set(f_idx, (from_class.clone(), f_cfg)); - cls_vec.set(t_idx, (to_class.clone(), t_cfg)); - env.storage().persistent().set(&classes_key, &cls_vec); - } - } - - env.events().publish( - (soroban_sdk::symbol_short!("cls_conv"), offering_id, holder), - (from_class, from_balance, new_from, to_class, to_balance, new_to), - ); - - Ok(()) - } - - /// Set or update a holder's jurisdiction tag for an offering. - /// - /// When `effective_ts` is `0` or in the past, the jurisdiction is applied - /// immediately. When `effective_ts` is in the future, a migration event is - /// emitted and a compliance deadline is scheduled. The holder retains full - /// claim access until the deadline (`effective_ts + grace_period_secs`) is - /// reached; after that, claims are blocked if the new jurisdiction is not - /// in the offering's allowlist. - pub fn set_holder_jurisdiction( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - jurisdiction: Symbol, - effective_ts: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage() - .persistent() - .set(&DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), &jurisdiction); - env.events().publish( - (Self::jurisdiction_set_event(&env), issuer, namespace, token), - (EVENT_JUR_SCOPE_HOLDER, holder, jurisdiction), - ); - Ok(()) - } - - /// Read a holder's configured jurisdiction tag for an offering. - pub fn get_holder_jurisdiction( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - Self::get_holder_jurisdiction_internal(&env, &offering_id, &holder) - } - - /// Configure a per-jurisdiction transfer cooldown for an offering. - /// - /// When a cooldown is set, holders whose jurisdiction matches the given - /// `jurisdiction` symbol must wait at least `cooldown_secs` between - /// successive transfers. The cooldown is tracked per `(offering_id, holder)` - /// and is enforced in the transfer path. - /// - /// Set `cooldown_secs` to `0` to disable the cooldown for this jurisdiction. - /// - /// # Auth - /// Requires issuer authentication. - /// - /// # Events - /// Emits [`EVENT_TRANSFER_COOLDOWN_SET`] on success. - pub fn set_transfer_cooldown( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - jurisdiction: Symbol, - cooldown_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - let key = DataKey2::TransferCooldownConfig(offering_id, jurisdiction.clone()); - env.storage().persistent().set(&key, &cooldown_secs); - - env.events().publish( - (EVENT_TRANSFER_COOLDOWN_SET, issuer, namespace, token), - (jurisdiction, cooldown_secs), - ); - Ok(()) - } - - /// Return the per-jurisdiction transfer cooldown (in seconds) for an offering. - /// - /// Returns `0` when no cooldown is configured for the given jurisdiction. - pub fn get_transfer_cooldown( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - jurisdiction: Symbol, - ) -> u64 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::TransferCooldownConfig(offering_id, jurisdiction); - env.storage().persistent().get::(&key).unwrap_or(0) - } - - /// Replace the offering's allowed jurisdiction set. - /// - /// An empty list disables jurisdiction gating for future share writes and snapshot inclusion. - pub fn set_allowed_jurisdictions( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - jurisdictions: Vec, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let normalized = Self::normalize_jurisdictions(&env, jurisdictions); - env.storage().persistent().set(&DataKey2::AllowedJurisdictions(offering_id), &normalized); - env.events().publish( - (Self::jurisdiction_set_event(&env), issuer, namespace, token), - (EVENT_JUR_SCOPE_ALLOW, normalized), - ); - Ok(()) - } - - /// Return the offering's allowed jurisdiction list in stored order. - pub fn get_allowed_jurisdictions( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Vec { - let offering_id = OfferingId { issuer, namespace, token }; - Self::get_allowed_jurisdictions_internal(&env, &offering_id) - } - - /// Set the claim delay in seconds for an offering. - pub fn set_claim_delay( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - delay_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::NotAuthorized); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { issuer: issuer.clone(), namespace, token }; - env.storage().persistent().set(&DataKey::ClaimDelaySecs(offering_id), &delay_secs); - Ok(()) - } - - /// Get the claim delay in seconds for an offering. - pub fn get_claim_delay(env: Env, issuer: Address, namespace: Symbol, token: Address) -> u64 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey::ClaimDelaySecs(offering_id)).unwrap_or(0) - } - - // ── Jurisdiction migration grace period (per-offering) ── - - /// Set the per-offering jurisdiction migration grace period in seconds. - /// - /// Must be between [`MIN_JURISDICTION_GRACE_SECS`] (1 hour) and - /// [`MAX_JURISDICTION_GRACE_SECS`] (90 days) inclusive. The grace period - /// applies to all holders relocating into a potentially disallowed jurisdiction. - pub fn set_jurisdiction_grace_period( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - grace_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::NotAuthorized); - } - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - if grace_secs < MIN_JURISDICTION_GRACE_SECS || grace_secs > MAX_JURISDICTION_GRACE_SECS { - return Err(RevoraError::InvalidAmount); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage() - .persistent() - .set(&DataKey2::JurisdictionGracePeriod(offering_id), &grace_secs); - - env.events().publish((EVENT_JUR_GRACE_SET, issuer, namespace, token), (grace_secs,)); - Ok(()) - } - - /// Read the per-offering jurisdiction migration grace period. - /// Returns [`DEFAULT_JURISDICTION_GRACE_SECS`] (7 days) when not configured. - pub fn get_jurisdiction_grace_period( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u64 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey2::JurisdictionGracePeriod(offering_id)) - .unwrap_or(DEFAULT_JURISDICTION_GRACE_SECS) - } - - /// Read a holder's pending jurisdiction migration state, if any. - pub fn get_jurisdiction_migration( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get::( - &DataKey2::JurisdictionMigration(offering_id, holder), - ) - } - - /// Return the current contract version as a semver triple (MAJOR, MINOR, PATCH) (#23). - pub fn get_version(_env: Env) -> (u32, u32, u32) { - CONTRACT_VERSION - } - - /// Migrate the contract storage to a new version. - /// - /// Reads the currently stored `DeployedVersion` (defaulting to [`CONTRACT_VERSION`] - /// if absent) and rejects the call if: - /// - The contract is not initialized (`NotInitialized`) - /// - The caller is not the admin (`NotAuthorized`) - /// - The contract is frozen (`ContractFrozen`) - /// - `target` equals the stored version (`AlreadyAtTargetVersion`) - /// - `target` is a semver downgrade (`MigrationDowngradeNotAllowed`) - /// - /// On success, persists `target` as the new `DeployedVersion` and emits a - /// `(symbol_short!("migrate"), (from, to))` event. - pub fn migrate_storage( - env: Env, - caller: Address, - target_major: u32, - target_minor: u32, - target_patch: u32, - ) -> Result<(), RevoraError> { - caller.require_auth(); - - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller != admin { - return Err(RevoraError::NotAuthorized); - } - - if env.storage().persistent().get::(&DataKey::Frozen).unwrap_or(false) { - return Err(RevoraError::ContractFrozen); - } - - let from = env - .storage() - .persistent() - .get::(&DataKey::DeployedVersion) - .unwrap_or(CONTRACT_VERSION); - let to = (target_major, target_minor, target_patch); - - assert_semver_forward(from, to)?; - - env.storage().persistent().set(&DataKey::DeployedVersion, &to); - env.events().publish((symbol_short!("migrate"),), (from, to)); - Ok(()) - } - - /// Configure the reporting access window for an offering. If unset, always open. - pub fn set_report_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - start_timestamp: u64, - end_timestamp: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); - let window = AccessWindow { start_timestamp, end_timestamp }; - Self::validate_window(&window)?; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage().persistent().set(&WindowDataKey::Report(offering_id), &window); - env.events().publish( - (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), - (start_timestamp, end_timestamp), - ); - Ok(()) - } - - /// Configure the claiming access window for an offering. If unset, always open. - pub fn set_claim_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - start_timestamp: u64, - end_timestamp: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); - let window = AccessWindow { start_timestamp, end_timestamp }; - Self::validate_window(&window)?; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage().persistent().set(&WindowDataKey::Claim(offering_id), &window); - env.events().publish( - (EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), - (start_timestamp, end_timestamp), - ); - Ok(()) - } - - /// Read configured reporting window (if any) for an offering. - pub fn get_report_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&WindowDataKey::Report(offering_id)) - } - - /// Read configured claiming window (if any) for an offering. - pub fn get_claim_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&WindowDataKey::Claim(offering_id)) - } - - /// Configure the redemption window for an offering. If unset, always open. - /// Rejects the request if a stored redemption window overlaps with the new one. - pub fn set_redemption_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - start_timestamp: u64, - end_timestamp: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); - let new_window = AccessWindow { start_timestamp, end_timestamp }; - Self::validate_window(&new_window)?; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Reject if the new window overlaps with the stored window - let existing: Option = - env.storage().persistent().get(&WindowDataKey::Redemption(offering_id.clone())); - if let Some(existing) = existing { - if start_timestamp < existing.end_timestamp && existing.start_timestamp < end_timestamp - { - return Err(RevoraError::RedemptionWindowOverlap); - } - } - - env.storage().persistent().set(&WindowDataKey::Redemption(offering_id), &new_window); - env.events().publish( - (EVENT_REDEMPTION_WINDOW_SET, issuer, namespace, token), - (start_timestamp, end_timestamp), - ); - Ok(()) - } - - /// Read configured redemption window (if any) for an offering. - pub fn get_redemption_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&WindowDataKey::Redemption(offering_id)) - } - pub fn claim( - env: Env, - holder: Address, - issuer: Address, - namespace: Symbol, - token: Address, - max_periods: u32, - ) -> Result { - holder.require_auth(); - - let offering_id = OfferingId { issuer, namespace, token }; - - // Halt claims while a critical dispute is active for this offering - if Self::is_dispute_freeze_active(&env, &offering_id) { - return Err(RevoraError::DisputeFreezeActive); - } - - // Initial blacklist check for early fail-fast - if Self::is_blacklisted( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ) { - return Err(RevoraError::HolderBlacklisted); - } - - // Jurisdiction migration deadline enforcement - Self::require_jurisdiction_migration_not_expired( - &env, - &offering_id, - &holder, - symbol_short!("claim"), - )?; - - let share_bps = Self::get_holder_share( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ); - if share_bps == 0 { - return Err(RevoraError::NoPendingClaims); - } - - Self::require_claim_window_open(&env, &offering_id)?; - - let count_key = DataKey::PeriodCount(offering_id.clone()); - let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); - let start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - - if start_idx >= period_count { - return Err(RevoraError::NoPendingClaims); - } - - let effective_max = if max_periods == 0 || max_periods > MAX_CLAIM_PERIODS { - MAX_CLAIM_PERIODS - } else { - max_periods - }; - let end_idx = core::cmp::min(start_idx + effective_max, period_count); - - let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); - let delay_secs: u64 = env.storage().persistent().get(&delay_key).unwrap_or(0); - let now = env.ledger().timestamp(); - - let mut total_payout: i128 = 0; - let mut claimed_periods = Vec::new(&env); - let mut last_claimed_idx = start_idx; - let mut previous_period_id: Option = None; - - for i in start_idx..end_idx { - // Enforce blacklist/whitelist decisiveness during partial claim sequences - // This ensures that if a holder becomes blacklisted mid-sequence, subsequent - // periods in the batch are not claimed - if Self::is_blacklisted( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ) { - break; - } - - let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap(); - - // Enforce index monotonicity: ensure periods are claimed in the exact - // order they were deposited in PeriodEntry - if let Some(prev_id) = previous_period_id { - if period_id <= prev_id { - // PeriodEntry order violated - this should never happen with correct - // deposit_revenue implementation, but we defensively check - return Err(RevoraError::NoPendingClaims); - } - } - previous_period_id = Some(period_id); - - let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - let deposit_time: u64 = env.storage().persistent().get(&time_key).unwrap_or(0); - if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { - break; - } - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap(); - let decimals = Self::get_payment_token_decimals( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - let normalized = Self::normalize_amount(revenue, decimals); - let payout = normalized * (share_bps as i128) / 10_000; - total_payout += payout; - claimed_periods.push_back(period_id); - last_claimed_idx = i + 1; - } - - if last_claimed_idx == start_idx { - return Err(RevoraError::ClaimDelayNotElapsed); - } - - if total_payout > 0 { - let bucket = crate::tax_bucket::rollover_distribution( - &env, - &offering_id, - &holder, - total_payout, - previous_period_id - .expect("rollover_distribution called with zero claimed periods; total_payout > 0 invariant broken"), - now, - ); - // Update the per-fiscal-year tax accumulator for year-end queries. - let fiscal_start_month = env - .storage() - .persistent() - .get::<_, u32>(&DataKey2::FiscalYearStartMonth(offering_id.clone())) - .unwrap_or(crate::tax_bucket::DEFAULT_FISCAL_START_MONTH); - let fy = crate::tax_bucket::fiscal_year_from_ts(now, fiscal_start_month); - crate::tax_bucket::update_tax_year_accumulator( - &env, - &offering_id, - &holder, - fy, - 0, // ordinary_income — reserved; currently always 0 - bucket.capital_gains, - bucket.return_of_capital, - ); - } - - // Transfer only if there is a positive payout - if total_payout > 0 { - let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id) - .ok_or(RevoraError::PaymentTokenMismatch)?; - let contract_addr = env.current_contract_address(); - if token::Client::new(&env, &payment_token) - .try_transfer(&contract_addr, &holder, &total_payout) - .is_err() - { - return Err(RevoraError::TransferFailed); - } - } - // Advance claim index only for periods actually claimed (respecting delay) - env.storage().persistent().set(&idx_key, &last_claimed_idx); - - let anchor_key = DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); - if let Some(a) = env.storage().persistent().get::(&anchor_key) { - if start_idx <= a.end_idx && a.end_idx < last_claimed_idx { - total_payout = total_payout.saturating_add(a.claimable_sum); - env.storage().persistent().remove(&anchor_key); - } - } - - // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) - Self::emit_v2_event( - &env, - ( - EVENT_CLAIM_V2, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (holder.clone(), total_payout, claimed_periods.clone()), - ); - env.events().publish( - ( - EVENT_CLAIM_V2, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (holder, total_payout, claimed_periods), - ); - env.events().publish( - ( - EVENT_INDEXED_V2, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_CLAIM, - issuer: offering_id.issuer, - namespace: offering_id.namespace, - token: offering_id.token, - period_id: 0, - }, - ), - (total_payout,), - ); - - Ok(total_payout) - } - - /// Read-only: check whether a proposal has reached quorum. - /// Returns `true` if total voted weight (sum of voter_weight_bps) >= quorum_bps. - /// Returns `false` (no panic) for empty votes (treated as zero). - pub fn check_quorum(env: Env, proposal_id: u32) -> bool { - let proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey2::MultisigProposal(proposal_id)) - .expect("Proposal not found"); - Self::check_quorum_inner(&env, &proposal) - } - - /// Read-only: get a proposal by id. - pub fn get_proposal(env: Env, proposal_id: u32) -> Option { - env.storage().persistent().get(&DataKey2::MultisigProposal(proposal_id)) - } - - /// Open a formal on-chain dispute against an offering. - /// - /// The dispute ID is deterministic: `sha256(issuer || namespace || token || holder || meta_hash)`. - /// A holder may have at most [`MAX_OPEN_DISPUTES_PER_HOLDER`] open disputes per offering. - /// - /// When `severity` is [`DisputeSeverity::Critical`] the offering's claims are frozen - /// (blocked) until the dispute is resolved or rejected via [`resolve_dispute`]. - /// - /// ### Arguments - /// * `holder` — The address opening the dispute. Must authenticate. - /// * `issuer` — The offering's issuer address. - /// * `namespace` — The offering's namespace symbol. - /// * `token` — The offering's token address. - /// * `severity` — [`DisputeSeverity::Critical`] halts claims until resolved. - /// * `meta_hash` — A 32-byte hash pointing to off-chain dispute evidence (e.g. IPFS CID). - /// - /// ### Errors - /// - [`RevoraError::DisputeZeroShare`] if the holder holds zero shares. - /// - [`RevoraError::DisputeAlreadyOpen`] if an identical dispute already exists. - /// - [`RevoraError::MaxDisputesReached`] if the per-holder cap is exceeded. - pub fn open_dispute( - env: Env, - holder: Address, - issuer: Address, - namespace: Symbol, - token: Address, - severity: DisputeSeverity, - meta_hash: BytesN<32>, - ) -> Result, RevoraError> { - holder.require_auth(); - Self::require_not_frozen(&env)?; - - let offering_id = OfferingId { issuer, namespace, token }; - - // Reject holders with zero shares (not a participant) - let share = Self::get_holder_share( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ); - if share == 0 { - return Err(RevoraError::DisputeZeroShare); - } - - // Deterministic dispute ID: sha256(issuer || namespace || token || holder || meta_hash) - let mut input = Bytes::new(&env); - input.append(&offering_id.issuer.to_xdr(&env)); - input.append(&offering_id.namespace.to_xdr(&env)); - 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).into(); - - // Reject duplicate - if env.storage().persistent().has(&DataKey2::Dispute(dispute_id.clone())) { - return Err(RevoraError::DisputeAlreadyOpen); - } - - // Enforce spam cap per (offering_id, holder) - let count_key = DataKey2::DisputeCount(offering_id.clone(), holder.clone()); - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - if count >= MAX_OPEN_DISPUTES_PER_HOLDER { - return Err(RevoraError::MaxDisputesReached); - } - - let opened_at = env.ledger().timestamp(); - - let dispute = Dispute { - id: dispute_id.clone(), - holder: holder.clone(), - offering_id: offering_id.clone(), - opened_at, - severity: severity.clone(), - meta_hash: meta_hash.clone(), - status: DisputeStatus::Open, - }; - - env.storage().persistent().set(&DataKey2::Dispute(dispute_id.clone()), &dispute); - env.storage().persistent().set(&count_key, &(count + 1)); - - // Track critical dispute for O(1) freeze lookups - if severity == DisputeSeverity::Critical { - let crit_key = DataKey2::CriticalDisputeCount(offering_id.clone()); - let crit_count: u32 = env.storage().persistent().get(&crit_key).unwrap_or(0); - env.storage().persistent().set(&crit_key, &(crit_count + 1)); - if crit_count == 0 { - env.events().publish( - (EVENT_DISPUTE_FREEZE_ON,), - (offering_id.clone(), holder.clone(), dispute_id.clone()), - ); - } - } - - env.events().publish( - (Symbol::new(&env, "dispute_open"),), - (dispute_id.clone(), offering_id, holder.clone(), meta_hash, severity), - ); - - Ok(dispute_id) - } - - /// Read an on-chain dispute record by its deterministic ID. - /// - /// Returns `None` if no dispute with the given ID exists. - pub fn get_dispute(env: Env, dispute_id: BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey2::Dispute(dispute_id)) - } - - /// 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 { - let crit_key = DataKey2::CriticalDisputeCount(offering_id.clone()); - env.storage().persistent().get::(&crit_key).unwrap_or(0) > 0 - } - - /// Resolve or reject an open dispute. - /// - /// Only the issuer of the disputed offering may call this. - /// When the last critical dispute for an offering transitions from `Open` to - /// `Resolved` / `Rejected`, the dispute freeze is lifted and a `dispute_freeze_off` - /// event is emitted. - /// - /// ### Arguments - /// * `caller` — The address resolving the dispute. Must be the offering's issuer. - /// * `dispute_id` — The deterministic dispute ID to resolve. - /// * `resolution` — The target status: [`DisputeStatus::Resolved`] or [`DisputeStatus::Rejected`]. - /// - /// ### Errors - /// - [`RevoraError::NotDisputeIssuer`] if the caller is not the dispute's offering issuer. - /// - [`RevoraError::DisputeNotFound`] if no dispute with the given ID exists. - /// - [`RevoraError::DisputeAlreadyResolved`] if the dispute is not `Open`. - pub fn resolve_dispute( - env: Env, - caller: Address, - dispute_id: BytesN<32>, - resolution: DisputeStatus, - ) -> Result<(), RevoraError> { - caller.require_auth(); - - let mut dispute: Dispute = env - .storage() - .persistent() - .get(&DataKey2::Dispute(dispute_id.clone())) - .ok_or(RevoraError::DisputeNotFound)?; - - if dispute.status != DisputeStatus::Open { - return Err(RevoraError::DisputeAlreadyResolved); - } - if caller != dispute.offering_id.issuer { - return Err(RevoraError::NotDisputeIssuer); - } - - let was_critical = dispute.severity == DisputeSeverity::Critical; - dispute.status = resolution.clone(); - env.storage().persistent().set(&DataKey2::Dispute(dispute_id), &dispute); - - // Decrement critical dispute count and emit freeze_off when it hits zero - if was_critical { - let crit_key = DataKey2::CriticalDisputeCount(dispute.offering_id.clone()); - let crit_count: u32 = env.storage().persistent().get(&crit_key).unwrap_or(0); - if crit_count > 0 { - let new_count = crit_count - 1; - env.storage().persistent().set(&crit_key, &new_count); - if new_count == 0 { - env.events().publish( - (EVENT_DISPUTE_FREEZE_OFF,), - (dispute.offering_id, dispute.holder, dispute.id), - ); - } - } - } - - Ok(()) - } -} - -// ── Holder shares, claims, admin, governance, and utility methods ────────── -// Plain impl block — excluded from the ABI spec to keep spec XDR within limit. -impl RevoraRevenueShare { - /// - /// The share determines the percentage of a period's revenue the holder can claim. - /// - /// ### Parameters - /// - `issuer`: The offering issuer. Must provide authentication. - /// - `token`: The token representing the offering. - /// - `holder`: The address of the token holder. - /// - `share_bps`: The holder's share in basis points (0-10000). - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::OfferingNotFound)` if the offering is not found. - /// - `Err(RevoraError::InvalidShareBps)` if `share_bps` exceeds 10000. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// Set a holder's revenue share (in basis points) for an offering. - fn set_holder_share_full( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - share_bps: u32, - share_class: Option, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - // Verify offering exists and issuer is current - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - Self::require_not_frozen(&env)?; - issuer.require_auth(); - let window = AccessWindow { start_timestamp, end_timestamp }; - Self::validate_window(&window)?; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage().persistent().set(&WindowDataKey::Report(offering_id), &window); - env.events().publish( - (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), - (start_timestamp, end_timestamp), - ); - Ok(()) - } - - // ── Meta-authorization, claims, windows, and query methods ─────────────────── - - /// Register an ed25519 public key for a signer address. - /// The signer must authorize this binding. - pub fn register_meta_signer_key( - env: Env, - signer: Address, - public_key: BytesN<32>, - ) -> Result<(), RevoraError> { - signer.require_auth(); - env.storage().persistent().set(&MetaDataKey::SignerKey(signer.clone()), &public_key); - Self::emit_v2_event(&env, (EVENT_META_SIGNER_SET, signer), public_key); - Ok(()) - } - - /// Configure the claiming access window for an offering. If unset, always open. - /// Read configured reporting window (if any) for an offering. - pub fn get_report_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&WindowDataKey::Report(offering_id)) - } - - /// Read configured claiming window (if any) for an offering. - pub fn get_claim_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&WindowDataKey::Claim(offering_id)) - } - - /// @notice Claim accumulated revenue for a holder across multiple unclaimed periods. - /// @dev Payouts are calculated based on the holder's share at the time of claim. - /// Capped at MAX_CLAIM_PERIODS (50) per transaction for gas safety. - /// This function enforces strict security invariants for multi-period claims. - /// - /// @param holder The address of the token holder. Must provide authentication. - /// @param issuer The address of the offering issuer. - /// @param namespace A symbol identifying the namespace. - /// @param token The token representing the offering. - /// @param max_periods Maximum number of periods to process (0 = MAX_CLAIM_PERIODS). - /// - /// @return Ok(i128) The total payout amount on success. - /// @return Err(RevoraError::HolderBlacklisted) if the holder is blacklisted. - /// @return Err(RevoraError::NoPendingClaims) if no share is set or all periods are claimed. - /// @return Err(RevoraError::ClaimDelayNotElapsed) if the next period is still within the claim delay window. - /// - /// # Idempotency and Safety Invariants - /// - /// This function provides the following hard guarantees: - /// - /// 1. **No double-pay**: `LastClaimedIdx` is written to storage only *after* the token - /// transfer succeeds. If the transfer panics (e.g. insufficient contract balance), - /// the index is not advanced and the holder may retry. Soroban's atomic transaction - /// model ensures partial state is never committed. - /// - /// 2. **Index advances only on processed periods**: The index is set to - /// `last_claimed_idx`, which reflects only periods that passed the delay check. - /// Periods blocked by `ClaimDelaySecs` are not counted; the function returns - /// `ClaimDelayNotElapsed` without writing any state. - /// - /// 3. **Zero-payout periods advance the index**: A period with `revenue = 0` (or - /// where `revenue * share_bps / 10_000 == 0` due to truncation) still advances - /// `LastClaimedIdx`. No transfer is issued for zero amounts. This prevents - /// permanently stuck indices on dust periods. - /// - /// 4. **Exhausted state returns `NoPendingClaims`**: Once `LastClaimedIdx >= PeriodCount`, - /// every subsequent call returns `Err(NoPendingClaims)` without touching storage. - /// Callers may safely retry without risk of side effects. - /// - /// 5. **Per-holder isolation**: Each holder's `LastClaimedIdx` is keyed by - /// `(offering_id, holder)`. One holder's claim progress never affects another's. - /// - /// 6. **Auth checked first**: `holder.require_auth()` is the first operation. - /// All subsequent checks (blacklist, share, period count) are read-only and - /// produce no state changes on failure. - /// - /// 7. **Blacklist/whitelist decisiveness during partial sequences**: The blacklist - /// check is performed INSIDE the period iteration loop. If a holder becomes - /// blacklisted mid-sequence during a multi-period claim, the loop breaks immediately - /// and no subsequent periods in the batch are claimed. The index is only advanced - /// for periods successfully processed before the blacklist took effect. This ensures - /// blacklist/whitelist decisions remain decisive even during partial claim sequences. - /// - /// 8. **Index monotonicity enforced**: The function validates that period IDs are - /// strictly increasing as they are retrieved from `PeriodEntry`. This ensures - /// `LastClaimedIdx` advances only in ways that match the deposited period order, - /// preventing any possibility of skipping periods or claiming out of order. - /// - /// # Arguments - /// * `holder` - The address of the holder claiming revenue. - /// * `issuer` - The address of the offering issuer. - /// * `namespace` - A symbol identifying the namespace. - /// * `token` - The address of the token. - /// * `max_periods` - The maximum number of periods to claim in this call. - /// - /// # Events - - /// Claim pending share payouts for a holder on an offering. - pub fn claim( - env: Env, - holder: Address, - issuer: Address, - namespace: Symbol, - token: Address, - max_periods: u32, - ) -> Result { - holder.require_auth(); - - let offering_id = OfferingId { issuer, namespace, token }; - - // Halt claims while a critical dispute is active for this offering - if Self::is_dispute_freeze_active(&env, &offering_id) { - return Err(RevoraError::DisputeFreezeActive); - } - - // Initial blacklist and freeze checks for early fail-fast - if Self::is_blacklisted( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ) { - return Err(RevoraError::HolderBlacklisted); - } - Self::require_holder_not_frozen(&env, &offering_id, &holder)?; - - // Jurisdiction migration deadline enforcement - Self::require_jurisdiction_migration_not_expired( - &env, - &offering_id, - &holder, - symbol_short!("claim"), - )?; - - let share_bps = Self::get_holder_share( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ); - if share_bps == 0 { - return Err(RevoraError::NoPendingClaims); - } - - Self::require_claim_window_open(&env, &offering_id)?; - - let count_key = DataKey::PeriodCount(offering_id.clone()); - let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); - let start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - - if start_idx >= period_count { - return Err(RevoraError::NoPendingClaims); - } - - let effective_max = if max_periods == 0 || max_periods > MAX_CLAIM_PERIODS { - MAX_CLAIM_PERIODS - } else { - max_periods - }; - let end_idx = core::cmp::min(start_idx + effective_max, period_count); - - let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); - let delay_secs: u64 = env.storage().persistent().get(&delay_key).unwrap_or(0); - let now = env.ledger().timestamp(); - - let mut total_payout: i128 = 0; - let mut claimed_periods = Vec::new(&env); - let mut last_claimed_idx = start_idx; - let mut previous_period_id: Option = None; - - for i in start_idx..end_idx { - // Enforce blacklist/whitelist and freeze decisiveness during partial claim sequences - // This ensures that if a holder becomes blacklisted or frozen mid-sequence, subsequent - // periods in the batch are not claimed - if Self::is_blacklisted( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ) { - break; - } - if Self::is_frozen(&env, &offering_id, &holder) { - break; - } - - let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap(); - - // Enforce index monotonicity: ensure periods are claimed in the exact - // order they were deposited in PeriodEntry - if let Some(prev_id) = previous_period_id { - if period_id <= prev_id { - // PeriodEntry order violated - this should never happen with correct - // deposit_revenue implementation, but we defensively check - return Err(RevoraError::NoPendingClaims); - } - } - previous_period_id = Some(period_id); - - let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - let deposit_time: u64 = env.storage().persistent().get(&time_key).unwrap_or(0); - if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { - break; - } - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap(); - let decimals = Self::get_payment_token_decimals( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - let normalized = Self::normalize_amount(revenue, decimals); - let payout = normalized * (share_bps as i128) / 10_000; - total_payout += payout; - claimed_periods.push_back(period_id); - last_claimed_idx = i + 1; - } - - if last_claimed_idx == start_idx { - return Err(RevoraError::ClaimDelayNotElapsed); - } - - // Transfer only if there is a positive payout - if total_payout > 0 { - let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id) - .ok_or(RevoraError::PaymentTokenMismatch)?; - let contract_addr = env.current_contract_address(); - if token::Client::new(&env, &payment_token) - .try_transfer(&contract_addr, &holder, &total_payout) - .is_err() - { - return Err(RevoraError::TransferFailed); - } - } - - // Advance claim index only for periods actually claimed (respecting delay) - env.storage().persistent().set(&idx_key, &last_claimed_idx); - - let anchor_key2 = DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); - if let Some(a2) = env.storage().persistent().get::(&anchor_key2) { - if start_idx <= a2.end_idx && a2.end_idx < last_claimed_idx { - total_payout = total_payout.saturating_add(a2.claimable_sum); - env.storage().persistent().remove(&anchor_key2); - } - } - - // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) - Self::emit_v2_event( - &env, - ( - EVENT_CLAIM_V2, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (holder.clone(), total_payout, claimed_periods.clone()), - ); - env.events().publish( - ( - EVENT_CLAIM_V2, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (holder, total_payout, claimed_periods), - ); - env.events().publish( - ( - EVENT_INDEXED_V2, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_CLAIM, - issuer: offering_id.issuer, - namespace: offering_id.namespace, - token: offering_id.token, - period_id: 0, - }, - ), - (total_payout,), - ); - - Ok(total_payout) - } - - /// Seal a reporting period so that no further `report_revenue` overrides are accepted. - /// - /// Once closed, the period's deposited revenue remains claimable by holders; only - /// issuer-initiated corrections via `override_existing=true` are blocked. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. - /// - /// ### Errors - /// - `OfferingNotFound` – offering does not exist or caller is not the current issuer. - /// - `InvalidPeriodId` – `period_id` is 0. - /// - `PeriodAlreadyClosed` – period has already been sealed. - /// - `ContractFrozen` / `ContractPaused` – contract is not operational. - pub fn close_period( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - period_id: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Verify offering exists and caller is the current issuer. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - // If dual-signature mode is enabled for this offering, the single-sig - // `close_period` path is not available — callers must use `close_period_dual_sig`. - if env - .storage() - .persistent() - .get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())) - .unwrap_or(false) - { - return Err(RevoraError::DualSigNotConfigured); - } - - let closed_key = DataKey2::ClosedPeriod(offering_id, period_id); - if env.storage().persistent().has(&closed_key) { - return Err(RevoraError::PeriodAlreadyClosed); - } - - Self::assert_close_period_invariants(&env, &offering_id)?; - - let closed_at = env.ledger().timestamp(); - env.storage().persistent().set(&closed_key, &closed_at); - - // Track the most recently closed period timestamp for dispute window enforcement. - env.storage() - .persistent() - .set(&DataKey2::LastClosedPeriodTimestamp(offering_id), &closed_at); - - env.events() - .publish((EVENT_PERIOD_CLOSED, issuer, namespace, token), (period_id, closed_at)); - - // Compute and persist the canonical per-class payout order (#523). - // Done after the period is sealed so the storage write is monotonic - // and the emitted pay order matches the on-chain sealed state. - Self::record_and_emit_pay_order(&env, &offering_id, period_id); - - Ok(()) - } - - fn assert_close_period_invariants( - env: &Env, - offering_id: &OfferingId, - ) -> Result<(), RevoraError> { - let total_share_bps: u32 = env - .storage() - .persistent() - .get(&DataKey::HolderShareTotal(offering_id.clone())) - .unwrap_or(0); - - let total_shares_issued: i128 = env - .storage() - .persistent() - .get(&DataKey2::TotalSharesIssued(offering_id.clone())) - .unwrap_or(0); - - if total_share_bps > 10_000 - || total_shares_issued < 0 - || total_shares_issued > 10_000 - || total_share_bps as i128 != total_shares_issued - { - return Err(RevoraError::CloseAbortInvariantsViolated); - } - Ok(()) - } - - /// Return `true` if the given period has been sealed by `close_period`. - pub fn is_period_closed( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - period_id: u64, - ) -> bool { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().has(&DataKey2::ClosedPeriod(offering_id, period_id)) - } - - // ── Per-class dividend priority ordering (#523) ──────────────────────── - - /// Compute the canonical class payout order for an offering (#523). - /// - /// Reads the offering's registered `Vec<(ShareClass, ClassConfig)>` and the - /// per-class priority index stored under `DataKey2::ClassPriority`. Classes - /// are sorted ascending by `(priority_index, share_class.to_xdr().bytes)`; - /// ties on priority are broken canonically by XDR-serialized bytes of the - /// `ShareClass`, which gives a stable, deterministic ordering identical - /// across reruns and across dual-sig / single-sig close paths. - /// - /// Classes without an explicit priority index resolve to `DEFAULT_CLASS_PRIORITY = 0`. - /// Returns an empty `Vec` when the offering has no classes registered. - fn resolve_class_pay_order(env: &Env, offering_id: &OfferingId) -> Vec { - let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - let classes_opt: Option> = - env.storage().persistent().get(&classes_key); - - let classes = match classes_opt { - Some(c) if !c.is_empty() => c, - _ => return Vec::new(env), - }; - - // Build the keyed list: (priority, xdr_bytes, ShareClass). - let mut keyed: Vec<(u32, Bytes, ShareClass)> = Vec::new(env); - for entry in classes.iter() { - let sc = entry.0.clone(); - let priority: u32 = env - .storage() - .persistent() - .get::(&DataKey2::ClassPriority(offering_id.clone(), sc.clone())) - .unwrap_or(DEFAULT_CLASS_PRIORITY); - let xdr_bytes: Bytes = sc.to_xdr(env); - keyed.push_back((priority, xdr_bytes, sc)); - } - - // Deterministic ascending sort by (priority, xdr_bytes). - // Implemented as an in-place bubble sort for `soroban_sdk::Vec` (which - // lacks `sort_by`). n is bounded by the per-offering class count, which - // the contract keeps small via ClassConfig. - let n = keyed.len(); - if n > 1 { - let mut i: u32 = 0; - while i < n.saturating_sub(1) { - let mut j: u32 = 0; - let stop = n.saturating_sub(1).saturating_sub(i); - while j < stop { - let cur = keyed.get(j).expect("index valid"); - let nxt = keyed.get(j.saturating_add(1)).expect("index valid"); - let should_swap = match cur.0.cmp(&nxt.0) { - core::cmp::Ordering::Greater => true, - core::cmp::Ordering::Equal => { - cur.1.cmp(&nxt.1) == core::cmp::Ordering::Greater - } - core::cmp::Ordering::Less => false, - }; - if should_swap { - keyed.set(j, nxt); - keyed.set(j.saturating_add(1), cur); - } - j = j.saturating_add(1); - } - i = i.saturating_add(1); - } - } - - let mut out: Vec = Vec::new(env); - for entry in keyed.iter() { - out.push_back(entry.2.clone()); - } - out - } - - /// Persist the resolved pay order and emit the `EVENT_CLASS_PAY_ORDER` event. - /// Called from both single-sig and dual-sig `close_period` paths after - /// existing validation/sealing logic so the canonical ordering is recorded - /// once per closed period and downstream auditors/indexers see a stable - /// per-period distribution order. - /// - /// Always emits, including when no classes are registered for the offering - /// — a `Vec::new()` payload is the documented fallback for legacy or - /// classless offerings. Indexers should treat empty orders as the - /// pre-deployment / no-class baseline. - fn record_and_emit_pay_order(env: &Env, offering_id: &OfferingId, period_id: u64) { - let ordered = Self::resolve_class_pay_order(env, offering_id); - env.storage() - .persistent() - .set(&DataKey2::ClassPayOrder(offering_id.clone(), period_id), &ordered); - env.events().publish( ( - EVENT_CLASS_PAY_ORDER, - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ), - (period_id, ordered), - ); - } - - // ── Close-of-period preflight (#563) ────────────────────────────────────────── - // - // Pure (no side effects) helper that computes the per-period preview - // returned by `preflight_close_period`. Reads from storage and never writes - // or emits events. The write-path `close_period` / - // `close_period_dual_sig` keep their inline pay-order resolver for tight - // coupling — callers verify parity by computing identical `class_pay_order` - // values (the very property the existing single-sig vs. dual-sig test - // suite asserts). - // - // Math: `compute_share(period_revenue, share_bps, rounding_mode)`, the - // same math the closest analogue `simulate_distribution` applies for the - // no-classes path. Blacklisted holders are silently skipped. Holders - // with zero `share_bps` are emitted with `normalized_payout = 0` so - // callers can detect them. Total is a saturating sum so it can never - // exceed `period_revenue`. - fn compute_period_close_preview( - env: &Env, - offering_id: &OfferingId, - period_id: u64, - holders: Vec
, - ) -> PreflightCloseResult { - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - let period_revenue: i128 = - env.storage().persistent().get::(&rev_key).unwrap_or(0); - - let class_pay_order = Self::resolve_class_pay_order(env, offering_id); - - let mode: RoundingMode = Self::get_rounding_mode( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - - let n = holders.len(); - let mut payout_rows: std::vec::Vec<(u32, u32, Address, i128)> = std::vec::Vec::new(); - let mut total: i128 = 0; - - for i in 0..n { - let holder = holders.get(i).unwrap(); - - // Blacklist precedence rule: a blacklisted address is ALWAYS - // excluded from payouts, regardless of any whitelist or - // investor registration. Dropped silently from the preview. - if Self::is_blacklisted( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ) { - continue; - } - - let share_bps: u32 = env - .storage() - .persistent() - .get::(&DataKey::HolderShare(offering_id.clone(), holder.clone())) - .unwrap_or(0); - - // Saturate share_bps to 10_000 — matches simulate_distribution's - // defence against malformed shares. With 0 share_bps the entry - // is still emitted (with payout 0) so callers can detect it. - let bounded_bps: u32 = if share_bps > 10_000 { 10_000 } else { share_bps }; - let normalized_payout = - Self::compute_share(env.clone(), period_revenue, bounded_bps, mode); - - total = total.saturating_add(normalized_payout); - payout_rows.push((bounded_bps, share_bps, holder.clone(), normalized_payout)); - } - - payout_rows.sort_by(|a, b| match b.0.cmp(&a.0) { - core::cmp::Ordering::Equal => a.2.cmp(&b.2), - other => other, - }); - - let mut payouts: Vec = Vec::new(env); - for (bounded_bps, share_bps, holder, normalized_payout) in payout_rows { - let _ = bounded_bps; - payouts.push_back(DistributionEntry { - holder, - share_bps, - normalized_payout, - }); - } - - PreflightCloseResult { - period_id, - period_revenue, - class_pay_order, - payouts, - total_distributed: total, - } - } - - /// Set the dividend priority index for a registered class on an offering (#523). - /// - /// Lower `priority_index` values resolve to earlier payout positions under - /// `close_period`. The configured class is recorded and an - /// `EVENT_CLASS_PRIORITY_SET` event is emitted so indexers and dashboards - /// can track priority changes. - /// - /// ### Auth - /// Requires issuer-quorum authentication matching the contract-wide - /// `Issuers.quorum` policy used by `set_holder_share` and similar mutations. - /// A priority change effectively reorders how distributions are paid out, so - /// we treat it as governance-equivalent to a holder-share mutation. - /// - /// ### Errors - /// - [`RevoraError::OfferingNotFound`] if the offering does not exist or the - /// caller is not the current issuer. - /// - [`RevoraError::InvalidShareClass`] if `share_class` is not a registered - /// class on the offering (i.e. absent from `DataKey2::OfferingClasses`). - /// - [`RevoraError::ContractFrozen`] / [`RevoraError::ContractPaused`] when - /// the contract is not operational. - #[allow(clippy::too_many_arguments)] - pub fn set_class_priority( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - share_class: ShareClass, - priority_index: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - // Issuer-quorum requires the primary signer and the configured number - // of co-signers to have authorized. This matches `set_holder_share` - // and other governance-equivalent mutations. - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Verify the share_class is registered for this offering. Rejecting - // unregistered classes prevents storage pollution and Denial-of-Service - // via arbitrarily large priority-index entries. - let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - let classes_opt: Option> = - env.storage().persistent().get(&classes_key); - let registered = classes_opt - .as_ref() - .map(|v| v.iter().any(|(sc, _)| sc == share_class)) - .unwrap_or(false); - if !registered { - return Err(RevoraError::InvalidShareClass); - } - - env.storage().persistent().set( - &DataKey2::ClassPriority(offering_id.clone(), share_class.clone()), - &priority_index, - ); - - env.events().publish( - (EVENT_CLASS_PRIORITY_SET, issuer, namespace, token, share_class), - priority_index, - ); - - Ok(()) - } - - /// Read the dividend priority index for a class on an offering (#523). - /// Returns `DEFAULT_CLASS_PRIORITY = 0` when no explicit priority has been set. - pub fn get_class_priority( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - share_class: ShareClass, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey2::ClassPriority(offering_id, share_class)) - .unwrap_or(DEFAULT_CLASS_PRIORITY) - } - - /// Read the canonical class payout order resolved at `close_period` time (#523). - /// Returns an empty `Vec` if the period was never closed via the - /// updated `close_period` / `close_period_dual_sig` implementation. - pub fn get_class_pay_order( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - period_id: u64, - ) -> Vec { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::>(&DataKey2::ClassPayOrder(offering_id, period_id)) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Enable or disable dual-signature close-of-period mode for an offering (#565). - /// - /// When enabled, `close_period` will reject with `DualSigNotConfigured` and the - /// issuer must use `close_period_dual_sig` instead, which requires two distinct - /// authorized signers. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. - /// - /// ### Errors - /// - `OfferingNotFound` – offering does not exist or caller is not the current issuer. - /// - `ContractFrozen` / `ContractPaused` – contract is not operational. - pub fn set_dual_sig_config( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - enabled: bool, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Verify offering exists and caller is the current issuer. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + EVENT_REV_INIT_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, blacklist.clone()), + ); - env.storage().persistent().set(&DataKey2::DualSigEnabled(offering_id), &enabled); - - env.events().publish((symbol_short!("dual_cfg"), issuer, namespace, token), (enabled,)); - Ok(()) - } - - /// Close a period using dual-signature authorization. - /// - /// For high-value periods, this function requires two distinct signers to - /// authorize the close. Both signers must be valid issuers of the offering - /// (the primary issuer or a co-issuer). - /// - /// ### Auth - /// Requires both `sig_a.require_auth()` and `sig_b.require_auth()`. - /// - /// ### Errors - /// - `DualSigSameSigner` – `sig_a` and `sig_b` are the same address. - /// - `DualSigNotConfigured` – dual-signature mode has not been enabled for this offering. - /// - `OfferingNotFound` – offering does not exist or a signer is not a valid issuer. - /// - `InvalidPeriodId` – `period_id` is 0. - /// - `PeriodAlreadyClosed` – period has already been sealed. - /// - `ContractFrozen` / `ContractPaused` – contract is not operational. - pub fn close_period_dual_sig( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - period_id: u64, - sig_a: Address, - sig_b: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - sig_a.require_auth(); - sig_b.require_auth(); - - // Both signers must be distinct. - if sig_a == sig_b { - return Err(RevoraError::DualSigSameSigner); - } + env.events().publish( + ( + EVENT_REVENUE_REPORT_INITIAL_ASSET, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payout_asset.clone(), amount, period_id, blacklist.clone()), + ); + } + } + } + + env.events().publish( + ( + EVENT_REVENUE_REPORTED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, blacklist.clone()), + ); + Self::emit_v2_and_v3( + &env, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_REP, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_REV_REP, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + _reserved: 0, + }, + (amount, payout_asset.clone(), actual_override), + ); + env.events().publish( + ( + EVENT_REVENUE_REPORTED_ASSET, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payout_asset.clone(), amount, period_id), + ); + // Versioned v2 events: always emitted regardless of feature flags (#RC26Q2-C31) + // rv_rep2: [2, amount, period_id, blacklist] + Self::emit_v2_event( + &env, + ( + EVENT_REV_REP_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (amount, period_id, blacklist.clone()), + ); + // rv_repa2: [2, payout_asset, amount, period_id] + Self::emit_v2_event( + &env, + ( + EVENT_REV_REPA_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payout_asset.clone(), amount, period_id), + ); + // rv_inia2: [2, payout_asset, amount, period_id, blacklist] + Self::emit_v2_event( + &env, + ( + EVENT_REV_INIA_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payout_asset.clone(), amount, period_id, blacklist.clone()), + ); + + // Platform fee model (#468): once a report is recorded, route the configured + // platform cut to the treasury and surface it via `plat_fee`. Reaching this point + // means a report was actually recorded (initial or override); the below-threshold + // and rejected paths return early above, so no fee is taken on those. + Self::apply_platform_fee( + &env, + &offering_id, + &issuer, + &namespace, + &token, + amount, + period_id, + ); + + if Self::is_event_versioning_enabled(env.clone()) { + env.events().publish( + ( + EVENT_REV_INIA_V1, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + ( + EVENT_SCHEMA_VERSION, + payout_asset.clone(), + amount, + period_id, + blacklist.clone(), + ), + ); + env.events().publish( + ( + EVENT_REV_REP_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (EVENT_SCHEMA_VERSION, amount, period_id, blacklist.clone()), + ); + env.events().publish( + (EVENT_REV_REPA_V1, issuer, namespace, token), + (EVENT_SCHEMA_VERSION, payout_asset, amount, period_id), + ); + } + + // Advance the cumulative accrual index. Skipped in event-only mode (no persistent state) + // and when amount == 0. Rejected duplicates (rv_rej) never reach this point (early return). + if !event_only { + Self::update_and_emit_accrual_index( + &env, + &offering_id, + amount, + period_id, + ); + } - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } + Ok(()) + } + + /// Repair the `AuditSummary` cache for an offering by recomputing it from the + /// authoritative `RevenueReports` map and writing the corrected value. + /// + /// ### Auth + /// Only the current issuer or the contract admin may call this. This prevents + /// arbitrary callers from triggering unnecessary storage writes. + /// + /// ### Security notes + /// - This function is idempotent: calling it when the summary is already correct + /// is safe and produces no observable side-effects beyond the storage write. + /// - If `RevenueReports` is empty (no reports ever filed), the summary is reset + /// to `{total_revenue: 0, report_count: 0}`. + /// - Overflow during recomputation is handled with saturation; the resulting + /// summary will have `total_revenue == i128::MAX` in that case. + /// + /// ### Returns + /// The corrected `AuditSummary` that was written to storage. + pub fn repair_audit_summary( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result { + Self::require_not_frozen(&env)?; + caller.require_auth(); + + // Auth: caller must be current issuer or admin. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = + Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; + if caller != current_issuer && caller != admin { + return Err(RevoraError::NotAuthorized); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let (corrected, _) = + Self::compute_audit_summary_from_reports(&env, &offering_id); + + let summary_key = DataKey::AuditSummary(offering_id); + env.storage().persistent().set(&summary_key, &corrected); + + Self::emit_v2_event( + &env, + (EVENT_AUDIT_REPAIRED, issuer, namespace, token), + (corrected.total_revenue, corrected.report_count), + ); + + Ok(corrected) + } + + /// Read-only comparison between the stored `AuditSummary` cache and the + /// authoritative `RevenueReports` map for an offering. + pub fn reconcile_audit_summary( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> AuditReconciliationResult { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let stored = env + .storage() + .persistent() + .get::(&DataKey::AuditSummary(offering_id.clone())) + .unwrap_or(AuditSummary { + total_revenue: 0, + report_count: 0, + }); + let (computed, is_saturated) = + Self::compute_audit_summary_from_reports(&env, &offering_id); + let is_consistent = !is_saturated + && stored.total_revenue == computed.total_revenue + && stored.report_count == computed.report_count; + + AuditReconciliationResult { + stored_total_revenue: stored.total_revenue, + stored_report_count: stored.report_count, + computed_total_revenue: computed.total_revenue, + computed_report_count: computed.report_count, + is_consistent, + is_saturated, + } + } + + pub fn get_revenue_by_period( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + ) -> i128 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::RevenueIndex(offering_id, period_id); + env.storage().persistent().get(&key).unwrap_or(0) + } + + /// Sum reported revenue for all period IDs in `[from_period, to_period]` (inclusive). + /// + /// **Warning:** unbounded range — for large ranges prefer [`get_revenue_range_chunk`]. + /// + /// ### Auth + /// None — read-only. + pub fn get_revenue_range( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from_period: u64, + to_period: u64, + ) -> i128 { + let mut total: i128 = 0; + for period in from_period..=to_period { + let amount = Self::get_revenue_by_period( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + period, + ); + total = total.s_add(amount).unwrap_or(i128::MAX); + } + total + } + + /// Read-only: sum revenue for a numeric period range but bounded by `max_periods` per call. + /// + /// Returns `(sum, next_start)` where `next_start` is `Some(period)` if there are remaining + /// periods to process and a subsequent call can continue from that period. + /// + /// ### Features & Security + /// - **Determinism**: The query is read-only and uses capped iterations to prevent CPU/Gas exhaustion. + /// - **Input Validation**: Automatically handles `from_period > to_period` by returning an empty result. + /// - **Capping**: `max_periods` of 0 or > `MAX_CHUNK_PERIODS` will be capped to `MAX_CHUNK_PERIODS`. + pub fn get_revenue_range_chunk( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from_period: u64, + to_period: u64, + max_periods: u32, + ) -> (i128, Option) { + if from_period > to_period { + return (0, None); + } + + let mut total: i128 = 0; + let mut processed: u32 = 0; + let cap = if max_periods == 0 || max_periods > MAX_CHUNK_PERIODS { + MAX_CHUNK_PERIODS + } else { + max_periods + }; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + let mut p = from_period; + while p <= to_period { + if processed >= cap { + return (total, Some(p)); + } + let amount = Self::get_revenue_by_period( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + p, + ); + total = total.s_add(amount).unwrap_or(i128::MAX); + processed = processed.s_add(1).unwrap_or(u32::MAX); + p = p.s_add(1).unwrap_or(u64::MAX); + } + (total, None) + } + /// Return the total number of offerings registered by `issuer` in `namespace`. + pub fn get_offering_count( + env: Env, + issuer: Address, + namespace: Symbol, + ) -> u32 { + let tenant_id = TenantId { issuer, namespace }; + let count_key = DataKey::OfferCount(tenant_id); + env.storage().persistent().get(&count_key).unwrap_or(0) + } + + /// Return a page of offerings for `issuer`. Limit capped at MAX_PAGE_LIMIT (20). + /// Ordering: by registration index (creation order), deterministic (#38). + /// Return a page of offerings for `issuer` in `namespace`. Limit capped at MAX_PAGE_LIMIT (20). + /// Ordering: by registration index (creation order), deterministic (#38). + pub fn get_offerings_page( + env: Env, + issuer: Address, + namespace: Symbol, + start: u32, + limit: u32, + ) -> (Vec, Option) { + let count = + Self::get_offering_count(env.clone(), issuer.clone(), namespace.clone()); + let tenant_id = TenantId { issuer, namespace }; + + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; - // Verify offering exists and retrieve the full Offering (including issuers). - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; + if start >= count { + return (Vec::new(&env), None); + } + + let end = core::cmp::min(start + effective_limit, count); + let mut results = Vec::new(&env); + + for i in start..end { + let item_key = DataKey::OfferItem(tenant_id.clone(), i); + let offering: Offering = + env.storage().persistent().get(&item_key).unwrap(); + results.push_back(offering); + } + + let next_cursor = if end < count { Some(end) } else { None }; + (results, next_cursor) + } + + /// Helper function to add an investor to the blacklist with attestation. + /// + /// Accepts an optional `BlacklistEntryMeta` that, when provided, is persisted + /// in a separate `BlacklistMeta` map keyed by `(offering_id, investor)`. + /// This enables compliance verification by linking blacklist entries to + /// signed off-chain OFAC snapshot hashes. + fn do_blacklist_add( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + attestation: SanctionsAttestation, + entry_meta: Option, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + caller.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + // Verify auth: caller must be issuer or admin + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = + Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; + + if caller != current_issuer && caller != admin { + return Err(RevoraError::NotAuthorized); + } + + // Validate attestation timestamp: attested_at must not be in the future + if attestation.attested_at > env.ledger().timestamp() { + return Err(RevoraError::InvalidAmount); // Wait, let's check error codes + // Wait, let's use a proper error? Wait let's check RevoraError + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Both signers must be valid issuers (primary or co-issuer). - let is_valid = |addr: &Address| -> bool { - if &offering.issuers.primary == addr { - return true; - } - offering.issuers.co.iter().any(|co| co == addr) - }; - if !is_valid(&sig_a) || !is_valid(&sig_b) { - return Err(RevoraError::OfferingNotFound); + if !Self::is_event_only(&env) { + let key = DataKey::Blacklist(offering_id.clone()); + let mut map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + + let was_present = map.contains_key(investor.clone()); + if !was_present { + // Guard: reject if the blacklist is already at capacity. + let limit = Self::get_effective_blacklist_limit(&env, &offering_id); + if map.len() >= limit { + return Err(RevoraError::BlacklistSizeLimitExceeded); } + map.set(investor.clone(), attestation.clone()); + env.storage().persistent().set(&key, &map); - // Dual-signature mode must be enabled for this offering. - if !env + // If BlacklistEntryMeta was provided, persist it in the BlacklistMeta map + if let Some(ref meta) = entry_meta { + let meta_key = DataKey::BlacklistMeta(offering_id.clone()); + let mut meta_map: Map = env .storage() .persistent() - .get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())) - .unwrap_or(false) - { - return Err(RevoraError::DualSigNotConfigured); - } - - let closed_key = DataKey2::ClosedPeriod(offering_id, period_id); - if env.storage().persistent().has(&closed_key) { - return Err(RevoraError::PeriodAlreadyClosed); + .get(&meta_key) + .unwrap_or_else(|| Map::new(&env)); + meta_map.set(investor.clone(), meta.clone()); + env.storage().persistent().set(&meta_key, &meta_map); } - let closed_at = env.ledger().timestamp(); - env.storage().persistent().set(&closed_key, &closed_at); + // Maintain insertion order for deterministic get_blacklist (#38) + let order_key = DataKey::BlacklistOrder(offering_id.clone()); + let mut order: Vec
= env + .storage() + .persistent() + .get(&order_key) + .unwrap_or_else(|| Vec::new(&env)); + order.push_back(investor.clone()); + env.storage().persistent().set(&order_key, &order); + } + } + + env.events().publish( + (EVENT_BL_ADD, issuer, namespace, token), + (caller, investor, attestation), + ); + Ok(()) + } + + /// Add an investor to the per-offering blacklist with a sanctions attestation. + /// + /// Blacklisted addresses are prohibited from claiming revenue for the specified token. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer of the offering. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `investor`: The address to be blacklisted. + /// - `attestation`: The sanctions attestation containing source, reference ID, and timestamp. + /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering to prevent + /// unbounded storage growth and keep distribution gas predictable. + /// - Idempotent adds (address already present) do not count against the size limit. + /// - `attestation.attested_at` must not be in the future. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. + /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. + /// - `Err(RevoraError::InvalidAmount)` if attestation timestamp is in the future. + #[allow(clippy::too_many_arguments)] + pub fn blacklist_add_with_attestation( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + attestation: SanctionsAttestation, + ) -> Result<(), RevoraError> { + Self::do_blacklist_add( + env, + caller, + issuer, + namespace, + token, + investor, + attestation, + None, + ) + } + + /// Add an investor to the per-offering blacklist, pinned to a signed OFAC snapshot hash. + /// + /// Compliance requires proving which OFAC list version informed each blacklist entry. + /// This variant binds every `blacklist_add` call to a signed off-chain snapshot hash + /// referencing the source list. The snapshot hash and addition timestamp are persisted + /// alongside the attestation for verifiable compliance audits. + /// + /// ### Parameters + /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer or admin. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `investor`: The address to be blacklisted. + /// - `attestation`: The sanctions attestation containing source, reference ID, and timestamp. + /// - `ofac_snapshot_hash`: SHA-256 hash of the signed off-chain OFAC list snapshot. + /// + /// ### Events + /// Publishes `(EVENT_BL_ADD_PINNED, issuer, namespace, token)` with + /// `(caller, investor, attestation, snapshot_hash)` as the event data. + /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering. + /// - Idempotent adds (address already present) do not count against the size limit. + /// - `attestation.attested_at` must not be in the future. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. + /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. + /// - `Err(RevoraError::InvalidAmount)` if attestation timestamp is in the future. + #[allow(clippy::too_many_arguments)] + pub fn blacklist_add_pinned( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + attestation: SanctionsAttestation, + ofac_snapshot_hash: BytesN<32>, + ) -> Result<(), RevoraError> { + // Build the entry meta with the snapshot hash and current timestamp + let entry_meta = BlacklistEntryMeta { + snapshot_hash: ofac_snapshot_hash, + added_ts: env.ledger().timestamp(), + }; + Self::do_blacklist_add( + env, + caller, + issuer, + namespace, + token, + investor, + attestation, + Some(entry_meta), + ) + } + + /// Add an investor to the per-offering blacklist (legacy, uses Source::Manual). + /// + /// Blacklisted addresses are prohibited from claiming revenue for the specified token. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer of the offering. + /// - `token`: The token representing the offering. + /// - `investor`: The address to be blacklisted. + /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering to prevent + /// unbounded storage growth and keep distribution gas predictable. + /// - Idempotent adds (address already present) do not count against the size limit. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. + /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. + pub fn blacklist_add( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> Result<(), RevoraError> { + let attestation = SanctionsAttestation { + source: Source::Manual, + ref_id: symbol_short!("manual"), + attested_at: env.ledger().timestamp(), + }; + Self::do_blacklist_add( + env, + caller, + issuer, + namespace, + token, + investor, + attestation, + None, + ) + } + + /// Add multiple investors to the per-offering blacklist in a single transaction (uses Source::Manual). + /// + /// Enables efficient bulk compliance updates by processing up to MAX_BATCH_SIZE (50) + /// addresses atomically. The operation is idempotent: addresses already blacklisted + /// are skipped without error. Events are emitted only for addresses that result in + /// actual state changes. + /// + /// ### Parameters + /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer or admin. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `investors`: Vector of addresses to blacklist (max 50). + /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - All-or-nothing semantics: if any validation fails, no addresses are added. + /// - Batch size is capped at MAX_BATCH_SIZE to keep gas costs predictable. + /// - Blacklist size is capped per-offering (configurable via set_blacklist_size_limit, default MAX_BLACKLIST_SIZE). + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::ContractPaused)` if the contract is paused. + /// - `Err(RevoraError::OfferingNotFound)` if the offering does not exist. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. + /// - `Err(RevoraError::LimitReached)` if batch size exceeds MAX_BATCH_SIZE. + /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if adding the batch would exceed the per-offering limit. + #[allow(clippy::too_many_arguments)] + pub fn blacklist_add_many( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investors: Vec
, + ) -> Result<(), RevoraError> { + // Task 2.1: Authorization checks + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + caller.require_auth(); + + // Task 2.2: Batch size validation + if investors.len() > MAX_BATCH_SIZE { + return Err(RevoraError::LimitReached); + } + + // Handle empty batch case (idempotent no-op) + if investors.is_empty() { + return Ok(()); + } + + // Task 2.3: Offering existence check and authorization + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Track the most recently closed period timestamp for dispute window enforcement. - env.storage() - .persistent() - .set(&DataKey2::LastClosedPeriodTimestamp(offering_id), &closed_at); + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = + Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; + + if caller != current_issuer && caller != admin { + return Err(RevoraError::NotAuthorized); + } + + // Task 2.3: Load storage + let key = DataKey::Blacklist(offering_id.clone()); + let mut map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + let order_key = DataKey::BlacklistOrder(offering_id.clone()); + let mut order: Vec
= env + .storage() + .persistent() + .get(&order_key) + .unwrap_or_else(|| Vec::new(&env)); + + // Task 2.4: Deduplication logic + let mut seen = Map::new(&env); + let mut unique_investors = Vec::new(&env); + for i in 0..investors.len() { + let investor = investors.get(i).unwrap(); + if !seen.contains_key(investor.clone()) { + seen.set(investor.clone(), true); + unique_investors.push_back(investor); + } + } + + // Task 2.5: Capacity validation + let limit = Self::get_effective_blacklist_limit(&env, &offering_id); + let current_size = map.len(); + let mut new_count = 0u32; + for i in 0..unique_investors.len() { + let investor = unique_investors.get(i).unwrap(); + if !map.contains_key(investor.clone()) { + new_count += 1; + } + } + + if current_size + new_count > limit { + return Err(RevoraError::BlacklistSizeLimitExceeded); + } + + // Task 2.6: Batch add logic with storage updates + let now = env.ledger().timestamp(); + for i in 0..unique_investors.len() { + let investor = unique_investors.get(i).unwrap(); + let was_present = map.contains_key(investor.clone()); + + if !was_present { + let attestation = SanctionsAttestation { + source: Source::Manual, + ref_id: symbol_short!("manual"), + attested_at: now, + }; + // Add to map and order vec + if !Self::is_event_only(&env) { + map.set(investor.clone(), attestation.clone()); + order.push_back(investor.clone()); + } + // Emit event for actual state change env.events().publish( - (EVENT_DUAL_SIG_CLOSE, issuer, namespace, token), - (period_id, closed_at, sig_a, sig_b), + ( + EVENT_BL_ADD, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (caller.clone(), investor, attestation), ); + } + // If already blacklisted, skip without error or event (idempotent) + } - // Compute and persist the canonical per-class payout order (#523). - // Mirrors the single-sig `close_period` path so both close flows - // resolve to the identical deterministic order. - Self::record_and_emit_pay_order(&env, &offering_id, period_id); - - Ok(()) - } - - /// Read-only: preview the canonical class pay order and per-holder - /// payouts that a successful `close_period` / `close_period_dual_sig` - /// call would expose for the given period — without touching storage or - /// emitting any event (#563). - /// - /// Mirrors the precondition chain of [`Self::close_period`] so that any - /// error the write path would surface (`ContractFrozen`, `ContractPaused`, - /// `InvalidPeriodId`, `OfferingNotFound`, `PeriodAlreadyClosed`) is also - /// surfaced here. A successful preflight guarantees the atomic close - /// would succeed for the same `(offering_id, period_id)` assuming the - /// caller passes `issuer.require_auth()` on the actual write call. - /// - /// `holders` is required because Soroban persistent maps cannot be - /// iterated by key — this matches the codebase idiom set by - /// [`Self::simulate_distribution`]. Pass the same holder set the - /// eventual `claim` flow will iterate. Blacklisted addresses are - /// silently dropped from the returned `payouts` (precedence rule: - /// blacklist wins over whitelist and over any non-zero share). - /// - /// # Compute parity - /// - /// The `class_pay_order` returned by this entrypoint is byte-identical - /// to what `close_period` would persist via `record_and_emit_pay_order` - /// for the same storage snapshot. The `payouts[i].normalized_payout` - /// values are produced by `compute_share(revenue, share_bps, mode)`, - /// matching the math `simulate_distribution` applies. There is no - /// `normalize_amount` step here because the surrounding - /// `simulate_distribution` flow does not apply one either; indexers - /// should treat this preview as the canonical non-normalized - /// distribution shape. - pub fn preflight_close_period( - env: Env, - offering_id: OfferingId, - period_id: u64, - holders: Vec
, - ) -> Result { - // Mirror `close_period`'s precondition chain exactly. NOTE: we - // intentionally do NOT execute `issuer.require_auth()` here — the - // preflight is a read-only view callable by anyone, aligning with - // `get_offering`, `simulate_distribution`, and `get_class_pay_order`. - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - - // Offering existence. We use `DataKey2::OfferingRecord` (introduced - // for O(1) issuance lookups) rather than `get_offering` so we do - // not charge for a full offering struct deserialisation when we - // only need the existence check. - let offering_record_key = DataKey2::OfferingRecord(offering_id.clone()); - if !env.storage().persistent().has(&offering_record_key) { - return Err(RevoraError::OfferingNotFound); - } + // Save updated storage + if !Self::is_event_only(&env) { + env.storage().persistent().set(&key, &map); + env.storage().persistent().set(&order_key, &order); + } - // Period already sealed — close would reject too, so the preflight - // must reject too. Operators calling preflight then close on the - // same period therefore see a deterministic `PeriodAlreadyClosed` - // rather than a stale preview. - let closed_key = DataKey2::ClosedPeriod(offering_id.clone(), period_id); - if env.storage().persistent().has(&closed_key) { - return Err(RevoraError::PeriodAlreadyClosed); - } + Ok(()) + } + + /// Remove an investor from the per-offering blacklist. + /// + /// Re-enables the address to claim revenue for the specified token. + /// This operation is idempotent. + /// + /// ### Parameters + /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer of the offering. + /// - `token`: The token representing the offering. + /// - `investor`: The address to be removed from the blacklist. + /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - `namespace` isolation ensures that removing from one blacklist does not affect others. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. + pub fn blacklist_remove( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + caller.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + Self::require_not_frozen(&env)?; + + // Verify auth: caller must be issuer or admin. + // Security assumption: only the current issuer or contract admin may remove + // addresses from the blacklist. This mirrors the add-side guard and prevents + // unauthorized actors from re-enabling blacklisted investors. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = + Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; + if caller != current_issuer && caller != admin { + return Err(RevoraError::NotAuthorized); + } + + let key = DataKey::Blacklist(offering_id.clone()); + let mut map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + map.remove(investor.clone()); + env.storage().persistent().set(&key, &map); + + // Also clean up the BlacklistMeta map if this entry had a pinned snapshot hash + let meta_key = DataKey::BlacklistMeta(offering_id.clone()); + let mut meta_map: Map = env + .storage() + .persistent() + .get(&meta_key) + .unwrap_or_else(|| Map::new(&env)); + meta_map.remove(investor.clone()); + env.storage().persistent().set(&meta_key, &meta_map); + + // Rebuild order vec so get_blacklist stays deterministic (#38) + let order_key = DataKey::BlacklistOrder(offering_id.clone()); + let old_order: Vec
= env + .storage() + .persistent() + .get(&order_key) + .unwrap_or_else(|| Vec::new(&env)); + let mut new_order = Vec::new(&env); + for i in 0..old_order.len() { + let addr = old_order.get(i).unwrap(); + if map.contains_key(addr.clone()) { + new_order.push_back(addr); + } + } + env.storage().persistent().set(&order_key, &new_order); + + env + .events() + .publish((EVENT_BL_REM, issuer, namespace, token), (caller, investor)); + Ok(()) + } + + /// Remove multiple investors from the per-offering blacklist in a single transaction. + /// + /// Enables efficient bulk compliance updates by processing up to MAX_BATCH_SIZE (50) + /// addresses atomically. The operation is idempotent: addresses not currently blacklisted + /// are skipped without error. Events are emitted only for addresses that result in + /// actual state changes. + /// + /// ### Parameters + /// - `caller`: The address authorized to manage the blacklist. Must be the current issuer or admin. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `investors`: Vector of addresses to remove from blacklist (max 50). + /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - All-or-nothing semantics: if any validation fails, no addresses are removed. + /// - Batch size is capped at MAX_BATCH_SIZE to keep gas costs predictable. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::ContractPaused)` if the contract is paused. + /// - `Err(RevoraError::OfferingNotFound)` if the offering does not exist. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer or admin. + /// - `Err(RevoraError::LimitReached)` if batch size exceeds MAX_BATCH_SIZE. + #[allow(clippy::too_many_arguments)] + pub fn blacklist_remove_many( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investors: Vec
, + ) -> Result<(), RevoraError> { + // Task 3.1: Authorization checks + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + caller.require_auth(); + + // Task 3.2: Batch size validation + if investors.len() > MAX_BATCH_SIZE { + return Err(RevoraError::LimitReached); + } + + // Handle empty batch case (idempotent no-op) + if investors.is_empty() { + return Ok(()); + } + + // Task 3.3: Offering existence check and authorization + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - Ok(Self::compute_period_close_preview(&env, &offering_id, period_id, holders)) - } - - /// Attach or replace off-chain disclosure metadata for an offering (#485). - /// - /// Issuers use this to bind a private placement memorandum (PPM), K-1 template, - /// or any other off-chain document to the on-chain record so investors can verify - /// the document's integrity via the stored hash. - /// - /// ### Validation - /// - `uri` must be at most 256 bytes; longer values return `DisclosureUriTooLong`. - /// - An empty `uri` paired with a non-zero `hash` returns `InconsistentDisclosure`. - /// (A zero-hash with an empty URI clears any previous disclosure.) - /// - /// ### Auth ordering - /// `issuer.require_auth()` is called immediately after the frozen guard. - pub fn update_disclosure( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - uri: Bytes, - hash: BytesN<32>, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - issuer.require_auth(); + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = + Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; + + if caller != current_issuer && caller != admin { + return Err(RevoraError::NotAuthorized); + } + + // Task 3.3: Load storage + let key = DataKey::Blacklist(offering_id.clone()); + let mut map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + + // Also load the BlacklistMeta map for cleanup + let meta_key = DataKey::BlacklistMeta(offering_id.clone()); + let mut meta_map: Map = env + .storage() + .persistent() + .get(&meta_key) + .unwrap_or_else(|| Map::new(&env)); + + // Task 3.4: Deduplication logic + let mut seen = Map::new(&env); + let mut unique_investors = Vec::new(&env); + for i in 0..investors.len() { + let investor = investors.get(i).unwrap(); + if !seen.contains_key(investor.clone()) { + seen.set(investor.clone(), true); + unique_investors.push_back(investor); + } + } + + // Task 3.5: Batch remove logic + for i in 0..unique_investors.len() { + let investor = unique_investors.get(i).unwrap(); + let was_present = map.contains_key(investor.clone()); + + if was_present { + // Remove from map + map.remove(investor.clone()); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + // Also clean up the BlacklistMeta entry if present + meta_map.remove(investor.clone()); - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + // Emit event for actual state change + env.events().publish( + ( + EVENT_BL_REM, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (caller.clone(), investor), + ); + } + // If not blacklisted, skip without error or event (idempotent) + } + + // Task 3.5: Rebuild order vec to maintain consistency + let order_key = DataKey::BlacklistOrder(offering_id.clone()); + let old_order: Vec
= env + .storage() + .persistent() + .get(&order_key) + .unwrap_or_else(|| Vec::new(&env)); + let mut new_order = Vec::new(&env); + for i in 0..old_order.len() { + let addr = old_order.get(i).unwrap(); + if map.contains_key(addr.clone()) { + new_order.push_back(addr); + } + } + + // Save updated storage + env.storage().persistent().set(&key, &map); + env.storage().persistent().set(&order_key, &new_order); + env.storage().persistent().set(&meta_key, &meta_map); - // URI length guard: max 256 bytes. - if uri.len() > 256 { - return Err(RevoraError::DisclosureUriTooLong); - } + Ok(()) + } + + /// Returns `true` if `investor` is blacklisted for an offering. + pub fn is_blacklisted( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> bool { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Blacklist(offering_id); + env + .storage() + .persistent() + .get::>(&key) + .map(|m| m.contains_key(investor)) + .unwrap_or(false) + } + + /// Returns the sanctions attestation for a blacklisted investor, if any. + pub fn get_blacklist_attestation( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Blacklist(offering_id); + env + .storage() + .persistent() + .get::>(&key) + .and_then(|m| m.get(investor)) + } + + /// Return all blacklisted addresses for an offering. + /// Ordering: by insertion order, deterministic and stable across calls (#38). + /// + /// ## Legacy/Bounded Warning + /// + /// This method returns the entire blacklist in a single call, which can exceed gas limits + /// for large lists. It is retained for backward compatibility but should be avoided in + /// production code. Use `get_blacklist_page` instead for pagination with deterministic cursors. + /// + /// The blacklist size is bounded by MAX_BLACKLIST_SIZE (200) per offering, so this method + /// will never return more than 200 addresses. However, for off-chain tooling and event + /// processing, the paginated form is preferred to avoid gas spikes. + pub fn get_blacklist( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Vec
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let order_key = DataKey::BlacklistOrder(offering_id); + env + .storage() + .persistent() + .get::>(&order_key) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return a page of blacklisted addresses for an offering. + /// + /// ## Pagination Behavior + /// + /// - `start`: Zero-based cursor position in the insertion-ordered blacklist + /// - `limit`: Maximum number of addresses to return (capped at MAX_PAGE_LIMIT = 20) + /// - Returns: (page of addresses, next_cursor) + /// - `next_cursor = Some(n)` indicates more data is available at position `n` + /// - `next_cursor = None` indicates end of list + /// + /// The cursor is deterministic and stable: it corresponds to the index in the + /// insertion-ordered blacklist. Pagination preserves insertion order (#38). + /// + /// ## Usage Pattern + /// + /// ```ignore + /// let mut cursor = 0; + /// loop { + /// let (page, next) = get_blacklist_page(env, issuer, ns, token, cursor, 20); + /// // process page... + /// match next { + /// Some(n) => cursor = n, + /// None => break, + /// } + /// } + /// ``` + pub fn get_blacklist_page( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start: u32, + limit: u32, + ) -> (Vec
, Option) { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let order_key = DataKey::BlacklistOrder(offering_id); + let all: Vec
= env + .storage() + .persistent() + .get::>(&order_key) + .unwrap_or_else(|| Vec::new(&env)); + + let count = all.len(); + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; - // Coherence guard: non-zero hash requires a URI. - let zero_hash = BytesN::from_array(&env, &[0u8; 32]); - if uri.is_empty() && hash != zero_hash { - return Err(RevoraError::InconsistentDisclosure); - } + if start >= count { + return (Vec::new(&env), None); + } + + let end = core::cmp::min(start + effective_limit, count); + let mut results = Vec::new(&env); + for i in start..end { + results.push_back(all.get(i).unwrap()); + } + + let next_cursor = if end < count { Some(end) } else { None }; + (results, next_cursor) + } + + /// Return the current number of blacklisted addresses for an offering. + /// + /// This is a cheap O(1) read of the underlying map length and can be used + /// by off-chain tooling to monitor proximity to the per-offering blacklist limit + /// (default MAX_BLACKLIST_SIZE = 200, configurable via set_blacklist_size_limit) + /// before attempting an add. + /// + /// Returns 0 when no blacklist exists yet for the offering. + pub fn get_blacklist_size( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Blacklist(offering_id); + env + .storage() + .persistent() + .get::>(&key) + .map(|m| m.len()) + .unwrap_or(0) + } + + /// Returns the `BlacklistEntryMeta` for a blacklisted investor, if one exists. + /// + /// Only entries added via `blacklist_add_pinned` will have metadata. + /// Returns `None` if the investor is not blacklisted or was added without a snapshot hash. + /// + /// ### Parameters + /// - `env`: The Soroban environment. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `investor`: The blacklisted address to query. + /// + /// ### Returns + /// - `Some(BlacklistEntryMeta)` containing the snapshot hash and addition timestamp. + /// - `None` if no pinned metadata exists for the investor. + pub fn get_blacklist_entry_meta( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let meta_key = DataKey::BlacklistMeta(offering_id); + env + .storage() + .persistent() + .get::>(&meta_key) + .and_then(|m| m.get(investor)) + } + + // ── Whitelist management ────────────────────────────────── + /// Get the effective blacklist size limit for a per-offering. + /// + /// Returns the per-offering limit if set, otherwise defaults to MAX_BLACKLIST_SIZE. + /// This is a private helper used by blacklist_add and blacklist_add_many. + /// + /// ### Parameters + /// - `env`: The Soroban environment. + /// - `offering_id`: The offering identifier. + /// + /// ### Returns + /// The maximum allowed blacklist size for the offering. + fn get_effective_blacklist_limit(env: &Env, offering_id: &OfferingId) -> u32 { + let key = DataKey2::BlacklistSizeLimit(offering_id.clone()); + env + .storage() + .persistent() + .get::(&key) + .unwrap_or(MAX_BLACKLIST_SIZE) + } + + /// Set the per-offering blacklist size limit. + /// + /// Allows the issuer to configure a maximum number of addresses that can be + /// blacklisted for a specific offering. This limit affects both `blacklist_add` + /// and `blacklist_add_many` operations. If not set, the default is MAX_BLACKLIST_SIZE (200). + /// + /// ### Parameters + /// - `env`: The Soroban environment. + /// - `caller`: The address making the request. Must be the current issuer. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `max_size`: The new maximum blacklist size (must be > 0). + /// + /// Idempotent — calling with an already-whitelisted address is safe. + /// When a whitelist exists (non-empty), only whitelisted addresses + /// are eligible for revenue distribution (subject to blacklist override). + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering. + /// - Caller must be authorized (require_auth). + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// - `Err(RevoraError::OfferingNotFound)` if the offering does not exist. + /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. + /// - `Err(RevoraError::LimitReached)` if max_size is 0. + pub fn set_blacklist_size_limit( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + max_size: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + caller.require_auth(); + + // Verify the offering exists and caller is the issuer + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + if caller != current_issuer { + return Err(RevoraError::NotAuthorized); + } + + // Validate: max_size must be at least 1 + if max_size == 0 { + return Err(RevoraError::LimitReached); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let key = DataKey2::DisclosureMeta(offering_id); - env.storage() - .persistent() - .set(&key, &DisclosureMeta { uri: uri.clone(), hash: hash.clone() }); + let key = DataKey2::BlacklistSizeLimit(offering_id); + env.storage().persistent().set(&key, &max_size); - Self::emit_v2_event( - &env, - (EVENT_DISCLOSURE_UPDATED, issuer, namespace, token), - (uri, hash), - ); + Ok(()) + } + + // ── Whitelist management ────────────────────────────────── + + /// Set per-offering concentration limit. Caller must be the offering issuer. + /// `max_bps`: max allowed single-holder share in basis points (0 = disable). + /// Add `investor` to the per-offering whitelist for `token`. + /// + /// Idempotent — calling with an already-whitelisted address is safe. + /// When a whitelist exists (non-empty), only whitelisted addresses + /// are eligible for revenue distribution (subject to blacklist override). + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering. + /// - `namespace` partitioning prevents whitelists from leaking across tenants. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::OfferingNotFound)` if the offering is not registered. + /// - `Err(RevoraError::NotAuthorized)` if the caller is not authorized. + pub fn whitelist_add( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + caller.require_auth(); + + // Verify offering exists and get current issuer for auth check + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + Self::require_not_frozen(&env)?; + + if !Self::is_event_only(&env) { + let key = DataKey::Whitelist(offering_id.clone()); + let mut map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + map.set(investor.clone(), true); + env.storage().persistent().set(&key, &map); + } + + env.events().publish( + ( + EVENT_WL_ADD, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (caller, investor), + ); + Ok(()) + } + + /// Remove `investor` from the per-offering whitelist for `token`. + /// + /// Idempotent — calling when the address is not listed is safe. + /// Remove `investor` from the per-offering whitelist. + pub fn whitelist_remove( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + caller.require_auth(); + + // Verify offering exists and get current issuer for auth check + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + Self::require_not_frozen(&env)?; + let key = DataKey::Whitelist(offering_id.clone()); + let mut map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + + if !Self::is_event_only(&env) { + let key = DataKey::Whitelist(offering_id.clone()); + if let Some(mut map) = env + .storage() + .persistent() + .get::>(&key) + { + if map.remove(investor.clone()).is_some() { + env.storage().persistent().set(&key, &map); + } + } + } + + env.events().publish( + ( + EVENT_WL_REM, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (caller, investor), + ); + Ok(()) + } + + /// Returns `true` if `investor` is whitelisted for `token`'s offering. + /// + /// Note: If the whitelist is empty (disabled), this returns `false`. + /// Use `is_whitelist_enabled` to check if whitelist enforcement is active. + pub fn is_whitelisted( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + investor: Address, + ) -> bool { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Whitelist(offering_id); + env + .storage() + .persistent() + .get::>(&key) + .map(|m| m.get(investor).unwrap_or(false)) + .unwrap_or(false) + } + + /// Return all whitelisted addresses for an offering. + pub fn get_whitelist( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Vec
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Whitelist(offering_id); + env + .storage() + .persistent() + .get::>(&key) + .map(|m| m.keys()) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return a page of whitelisted addresses for an offering. + /// Limit capped at MAX_PAGE_LIMIT (20). + pub fn get_whitelist_page( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start: u32, + limit: u32, + ) -> (Vec
, Option) { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Whitelist(offering_id); + let all: Vec
= env + .storage() + .persistent() + .get::>(&key) + .map(|m| m.keys()) + .unwrap_or_else(|| Vec::new(&env)); + + let count = all.len(); + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; - Ok(()) + if start >= count { + return (Vec::new(&env), None); } - /// Return the off-chain disclosure metadata for an offering, if set. - pub fn get_disclosure( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::DisclosureMeta(offering_id)) + let end = core::cmp::min(start + effective_limit, count); + let mut results = Vec::new(&env); + for i in start..end { + results.push_back(all.get(i).unwrap()); } -} -// ── Holder shares, claims, admin, governance, and utility methods ───────────── -// Plain impl block — excluded from the ABI spec to keep spec XDR within limit. -impl RevoraRevenueShare { - /// - /// The share determines the percentage of a period's revenue the holder can claim. - /// - /// ### Parameters - /// - `issuer`: The offering issuer. Must provide authentication. - /// - `token`: The token representing the offering. - /// - `holder`: The address of the token holder. - /// - `share_bps`: The holder's share in basis points (0-10000). - /// - /// ### Returns - /// - `Ok(())` on success. - /// - `Err(RevoraError::OfferingNotFound)` if the offering is not found. - /// - `Err(RevoraError::InvalidShareBps)` if `share_bps` exceeds 10000. - /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. - /// Set a holder's revenue share (in basis points) for an offering. - fn set_holder_share_full( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - share_bps: u32, - share_class: Option, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - // Verify offering exists and issuer is current - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; + let next_cursor = if end < count { Some(end) } else { None }; + (results, next_cursor) + } - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + /// Returns `true` if whitelist enforcement is enabled for an offering. + pub fn is_whitelist_enabled( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> bool { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::Whitelist(offering_id); + let map: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(&env)); + !map.is_empty() + } + + // ── Holder concentration guardrail (#26) ─────────────────── + + /// Set the concentration limit for an offering. + /// + /// Configures the maximum share a single holder can own and whether it is enforced. + /// + /// ### Parameters + /// - `issuer`: The offering issuer. Must provide authentication. + /// - `namespace`: The namespace the offering belongs to. + /// - `token`: The token representing the offering. + /// - `max_bps`: The maximum allowed single-holder share in basis points (0-10000, 0 = disabled). + /// - `enforce`: If true, `report_revenue` will fail if current concentration exceeds `max_bps`. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::LimitReached)` if the offering is not found. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// Configure the concentration limit for an offering. + /// + /// ### Parameters + /// - `max_bps`: The maximum allowed share for a single holder in basis points. + /// - `enforce`: If true, `report_revenue` will fail if current concentration > `max_bps`. + /// - `max_staleness_secs`: When > 0 and `enforce` is true, `report_revenue` rejects if no + /// concentration has been reported or the last report is older than this many seconds. + /// Set to 0 to disable the staleness check. + /// + /// ### Constraints + /// - `max_bps` must be <= 10,000. + pub fn set_concentration_limit( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + max_bps: u32, + enforce: bool, + max_staleness_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + if max_bps > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::LimitReached)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::LimitReached); + } + + // Auth-first: authenticate before any state reads or side effects. + // This prevents unauthenticated callers from probing offering existence + // and ensures event-only mode never silently skips authorization. + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + // Verify offering exists and issuer is current + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - Self::require_not_frozen(&env)?; - issuer.require_auth(); - Self::set_holder_share_internal( - &env, - offering_id.issuer, - offering_id.namespace, - offering_id.token, - holder, - share_bps, - share_class, - None, - ) + if !Self::is_event_only(&env) { + let key = DataKey::ConcentrationLimit(offering_id); + env.storage().persistent().set( + &key, + &ConcentrationLimitConfig { + max_bps, + enforce, + max_staleness_secs, + }, + ); } - // ── Meta-authorization, claims, windows, and query methods ─────────────────── - - /// Register an ed25519 public key for a signer address. - /// The signer must authorize this binding. - pub fn register_meta_signer_key( - env: Env, - signer: Address, - public_key: BytesN<32>, - ) -> Result<(), RevoraError> { - signer.require_auth(); - env.storage().persistent().set(&MetaDataKey::SignerKey(signer.clone()), &public_key); - Self::emit_v2_event(&env, (EVENT_META_SIGNER_SET, signer), public_key); - Ok(()) - } - - /// Set or update an offering-level delegate signer for off-chain authorizations. - /// Only the current issuer may set this value. - pub fn set_meta_delegate( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - delegate: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - env.storage().persistent().set(&MetaDataKey::Delegate(offering_id), &delegate); - Self::emit_v2_event(&env, (EVENT_META_DELEGATE_SET, issuer, namespace, token), delegate); - Ok(()) - } - - /// Get the configured offering-level delegate signer. - pub fn get_meta_delegate( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option
{ - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&MetaDataKey::Delegate(offering_id)) - } - - /// Meta-transaction variant of `set_holder_share`. - /// A registered delegate signer authorizes this action via off-chain ed25519 signature. - #[allow(clippy::too_many_arguments)] - pub fn meta_set_holder_share( - env: Env, - signer: Address, - payload: MetaSetHolderSharePayload, - nonce: u64, - expiry: u64, - signature: BytesN<64>, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - let current_issuer = Self::get_current_issuer( - &env, - payload.issuer.clone(), - payload.namespace.clone(), - payload.token.clone(), - ) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != payload.issuer { - return Err(RevoraError::OfferingNotFound); - } - let offering_id = OfferingId { - issuer: payload.issuer.clone(), - namespace: payload.namespace.clone(), - token: payload.token.clone(), - }; - Self::require_not_frozen(&env)?; - let configured_delegate: Address = env - .storage() - .persistent() - .get(&MetaDataKey::Delegate(offering_id)) - .ok_or(RevoraError::NotAuthorized)?; - if configured_delegate != signer { - return Err(RevoraError::NotAuthorized); - } - let action = MetaAction::SetHolderShare(payload.clone()); - Self::verify_meta_signature(&env, &signer, nonce, expiry, action, &signature)?; - Self::set_holder_share_internal( - &env, - payload.issuer.clone(), - payload.namespace.clone(), - payload.token.clone(), - payload.holder.clone(), - payload.share_bps, - None, - None, - )?; - Self::mark_meta_nonce_used(&env, &signer, nonce); - env.events().publish( - (EVENT_META_SHARE_SET, payload.issuer, payload.namespace, payload.token), - (signer, payload.holder, payload.share_bps, nonce, expiry), - ); - Ok(()) - } - - /// Meta-transaction authorization for a revenue report payload. - /// This does not mutate revenue data directly; it records a signed approval. - #[allow(clippy::too_many_arguments)] - pub fn meta_approve_revenue_report( - env: Env, - signer: Address, - payload: MetaRevenueApprovalPayload, - nonce: u64, - expiry: u64, - signature: BytesN<64>, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - let current_issuer = Self::get_current_issuer( - &env, - payload.issuer.clone(), - payload.namespace.clone(), - payload.token.clone(), - ) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != payload.issuer { - return Err(RevoraError::OfferingNotFound); - } - let offering_id = OfferingId { - issuer: payload.issuer.clone(), - namespace: payload.namespace.clone(), - token: payload.token.clone(), - }; - Self::require_not_frozen(&env)?; - let configured_delegate: Address = env - .storage() - .persistent() - .get(&MetaDataKey::Delegate(offering_id.clone())) - .ok_or(RevoraError::NotAuthorized)?; - if configured_delegate != signer { - return Err(RevoraError::NotAuthorized); - } - let action = MetaAction::ApproveRevenueReport(payload.clone()); - Self::verify_meta_signature(&env, &signer, nonce, expiry, action, &signature)?; - env.storage() - .persistent() - .set(&MetaDataKey::RevenueApproved(offering_id, payload.period_id), &true); - Self::mark_meta_nonce_used(&env, &signer, nonce); - env.events().publish( - (EVENT_META_REV_APPROVE, payload.issuer, payload.namespace, payload.token), - ( - signer, - payload.payout_asset, - payload.amount, - payload.period_id, - payload.override_existing, - nonce, - expiry, - ), - ); - Ok(()) - } - - /// Return a holder's share in basis points for an offering (0 if unset). - fn get_holder_share_internal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::HolderShare(offering_id, holder); - env.storage().persistent().get(&key).unwrap_or(0) - } - - /// @notice Claim accumulated revenue for a holder across multiple unclaimed periods. - /// @dev Payouts are calculated based on the holder's share at the time of claim. - /// Capped at MAX_CLAIM_PERIODS (50) per transaction for gas safety. - /// This function enforces strict security invariants for multi-period claims. - /// - /// @param holder The address of the token holder. Must provide authentication. - /// @param issuer The address of the offering issuer. - /// @param namespace A symbol identifying the namespace. - /// @param token The token representing the offering. - /// @param max_periods Maximum number of periods to process (0 = MAX_CLAIM_PERIODS). - /// - /// @return Ok(i128) The total payout amount on success. - /// @return Err(RevoraError::HolderBlacklisted) if the holder is blacklisted. - /// @return Err(RevoraError::NoPendingClaims) if no share is set or all periods are claimed. - /// @return Err(RevoraError::ClaimDelayNotElapsed) if the next period is still within the claim delay window. - /// - /// # Idempotency and Safety Invariants - /// - /// This function provides the following hard guarantees: - /// - /// 1. **No double-pay**: `LastClaimedIdx` is written to storage only *after* the token - /// transfer succeeds. If the transfer panics (e.g. insufficient contract balance), - /// the index is not advanced and the holder may retry. Soroban's atomic transaction - /// model ensures partial state is never committed. - /// - /// 2. **Index advances only on processed periods**: The index is set to - /// `last_claimed_idx`, which reflects only periods that passed the delay check. - /// Periods blocked by `ClaimDelaySecs` are not counted; the function returns - /// `ClaimDelayNotElapsed` without writing any state. - /// - /// 3. **Zero-payout periods advance the index**: A period with `revenue = 0` (or - /// where `revenue * share_bps / 10_000 == 0` due to truncation) still advances - /// `LastClaimedIdx`. No transfer is issued for zero amounts. This prevents - /// permanently stuck indices on dust periods. - /// - /// 4. **Exhausted state returns `NoPendingClaims`**: Once `LastClaimedIdx >= PeriodCount`, - /// every subsequent call returns `Err(NoPendingClaims)` without touching storage. - /// Callers may safely retry without risk of side effects. - /// - /// 5. **Per-holder isolation**: Each holder's `LastClaimedIdx` is keyed by - /// `(offering_id, holder)`. One holder's claim progress never affects another's. - /// - /// 6. **Auth checked first**: `holder.require_auth()` is the first operation. - /// All subsequent checks (blacklist, share, period count) are read-only and - /// produce no state changes on failure. - /// - /// 7. **Blacklist/whitelist decisiveness during partial sequences**: The blacklist - /// check is performed INSIDE the period iteration loop. If a holder becomes - /// blacklisted mid-sequence during a multi-period claim, the loop breaks immediately - /// and no subsequent periods in the batch are claimed. The index is only advanced - /// for periods successfully processed before the blacklist took effect. This ensures - /// blacklist/whitelist decisions remain decisive even during partial claim sequences. - /// - /// 8. **Index monotonicity enforced**: The function validates that period IDs are - /// strictly increasing as they are retrieved from `PeriodEntry`. This ensures - /// `LastClaimedIdx` advances only in ways that match the deposited period order, - /// preventing any possibility of skipping periods or claiming out of order. - /// - /// # Arguments - /// * `holder` - The address of the holder claiming revenue. - /// * `issuer` - The address of the offering issuer. - /// * `namespace` - A symbol identifying the namespace. - /// * `token` - The address of the token. - /// * `max_periods` - The maximum number of periods to claim in this call. - /// - /// # Events - /// Read-only: return a page of pending period IDs for a holder, bounded by `limit`. - /// Returns `(periods_page, next_cursor)` where `next_cursor` is `Some(next_index)` when more - /// periods remain, otherwise `None`. `limit` of 0 or greater than `MAX_PAGE_LIMIT` will be - /// capped to `MAX_PAGE_LIMIT` to keep calls predictable. - #[allow(clippy::too_many_arguments)] - pub fn get_pending_periods_page( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - start: u32, - limit: u32, - ) -> (Vec, Option) { - let offering_id = OfferingId { issuer, namespace, token }; - let count_key = DataKey::PeriodCount(offering_id.clone()); - let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder); - let holder_start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - - let actual_start = core::cmp::max(start, holder_start_idx); - - if actual_start >= period_count { - return (Vec::new(&env), None); - } - - let effective_limit = - if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; - let end = core::cmp::min(actual_start + effective_limit, period_count); - - let mut results = Vec::new(&env); - for i in actual_start..end { - let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); - if period_id == 0 { - continue; - } - results.push_back(period_id); - } - - let next_cursor = if end < period_count { Some(end) } else { None }; - (results, next_cursor) - } - - /// Read-only: return a paginated statement page for a holder. - /// - /// Each entry is ordered by the persisted `PeriodEntry` index, which is monotonic in - /// `period_id` for valid offering state. The cursor is the zero-based period-entry index - /// and is clamped to the holder's current `LastClaimedIdx`, so stale callers cannot page - /// back into already-claimed history. - /// - /// Security assumptions: - /// - Returning an empty page for a cursor past the end must be safe and deterministic. - /// - The first delayed period forms a hard stop because later periods are not claimable yet. - /// - `limit` is capped to `MAX_PAGE_LIMIT` to keep read gas bounded. - #[allow(clippy::too_many_arguments)] - pub fn get_holder_statement_page( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - cursor: u32, - limit: u32, - ) -> (Vec, Option) { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - if Self::is_blacklisted(env.clone(), issuer, namespace, token, holder.clone()) { - return (Vec::new(&env), None); - } - if Self::require_claim_window_open(&env, &offering_id).is_err() { - return (Vec::new(&env), None); - } - - let count_key = DataKey::PeriodCount(offering_id.clone()); - let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + Self::emit_v2_event( + &env, + (EVENT_CONC_LIMIT_SET, issuer, namespace, token), + (max_bps, enforce), + ); - let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); - let holder_start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - let start_idx = core::cmp::max(cursor, holder_start_idx); - if start_idx >= period_count { - return (Vec::new(&env), None); - } + Ok(()) + } + + pub fn set_transfer_restrictions( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + category: Symbol, + max_holders: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let effective_limit = - if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; + let key = DataKey2::TransferRestrictions(offering_id, category.clone()); + let restrictions = TransferRestrictions { + category, + max_holders, + }; + env.storage().persistent().set(&key, &restrictions); + Ok(()) + } + + 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(), + }; - let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); - let delay_secs: u64 = env.storage().persistent().get(&delay_key).unwrap_or(0); - let now = env.ledger().timestamp(); + if from == to { + return Ok(()); + } - let mut entries = Vec::new(&env); - let mut processed: u32 = 0; - let mut idx = start_idx; - let mut previous_period_id: Option = None; + // 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); + } + } - while idx < period_count && processed < effective_limit { - let entry_key = DataKey::PeriodEntry(offering_id.clone(), idx); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); - if period_id == 0 { - idx = idx.saturating_add(1); - continue; - } + // Zero-value transfer is meaningless + if amount_bps == 0 { + return Err(RevoraError::InvalidAmount); + } - if let Some(previous) = previous_period_id { - if period_id <= previous { - break; - } - } - previous_period_id = Some(period_id); + // 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 time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - let deposit_timestamp: u64 = env.storage().persistent().get(&time_key).unwrap_or(0); - if delay_secs > 0 && now < deposit_timestamp.saturating_add(delay_secs) { - return (entries, Some(idx)); + 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); } - - let claimable_amount = Self::compute_holder_payout_for_range( - &env, - &offering_id, - &holder, - idx, - idx.saturating_add(1), + } + } + } + } + + // ── Per-jurisdiction transfer cooldown check ── + // Look up the `from` holder's jurisdiction and check whether a cooldown + // is configured for that jurisdiction. If so, verify the required time + // has elapsed since the holder's last transfer. + let jurisdiction = + Self::get_holder_jurisdiction_internal(&env, &offering_id, &from); + if let Some(jur) = jurisdiction { + if jur != EVENT_JUR_UNSET { + let cooldown_key = + DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); + if let Some(cooldown_secs) = env + .storage() + .persistent() + .get::(&cooldown_key) + { + if cooldown_secs > 0 { + let last_xfer_key = DataKey2::HolderLastTransferTime( + offering_id.clone(), + from.clone(), ); - entries.push_back(HolderStatementEntry { - period_id, - deposit_timestamp, - claimable_amount, - }); - - processed = processed.saturating_add(1); - idx = idx.saturating_add(1); - } - - let next_cursor = if idx < period_count { Some(idx) } else { None }; - (entries, next_cursor) - } - - /// Shared claim-preview engine used by both full and chunked read-only views. - /// - /// Security assumptions: - /// - Previews must never overstate what `claim` could legally pay at the current ledger state. - /// - Callers may provide stale or adversarial cursors, so we clamp to the holder's current - /// `LastClaimedIdx` before iterating. - /// - The first delayed period forms a hard stop because later periods are not claimable either. - /// - /// Returns `(total, next_cursor)` where `next_cursor` resumes from the first unprocessed index. - fn compute_claimable_preview( - env: &Env, - offering_id: &OfferingId, - holder: &Address, - requested_start_idx: u32, - count: Option, - ) -> (i128, Option) { - let count_key = DataKey::PeriodCount(offering_id.clone()); - let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); - let holder_start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - let actual_start = core::cmp::max(requested_start_idx, holder_start_idx); - - if actual_start >= period_count { - return (0, None); - } - - let effective_cap = count.map(|requested| { - if requested == 0 || requested > MAX_CHUNK_PERIODS { - MAX_CHUNK_PERIODS - } else { - requested + let last_xfer: u64 = + env.storage().persistent().get(&last_xfer_key).unwrap_or(0); + let now = env.ledger().timestamp(); + if now < last_xfer.saturating_add(cooldown_secs) { + return Err(RevoraError::TransferCooldownActive); } - }); + } + } + } + } - let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); - let delay_secs: u64 = env.storage().persistent().get(&delay_key).unwrap_or(0); - let now = env.ledger().timestamp(); + Ok(()) + } + + /// Check all transfer eligibility gates in one place. + /// + /// Validates that a transfer between `from` and `to` within the given + /// offering and category is permitted. This gate is called by + /// `transfer_with_attestation`. Note that `estimate_transfer` duplicates + /// these checks inline (including the cooldown check) as a public query + /// endpoint so callers can dry-run without issuer auth. + /// + /// # Checks performed + /// - Contract not frozen + /// - Non-zero amount + /// - Neither party blacklisted + /// - `to` jurisdiction allowed + /// - Lockup schedule not active + /// - Sender has enough shares + /// - Category capacity not exceeded + /// - Per-jurisdiction transfer cooldown has elapsed + fn check_transfer_eligibility( + env: &Env, + issuer: &Address, + namespace: &Symbol, + token: &Address, + from: &Address, + to: &Address, + amount_bps: u32, + category: &Symbol, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(env)?; + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let mut total: i128 = 0; - let mut processed: u32 = 0; - let mut idx = actual_start; + if from == to { + return Ok(()); + } - let anchor_key = DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); - let anchor: Option = env.storage().persistent().get(&anchor_key); + // Zero-value transfer is meaningless + if amount_bps == 0 { + return Err(RevoraError::InvalidAmount); + } - if let Some(a) = anchor { - if holder_start_idx <= a.end_idx { - total = total.saturating_add(a.claimable_sum); - let anchor_periods = a.end_idx.saturating_sub(holder_start_idx).saturating_add(1); - processed = processed.saturating_add(anchor_periods); - idx = core::cmp::max(idx, a.end_idx.saturating_add(1)); - env.storage().persistent().remove(&anchor_key); + // 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"), + )?; + + // 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); + } + } + + 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); + + 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); + } + } + } + } + } + + // ── Per-jurisdiction transfer cooldown check ── + // Look up the `from` holder's jurisdiction and check whether a cooldown + // is configured for that jurisdiction. If so, verify the required time + // has elapsed since the holder's last transfer. + let jurisdiction = + Self::get_holder_jurisdiction_internal(env, &offering_id, from); + if let Some(jur) = jurisdiction { + if jur != EVENT_JUR_UNSET { + let cooldown_key = + DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); + if let Some(cooldown_secs) = env + .storage() + .persistent() + .get::(&cooldown_key) + { + if cooldown_secs > 0 { + let last_xfer_key = DataKey2::HolderLastTransferTime( + offering_id.clone(), + from.clone(), + ); + let last_xfer: u64 = + env.storage().persistent().get(&last_xfer_key).unwrap_or(0); + let now = env.ledger().timestamp(); + if now < last_xfer.saturating_add(cooldown_secs) { + return Err(RevoraError::TransferCooldownActive); } + } } + } + } - let effective_end = - count.map(|c| core::cmp::min(actual_start + c, period_count)).unwrap_or(period_count); + Ok(()) + } + + pub fn transfer_with_attestation( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from: Address, + to: Address, + amount_bps: u32, + category: Symbol, + ) -> Result<(), RevoraError> { + Self::check_transfer_eligibility( + &env, &issuer, &namespace, &token, &from, &to, amount_bps, &category, + )?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - while idx < effective_end { - if let Some(cap) = effective_cap { - if processed >= cap { - return (total, Some(idx)); - } - } + if from == to { + return Ok(()); + } + + let from_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), from.clone())) + .unwrap_or(0); + + 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 entry_key = DataKey::PeriodEntry(offering_id.clone(), idx); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); - if period_id == 0 { - idx = idx.saturating_add(1); - continue; + 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); } - - let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - let deposit_time: u64 = env.storage().persistent().get(&time_key).unwrap_or(0); - if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { - return (total, Some(idx)); + } + } + } + } + + // ── Per-jurisdiction transfer cooldown check ── + // Look up the `from` holder's jurisdiction and check whether a cooldown + // is configured for that jurisdiction. If so, verify the required time + // has elapsed since the holder's last transfer. + let jurisdiction = + Self::get_holder_jurisdiction_internal(&env, &offering_id, &from); + if let Some(jur) = jurisdiction { + if jur != EVENT_JUR_UNSET { + let cooldown_key = + DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); + if let Some(cooldown_secs) = env + .storage() + .persistent() + .get::(&cooldown_key) + { + if cooldown_secs > 0 { + let last_xfer_key = DataKey2::HolderLastTransferTime( + offering_id.clone(), + from.clone(), + ); + let last_xfer: u64 = + env.storage().persistent().get(&last_xfer_key).unwrap_or(0); + let now = env.ledger().timestamp(); + if now < last_xfer.saturating_add(cooldown_secs) { + return Err(RevoraError::TransferCooldownActive); } - - total = total.saturating_add(Self::compute_holder_payout_for_range( - env, - offering_id, - holder, - idx, - idx.saturating_add(1), - )); - processed = processed.saturating_add(1); - idx = idx.saturating_add(1); + } } + } + } - (total, None) - } - - /// Request redemption of a portion of the caller's holder shares. - /// - /// The holder submits a request specifying `shares_bps` to redeem. Only one - /// pending request per holder per offering is allowed. The redemption window - /// must be open (if configured). Blacklisted holders are rejected. - pub fn request_redemption( - env: Env, - holder: Address, - issuer: Address, - namespace: Symbol, - token: Address, - shares_bps: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - holder.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Verify offering exists - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; + Ok(()) + } + + /// Check all transfer eligibility gates in one place. + /// + /// Validates that a transfer between `from` and `to` within the given + /// offering and category is permitted. This gate is called by + /// `transfer_with_attestation`. Note that `estimate_transfer` duplicates + /// these checks inline (including the cooldown check) as a public query + /// endpoint so callers can dry-run without issuer auth. + /// + /// # Checks performed + /// - Contract not frozen + /// - Non-zero amount + /// - Neither party blacklisted + /// - `to` jurisdiction allowed + /// - Lockup schedule not active + /// - Sender has enough shares + /// - Category capacity not exceeded + /// - Per-jurisdiction transfer cooldown has elapsed + fn check_transfer_eligibility( + env: &Env, + issuer: &Address, + namespace: &Symbol, + token: &Address, + from: &Address, + to: &Address, + amount_bps: u32, + category: &Symbol, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(env)?; + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Check redemption window is open - Self::require_redemption_window_open(&env, &offering_id)?; + if from == to { + return Ok(()); + } - // Check holder is not blacklisted - if Self::is_blacklisted( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), - ) { - return Err(RevoraError::HolderBlacklisted); - } + // Zero-value transfer is meaningless + if amount_bps == 0 { + return Err(RevoraError::InvalidAmount); + } - // Check holder has shares to redeem - let current_share = Self::get_holder_share_internal( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), + // 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"), + )?; + + // 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()), ); - if current_share == 0 { - return Err(RevoraError::NoPendingClaims); - } - if shares_bps == 0 || shares_bps > current_share { - return Err(RevoraError::InvalidShareBps); - } - - // Check no pending request already exists - let request_key = DataKey2::RedemptionRequest(offering_id.clone(), holder.clone()); - if env.storage().persistent().has(&request_key) { - return Err(RevoraError::LimitReached); + return Err(RevoraError::LockupViolation); + } + } + + 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); + + 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); + } + } + } + } + } + + // ── Per-jurisdiction transfer cooldown check ── + // Look up the `from` holder's jurisdiction and check whether a cooldown + // is configured for that jurisdiction. If so, verify the required time + // has elapsed since the holder's last transfer. + let jurisdiction = + Self::get_holder_jurisdiction_internal(env, &offering_id, from); + if let Some(jur) = jurisdiction { + if jur != EVENT_JUR_UNSET { + let cooldown_key = + DataKey2::TransferCooldownConfig(offering_id.clone(), jur.clone()); + if let Some(cooldown_secs) = env + .storage() + .persistent() + .get::(&cooldown_key) + { + if cooldown_secs > 0 { + let last_xfer_key = DataKey2::HolderLastTransferTime( + offering_id.clone(), + from.clone(), + ); + let last_xfer: u64 = + env.storage().persistent().get(&last_xfer_key).unwrap_or(0); + let now = env.ledger().timestamp(); + if now < last_xfer.saturating_add(cooldown_secs) { + return Err(RevoraError::TransferCooldownActive); + } + } } + } + } - // Store pending request - let pending = PendingRedemption { shares_bps, timestamp: env.ledger().timestamp() }; - env.storage().persistent().set(&request_key, &pending); - - // Emit event - env.events() - .publish((EVENT_REDEMPTION_REQUESTED, issuer, namespace, token), (holder, shares_bps)); - Ok(()) - } - - /// Fulfill a pending redemption request. - /// - /// The issuer transfers `amount` of the offering's locked payment token from - /// the contract to the holder and reduces the holder's share by the requested - /// `shares_bps`. The redemption window must be open. Blacklisted holders are - /// rejected even if they had a pending request. - pub fn fulfill_redemption( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - amount: i128, - ) -> Result { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Verify caller is the current issuer - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + Ok(()) + } + + pub fn transfer_with_attestation( + 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>, + nonce: u64, + expires_at: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let active_network_id = env.ledger().network_id(); + if network_id != active_network_id { + return Err(RevoraError::NetworkIdMismatch); + } + + // Guard 3 — self-transfer is never permitted. + if from == to { + return Err(RevoraError::InvalidTransferParticipants); + } + + // Guard 10 — transferring zero shares is nonsensical and likely a caller bug. + if amount_bps == 0 { + return Err(RevoraError::InvalidShareBps); + } + + // Guard 4 — offering must exist and the supplied issuer must be its primary issuer. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Check redemption window is open - Self::require_redemption_window_open(&env, &offering_id)?; + // Offering-level freeze check + if env + .storage() + .persistent() + .get::(&DataKey2::FrozenOffering(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::OfferingFrozen); + } - // Reject blacklisted holders even if they had a pending request - if Self::is_blacklisted( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), - ) { - return Err(RevoraError::HolderBlacklisted); - } + // Dual-party authorization: both from and to must sign + from.require_auth(); + to.require_auth(); - // Validate amount - if amount <= 0 { - return Err(RevoraError::InvalidAmount); - } + // Attestation nonce/expiry validation: check but do NOT consume yet. + // Consumption happens only after all guards pass (see mark at the end). + // When expires_at is 0 (internal callers without attestation context, e.g. atomic_swap) + // the nonce/expiry check is skipped. + if expires_at > 0 { + if env.ledger().timestamp() > expires_at { + return Err(RevoraError::SignatureExpired); + } + let nonce_key = DataKey2::AttestationNonceUsed(from.clone(), nonce); + if env.storage().persistent().has(&nonce_key) { + return Err(RevoraError::SignatureReplay); + } + } - // Read pending request - let request_key = DataKey2::RedemptionRequest(offering_id.clone(), holder.clone()); - let pending: PendingRedemption = - env.storage().persistent().get(&request_key).ok_or(RevoraError::NoTransferPending)?; + if from == to { + return Ok(()); + } - // Read holder's current share - let current_share = Self::get_holder_share_internal( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), + // Lockup violation check + 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()), ); - if current_share == 0 { - return Err(RevoraError::NoPendingClaims); - } + return Err(RevoraError::LockupViolation); + } + } - // Compute effective redeem bps (capped to what holder actually has) - let redeem_bps = core::cmp::min(pending.shares_bps, current_share); - let new_share = current_share - redeem_bps; + // Zero-value transfer is meaningless + if amount_bps == 0 { + return Err(RevoraError::InvalidAmount); + } - // Transfer amount (minus redemption fee if configured) from contract to holder - let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id) - .ok_or(RevoraError::PaymentTokenMismatch)?; - let contract_addr = env.current_contract_address(); + // 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); + } - let fee_config = Self::get_redemption_fee_config( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - ); - let (net_amount, fee_amount, treasury_addr) = if let Some(cfg) = fee_config { - if cfg.fee_bps > 0 { - let fee = amount.checked_mul(cfg.fee_bps as i128).unwrap_or(0) / 10_000i128; - let net = amount.saturating_sub(fee); - (net, fee, Some(cfg.treasury)) - } else { - (amount, 0i128, None) - } - } else { - (amount, 0i128, None) - }; + // Whitelist enforcement + if Self::is_whitelist_enabled( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) { + if !Self::is_whitelisted( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + from.clone(), + ) || !Self::is_whitelisted( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + to.clone(), + ) { + return Err(RevoraError::NotAuthorized); + } + } + + // Jurisdiction block + Self::require_holder_jurisdiction_allowed( + env.clone(), + &offering_id, + to.clone(), + 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); + } + + // Guard 9 — recipient share cap: `to`'s resulting share must not exceed 10 000 bps. + let to_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), to.clone())) + .unwrap_or(0); + if to_share.checked_add(amount_bps).unwrap_or(u32::MAX) > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + + // All guards passed — apply the share transfer atomically. + Self::set_holder_share_internal( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + from.clone(), + from_share - amount_bps, + None, + None, + )?; + Self::set_holder_share_internal( + &env, + issuer, + namespace, + token, + to, + to_share + amount_bps, + None, + None, + )?; + + // ── Record last transfer timestamp for cooldown enforcement ── + // Update the `from` holder's last transfer timestamp so that subsequent + // transfers by the same holder are gated by the jurisdiction cooldown. + // Only record when `from` retains shares (moved some, kept some) or + // when `from` still has a jurisdiction set, so that the cooldown state + // is meaningful. + let now = env.ledger().timestamp(); + let jur = Self::get_holder_jurisdiction_internal(&env, &offering_id, &from); + if let Some(j) = jur { + if j != EVENT_JUR_UNSET { + let cooldown_key = + DataKey2::TransferCooldownConfig(offering_id.clone(), j.clone()); + if env.storage().persistent().has(&cooldown_key) { + let last_xfer_key = + DataKey2::HolderLastTransferTime(offering_id.clone(), from.clone()); + env.storage().persistent().set(&last_xfer_key, &now); + } + } + } - let token_client = token::Client::new(&env, &payment_token); - if net_amount > 0 - && token_client.try_transfer(&contract_addr, &holder, &net_amount).is_err() - { - return Err(RevoraError::TransferFailed); - } + Ok(()) + } + + /// Compute the canonical domain-separated attestation digest for a share transfer. + /// + /// Off-chain signers use this protocol to produce the `attest_hash` that must be + /// passed to `transfer_with_attestation`. The digest commits to the current + /// **network's identity** (`network_id`) so an attestation signed for testnet is + /// **cryptographically incompatible** with mainnet (closes #578). + /// + /// ## Digest construction + /// + /// ```text + /// digest = sha256( + /// network_id (32 bytes — sha256 of 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) + /// ) + /// ``` + /// + /// ## Parameters + /// - All parameters mirror `transfer_with_attestation`. + /// + /// ## Returns + /// - `Ok(BytesN<32>)` — the expected digest for the current chain. + /// + /// ## Usage + /// + /// Off-chain: + /// 1. Collect the transfer parameters. + /// 2. Call this function (read-only) to retrieve the expected digest. + /// 3. Have the authorised signer sign / produce the digest. + /// 4. Pass the returned hash as `attest_hash` to `transfer_with_attestation`. + /// + /// On-chain validation: + /// Call `verify_attestation_digest` with a `SignedAttestation` to assert that a + /// previously produced hash is still valid for the current network. + pub fn compute_attestation_digest( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from: Address, + to: Address, + amount_bps: u32, + ) -> BytesN<32> { + Self::build_attestation_digest( + &env, &issuer, &namespace, &token, &from, &to, amount_bps, + ) + } + + /// Verify that a `SignedAttestation`'s embedded `network_id` matches the current chain. + /// + /// Returns `Ok(())` when the attestation is valid for this chain and the digest matches + /// the expected domain-separated hash of the transfer parameters. + /// Returns `Err(NetworkIdMismatch)` when the attestation was produced for a different + /// network (e.g. testnet attestation replayed on mainnet). + /// + /// ## Security note + /// + /// This function is **read-only** — it does not transfer shares or write any state. + /// It is intended as a pre-flight check before calling `transfer_with_attestation`. + /// + /// ## Parameters + /// - `attestation`: A `SignedAttestation` containing the signer's `network_id` and `digest`. + /// - `issuer` / `namespace` / `token` / `from` / `to` / `amount_bps`: Transfer parameters + /// whose canonical digest the attestation must cover. + /// + /// ## Returns + /// - `Ok(())` if `attestation.network_id == env.ledger().network_id()` AND + /// `attestation.digest == sha256(network_id || issuer || ... || amount_bps)`. + /// - `Err(RevoraError::NetworkIdMismatch)` otherwise. + pub fn verify_attestation_digest( + env: Env, + attestation: SignedAttestation, + issuer: Address, + namespace: Symbol, + token: Address, + from: Address, + to: Address, + amount_bps: u32, + ) -> Result<(), RevoraError> { + // Step 1 — the network_id embedded in the attestation must match the current ledger. + let chain_network_id = env.ledger().network_id(); + if attestation.network_id != chain_network_id { + return Err(RevoraError::NetworkIdMismatch); + } + + // Step 2 — the digest must match the canonical preimage for these parameters. + let expected = Self::build_attestation_digest( + &env, &issuer, &namespace, &token, &from, &to, amount_bps, + ); + if attestation.digest != expected { + return Err(RevoraError::NetworkIdMismatch); + } - if fee_amount > 0 { - if let Some(treasury) = treasury_addr { - if token_client.try_transfer(&contract_addr, &treasury, &fee_amount).is_err() { - return Err(RevoraError::TransferFailed); - } - env.events().publish( - (EVENT_REDEMPTION_FEE, issuer.clone(), namespace.clone(), token.clone()), - (holder.clone(), treasury, fee_amount, net_amount), - ); - } - } + Ok(()) + } + + /// Internal helper: compute the canonical attestation digest. + /// + /// `sha256(network_id || XDR(issuer) || XDR(namespace) || XDR(token) + /// || XDR(from) || XDR(to) || XDR(amount_bps))` + fn build_attestation_digest( + env: &Env, + issuer: &Address, + namespace: &Symbol, + token: &Address, + from: &Address, + to: &Address, + amount_bps: u32, + ) -> BytesN<32> { + let chain_network_id = env.ledger().network_id(); + + let mut preimage = Bytes::new(env); + // Prefix with the 32-byte network id so the digest is chain-specific. + for b in chain_network_id.iter() { + preimage.push_back(b); + } + preimage.append(&issuer.to_xdr(env)); + preimage.append(&namespace.to_xdr(env)); + preimage.append(&token.to_xdr(env)); + preimage.append(&from.to_xdr(env)); + preimage.append(&to.to_xdr(env)); + // Encode amount_bps as 4 big-endian bytes (canonical u32 serialisation). + let bps_bytes = amount_bps.to_be_bytes(); + for b in bps_bytes.iter() { + preimage.push_back(*b); + } + + env.crypto().sha256(&preimage) + } + + /// Report the current top-holder concentration for an offering. + /// + /// a `conc_warn` event is emitted. The stored value is used for enforcement in `report_revenue`. + /// + /// ### Enforcement Boundary + /// - If `enforce` is true in `ConcentrationLimitConfig`: + /// - `concentration_bps <= max_bps`: `report_revenue` is allowed. + /// - `concentration_bps > max_bps`: `report_revenue` is rejected. + /// + /// ### Parameters + /// - `issuer`: The offering issuer. Must provide authentication. + /// - `token`: The token representing the offering. + /// - `concentration_bps`: The current top-holder share in basis points. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + pub fn report_concentration( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + concentration_bps: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + if concentration_bps > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Reduce holder's share by the redeemed bps - Self::set_holder_share_internal( - &env, + let limit_key = DataKey::ConcentrationLimit(offering_id.clone()); + if let Some(config) = env + .storage() + .persistent() + .get::(&limit_key) + { + if config.max_bps > 0 && concentration_bps > config.max_bps { + env.events().publish( + ( + EVENT_CONCENTRATION_WARNING, issuer.clone(), namespace.clone(), token.clone(), - holder.clone(), - new_share, - None, - None, - )?; + ), + (concentration_bps, config.max_bps), + ); + } + } + + if !Self::is_event_only(&env) { + env.storage().persistent().set( + &DataKey::CurrentConcentration(offering_id.clone()), + &concentration_bps, + ); + env.storage().persistent().set( + &DataKey::ConcentrationReportedAt(offering_id.clone()), + &env.ledger().timestamp(), + ); + env.events().publish( + (EVENT_CONCENTRATION_REPORTED, issuer, namespace, token), + concentration_bps, + ); + } + Ok(()) + } + + /// Get concentration limit config for an offering. + pub fn get_concentration_limit( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::ConcentrationLimit(offering_id); + env.storage().persistent().get(&key) + } + + /// Get last reported concentration in bps for an offering. + pub fn get_current_concentration( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::CurrentConcentration(offering_id); + env.storage().persistent().get(&key) + } + + // ── Audit log summary (#34) ──────────────────────────────── + + /// Get per-offering audit summary (total revenue and report count). + pub fn get_audit_summary( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::AuditSummary(offering_id); + env.storage().persistent().get(&key) + } + + /// Set rounding mode for an offering. Default is truncation. + /// + /// ### Auth ordering + /// `issuer.require_auth()` is called immediately after the frozen guard so that + /// unauthenticated callers cannot probe offering existence or trigger side effects. + pub fn set_rounding_mode( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + mode: RoundingMode, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + + // Auth-first: authenticate before any state reads. + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let key = DataKey::RoundingMode(offering_id); + env.storage().persistent().set(&key, &mode); + Self::emit_v2_event( + &env, + (EVENT_ROUNDING_MODE_SET, issuer, namespace, token), + mode, + ); + Ok(()) + } + + /// Get rounding mode for an offering. + pub fn get_rounding_mode( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> RoundingMode { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::RoundingMode(offering_id); + env + .storage() + .persistent() + .get(&key) + .unwrap_or(RoundingMode::Truncation) + } + + // ── Per-offering investment constraints (#97) ───────────── + + /// Set min and max stake per investor for an offering. Issuer/admin only. Constraints are read by off-chain systems for enforcement. + /// Validates amounts using the Negative Amount Validation Matrix (#163). + /// + /// ### Auth ordering + /// `issuer.require_auth()` is called immediately after the frozen guard, before any state reads. + pub fn set_investment_constraints( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + min_stake: i128, + max_stake: i128, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + + // Auth-first: authenticate before any state reads. + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Remove the pending request - env.storage().persistent().remove(&request_key); + // Negative Amount Validation Matrix: InvestmentMinStake requires >= 0 (#163) + if let Err((err, _)) = AmountValidationMatrix::validate( + min_stake, + AmountValidationCategory::InvestmentMinStake, + ) { + return Err(err); + } - // Emit fulfillment event - env.events().publish( - (EVENT_REDEMPTION_FULFILLED, issuer, namespace, token), - (holder, redeem_bps, amount), - ); - Ok(amount) - } - - /// Set the redemption fee configuration for an offering. - /// - /// Auth: Issuer only. Must be current issuer of a registered offering. - /// Fee must not exceed `MAX_REDEMPTION_FEE_BPS` (5 000 BPS / 50%). - #[allow(clippy::too_many_arguments)] - pub fn set_redemption_fee_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - fee_bps: u32, - treasury: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); + // Negative Amount Validation Matrix: InvestmentMaxStake requires >= 0 (#163) + if let Err((err, _)) = AmountValidationMatrix::validate( + max_stake, + AmountValidationCategory::InvestmentMaxStake, + ) { + return Err(err); + } + + // Validate range: max_stake >= min_stake when max_stake > 0 + AmountValidationMatrix::validate_stake_range(min_stake, max_stake)?; + + let key = DataKey2::InvestmentConstraints(offering_id); + let previous = env + .storage() + .persistent() + .get::(&key); + env.storage().persistent().set( + &key, + &InvestmentConstraintsConfig { + min_stake, + max_stake, + }, + ); + Self::emit_v2_event( + &env, + (EVENT_INV_CONSTRAINTS, issuer, namespace, token), + (min_stake, max_stake, previous.is_some()), + ); + Ok(()) + } + + /// Get per-offering investment constraints. Returns None if not set. + pub fn get_investment_constraints( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::InvestmentConstraints(offering_id); + env.storage().persistent().get(&key) + } + + // ── Per-offering minimum revenue threshold (#25) ───────────────────── + + /// Set minimum revenue per period below which no distribution is triggered. + /// Only the offering issuer may set this. Emits event when configured or changed. + /// Pass 0 to disable the threshold. + /// Validates amount using the Negative Amount Validation Matrix (#163). + /// + /// ### Auth ordering + /// `issuer.require_auth()` is called immediately after the frozen guard, before any state reads. + pub fn set_min_revenue_threshold( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + min_amount: i128, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + + // Auth-first: authenticate before any state reads. + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + // Negative Amount Validation Matrix: MinRevenueThreshold requires >= 0 (#163) + if let Err((err, _)) = AmountValidationMatrix::validate( + min_amount, + AmountValidationCategory::MinRevenueThreshold, + ) { + return Err(err); + } - // Verify offering exists and caller is current issuer - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + let key = DataKey2::MinRevenueThreshold(offering_id); + let previous: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &min_amount); - // Validate fee_bps cap - if fee_bps > MAX_REDEMPTION_FEE_BPS { - return Err(RevoraError::InvalidRevenueShareBps); - } + Self::emit_v2_event( + &env, + (EVENT_MIN_REV_THRESHOLD_SET, issuer, namespace, token), + (previous, min_amount), + ); + Ok(()) + } + + /// Get minimum revenue threshold for an offering. 0 means no threshold. + pub fn get_min_revenue_threshold( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> i128 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::MinRevenueThreshold(offering_id); + env.storage().persistent().get(&key).unwrap_or(0) + } + + /// Set the per-offering checkpoint compression threshold. + /// + /// When a holder's share schedule length exceeds `threshold` the + /// oldest entries are folded into an `AccrualAnchor` and pruned + /// from the schedule. The anchor stores a lossless pre-computed + /// sum of claimable amounts for the compressed period range. + /// + /// Pass `0` to disable compression (the schedule will never be pruned). + /// + /// ### Auth + /// Requires `issuer.require_auth()`. The caller must be the current + /// issuer of the offering. + /// + /// ### Errors + /// - [`RevoraError::OfferingNotFound`] â the offering does not exist + /// or the caller is not the current issuer. + /// - [`RevoraError::NotAuthorized`] â the caller is not the issuer. + pub fn set_checkpoint_threshold( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + threshold: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let config = RedemptionFeeConfig { fee_bps, treasury: treasury.clone() }; - let key = DataKey2::RedemptionFeeConfig(offering_id); - env.storage().persistent().set(&key, &config); - - env.events() - .publish((EVENT_REDEMPTION_FEE_SET, issuer, namespace, token), (fee_bps, treasury)); - Ok(()) - } - - /// Return the stored redemption fee configuration for an offering. - pub fn get_redemption_fee_config( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::RedemptionFeeConfig(offering_id); - env.storage().persistent().get(&key) - } - - /// Return the configured redemption fee BPS for an offering (0 if unset). - pub fn get_redemption_fee_bps( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u32 { - Self::get_redemption_fee_config(env, issuer, namespace, token) - .map(|cfg| cfg.fee_bps) + let key = DataKey2::CheckpointThreshold(offering_id); + let previous: u32 = env + .storage() + .persistent() + .get(&key) + .unwrap_or(CHECKPOINT_THRESHOLD_DEFAULT); + env.storage().persistent().set(&key, &threshold); + + Self::emit_v2_event( + &env, + (symbol_short!("chk_pt"), issuer, namespace, token), + (previous, threshold), + ); + Ok(()) + } + + /// Get the checkpoint compression threshold for an offering. + /// + /// Returns the configured threshold, or [`CHECKPOINT_THRESHOLD_DEFAULT`] + /// when no explicit threshold has been set for this offering. + pub fn get_checkpoint_threshold( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::CheckpointThreshold(offering_id); + env + .storage() + .persistent() + .get(&key) + .unwrap_or(CHECKPOINT_THRESHOLD_DEFAULT) + } + + /// Compute share of `amount` at `revenue_share_bps` using the given rounding mode. + /// Security assumptions: + /// - Callers should pass `revenue_share_bps` in [0, 10_000]. Values above 10_000 are rejected by returning 0. + /// - Revenue flows in this contract are non-negative, but this helper is total over signed `amount` for testability. + /// + /// Guarantees: + /// - Overflow-resistant arithmetic without panic. + /// - Result is clamped to [min(0, amount), max(0, amount)] to avoid over-distribution. + /// + /// ## Decomposition Bound + /// + /// The function decomposes `amount` as `amount = q * 10_000 + r` where: + /// - `q = amount / 10_000` (quotient) + /// - `r = amount % 10_000` (remainder, bounded to `|r| < 10_000`) + /// + /// This ensures: + /// - `|r * bps| < 10_000 * 10_000 = 10^8` (well within i128 range) + /// - The remainder product uses `checked_mul` with saturating fallback for defense-in-depth + /// - Even if the bound assumption is violated by refactors, saturation prevents overflow + pub fn compute_share( + _env: Env, + amount: i128, + revenue_share_bps: u32, + mode: RoundingMode, + ) -> i128 { + if revenue_share_bps > 10_000 { + return 0; + } + if amount == 0 || revenue_share_bps == 0 { + return 0; + } + + // Decompose `amount` to avoid `amount * bps` overflow: + // amount = q * 10_000 + r, so (amount * bps) / 10_000 = q * bps + (r * bps) / 10_000. + // `r` is bounded to (-10_000, 10_000), so `r * bps` is always safe in i128. + // Defense-in-depth: use s_mul with saturating fallback to guard against refactors. + let q = amount.s_div(10_000).unwrap_or(0); + let r = amount % 10_000; + let bps = revenue_share_bps as i128; + let base = q.s_mul(bps).unwrap_or_else(|_| { + if (q >= 0 && bps >= 0) || (q < 0 && bps < 0) { + i128::MAX + } else { + i128::MIN + } + }); + + let remainder_product = r.s_mul(bps).unwrap_or_else(|_| { + if (r >= 0 && bps >= 0) || (r < 0 && bps < 0) { + i128::MAX + } else { + i128::MIN + } + }); + let remainder_share = match mode { + RoundingMode::Truncation => remainder_product.s_div(10_000).unwrap_or(0), + RoundingMode::RoundHalfUp => { + let half = 5_000_i128; + if remainder_product >= 0 { + remainder_product + .s_add(half) + .unwrap_or(i128::MAX) + .s_div(10_000) + .unwrap_or(0) + } else { + remainder_product + .s_sub(half) + .unwrap_or(i128::MIN) + .s_div(10_000) .unwrap_or(0) + } + } + }; + + let share = base.s_add(remainder_share).unwrap_or_else(|_| { + if (base >= 0 && remainder_share >= 0) + || (base < 0 && remainder_share < 0) + { + if base >= 0 { + i128::MAX + } else { + i128::MIN + } + } else { + 0 + } + }); + + // Clamp to [min(0, amount), max(0, amount)] to avoid overflow semantics affecting bounds + let lo = core::cmp::min(0, amount); + let hi = core::cmp::max(0, amount); + core::cmp::min(core::cmp::max(share, lo), hi) + } + + /// Normalize `amount` from the token's native decimal precision to Stellar's canonical 7-decimal + /// (stroop) precision used internally by this contract. + /// + /// - If `from_decimals == 7`: returns `amount` unchanged. + /// - If `from_decimals < 7`: scales **up** by `10^(7 - from_decimals)` (e.g., 6-decimal USDC → 7). + /// - If `from_decimals > 7`: scales **down** by `10^(from_decimals - 7)` using integer truncation. + /// + /// Returns `0` if intermediate arithmetic overflows to prevent fund inflation bugs. + fn normalize_amount(amount: i128, from_decimals: u32) -> i128 { + if from_decimals == STELLAR_CANONICAL_DECIMALS { + return amount; + } + if from_decimals < STELLAR_CANONICAL_DECIMALS { + let exp = STELLAR_CANONICAL_DECIMALS - from_decimals; + let factor: i128 = match 10_i128.checked_pow(exp) { + Some(f) => f, + None => return 0, + }; + amount.checked_mul(factor).unwrap_or(0) + } else { + let exp = from_decimals - STELLAR_CANONICAL_DECIMALS; + let factor: i128 = match 10_i128.checked_pow(exp) { + Some(f) => f, + None => return 0, + }; + amount.checked_div(factor).unwrap_or(0) + } + } + + /// Set the decimal precision of the payout asset for an offering. + /// + /// Must be called by the offering `issuer`. Accepted range is `0..=18`. + /// If not set, the contract defaults to `7` (Stellar canonical stroops). + /// + /// ### Security + /// - Only the offering issuer may configure decimals. + /// - Misconfigured decimals directly affect payout arithmetic; issuers must supply + /// the on-chain token's actual decimal value. + /// + /// ### Errors + /// - `RevoraError::NotAuthorized` if caller is not the issuer. + /// - `RevoraError::LimitReached` if `decimals > 18`. + pub fn set_payment_token_decimals( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + decimals: u32, + ) -> Result<(), RevoraError> { + if decimals > MAX_TOKEN_DECIMALS { + return Err(RevoraError::LimitReached); + } + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env + .storage() + .persistent() + .set(&DataKey2::PaymentTokenDecimals(offering_id), &decimals); + env + .events() + .publish((EVENT_DECIMAL_SET, issuer, namespace, token), decimals); + Ok(()) + } + + /// Get the configured decimal precision of the payout asset for an offering. + /// Defaults to `7` (Stellar canonical stroops) if not explicitly set. + pub fn get_payment_token_decimals( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::PaymentTokenDecimals(offering_id)) + .unwrap_or(STELLAR_CANONICAL_DECIMALS) + } + + // ── Multi-period aggregated claims ─────────────────────────── + + /// Deposit revenue for a specific period of an offering. + /// + /// # Arguments + /// * `issuer` - The address of the offering issuer. + /// * `namespace` - A symbol identifying the namespace. + /// * `token` - The address of the token. + /// * `payment_token` - The address of the token used for payment. + /// * `amount` - The amount of revenue to deposit. + /// * `period_id` - The identifier for the revenue period. + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::OfferingNotFound)` if the offering is not found. + /// - `Err(RevoraError::PeriodAlreadyDeposited)` if revenue has already been deposited for this `period_id`. + /// - `Err(RevoraError::PaymentTokenMismatch)` if `payment_token` differs from the token locked by the first successful deposit. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + pub fn deposit_revenue( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + payment_token: Address, + amount: i128, + period_id: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + // Input validation (#35): reject zero/invalid period_id and non-positive amounts. + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + Self::require_positive_amount(amount)?; + + // Verify offering exists and issuer is current + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + Self::require_not_frozen(&env)?; + + Self::do_deposit_revenue( + &env, + issuer, + namespace, + token, + payment_token, + amount, + period_id, + ) + } + + /// any previously recorded snapshot for this offering to prevent duplication. + /// Validates amount and snapshot reference using the Negative Amount Validation Matrix (#163). + #[allow(clippy::too_many_arguments)] + pub fn deposit_revenue_with_snapshot( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + payment_token: Address, + amount: i128, + period_id: u64, + snapshot_reference: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + // 0. Validate snapshot reference using Negative Amount Validation Matrix (#163) + // SnapshotReference requires > 0 and strictly increasing + if let Err((err, _)) = AmountValidationMatrix::validate( + snapshot_reference as i128, + AmountValidationCategory::SnapshotReference, + ) { + return Err(err); } - /// Configure the lockup schedule for an offering. - /// - /// Auth: Issuer only. Must be current issuer of a registered offering. - pub fn set_lockup_schedule( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - schedule: LockupSchedule, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); + // 1. Verify snapshots are enabled + if !Self::get_snapshot_config( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) { + return Err(RevoraError::SnapshotNotEnabled); + } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - schedule.validate()?; - - let key = DataKey2::LockupSchedule(offering_id); - env.storage().persistent().set(&key, &schedule); - - env.events().publish((EVENT_LOCKUP_SET, issuer, namespace, token), schedule); - Ok(()) - } - - /// Return the stored lockup schedule for an offering. - pub fn get_lockup_schedule( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey2::LockupSchedule(offering_id); - env.storage().persistent().get(&key) - } - - /// Return the unlocked BPS (0 to 10 000) for an offering at the current ledger timestamp. - /// - /// Returns 10 000 (100% unlocked) if no lockup schedule is configured. - pub fn get_unlocked_bps(env: Env, issuer: Address, namespace: Symbol, token: Address) -> u32 { - let schedule = Self::get_lockup_schedule(env.clone(), issuer, namespace, token); - let now = env.ledger().timestamp(); - schedule.map(|s| s.calculate_unlocked_bps(now)).unwrap_or(10_000) - } - - /// Extend the taper end timestamp of a lockup schedule with holder consent. - /// - /// Insider lockups sometimes need to be extended for regulatory or contractual - /// reasons. This entrypoint pushes `taper_end_ts` forward; it may **never** - /// shorten an existing lockup. The holder must sign an off-chain attestation - /// consenting to the extension. - /// - /// # Parameters - /// - /// * `issuer` — the offering issuer (must authorise). - /// * `namespace` / `token` — identify the offering. - /// * `holder` — the locked holder who consents to the extension. - /// * `new_taper_end_ts` — new taper end timestamp (must be > current `taper_end_ts`). - /// * `attestation` — a [`SignedAttestation`] committing to the extension parameters. - /// - /// # Returns - /// - /// * `Ok(())` — the extension was applied and persisted. - /// * `Err(RevoraError::InvalidAmount)` — `new_taper_end_ts <= current taper_end_ts` - /// or no lockup schedule exists for this offering. - /// * `Err(RevoraError::NetworkIdMismatch)` — the attestation was produced for a - /// different network. - /// - /// # Events - /// - /// Emits [`EVENT_LOCKUP_EXTEND`] with `(offering_id, new_taper_end_ts, attestation.digest)`. - #[allow(clippy::too_many_arguments)] - pub fn extend_lockup( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - new_taper_end_ts: u64, - attestation: SignedAttestation, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); + if Self::snapshot_finalization_required(env.clone()) + && !Self::is_snapshot_finalized(&env, &offering_id, snapshot_reference) + { + return Err(RevoraError::SnapshotNotFinalized); + } + + Self::require_not_frozen(&env)?; + + // 2. Validate snapshot reference is strictly monotonic using matrix helper + let snap_key = DataKey::LastSnapshotRef(offering_id.clone()); + let last_snap: u64 = env.storage().persistent().get(&snap_key).unwrap_or(0); + AmountValidationMatrix::validate_snapshot_monotonic( + snapshot_reference as i128, + last_snap as i128, + )?; + + // 3. Delegate to core deposit logic (includes RevenueDeposit validation) + Self::do_deposit_revenue( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + payment_token.clone(), + amount, + period_id, + )?; + + // 4. Update last snapshot and emit specialized event + env + .storage() + .persistent() + .set(&snap_key, &snapshot_reference); + // Versioned event v2: [version: u32, payment_token: Address, amount: i128, period_id: u64, snapshot_reference: u64] + Self::emit_v2_event( + &env, + ( + EVENT_REV_DEP_SNAP_V2, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (payment_token, amount, period_id, snapshot_reference), + ); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + Ok(()) + } + + /// Enable or disable snapshot-based distribution for an offering. + pub fn set_snapshot_config( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + enabled: bool, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + Self::require_not_frozen(&env)?; + let key = DataKey::SnapshotConfig(offering_id.clone()); + env.storage().persistent().set(&key, &enabled); + env.events().publish( + ( + EVENT_SNAP_CONFIG, + offering_id.issuer, + offering_id.namespace, + offering_id.token, + ), + enabled, + ); + Ok(()) + } + + /// Check if snapshot-based distribution is enabled for an offering. + pub fn get_snapshot_config( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> bool { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::SnapshotConfig(offering_id); + env.storage().persistent().get(&key).unwrap_or(false) + } + + /// Get the latest recorded snapshot reference for an offering. + pub fn get_last_snapshot_ref( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u64 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let deposit_ref: u64 = env + .storage() + .persistent() + .get(&DataKey::LastSnapshotRef(offering_id.clone())) + .unwrap_or(0); + let commit_ref: u64 = env + .storage() + .persistent() + .get(&DataKey::LastSnapshotCommitRef(offering_id)) + .unwrap_or(0); + if deposit_ref > commit_ref { + deposit_ref + } else { + commit_ref + } + } + + // ── Deterministic Snapshot Expansion (#054) ────────────────────────────── + // + // Design: + // A "snapshot" is an immutable, write-once record that captures the + // canonical holder-share distribution at a specific point in time. + // + // Workflow: + // 1. Issuer calls `commit_snapshot` with a strictly-increasing `snapshot_ref` + // and a 32-byte `content_hash` of the off-chain holder dataset. + // The contract stores a `SnapshotEntry` and emits `snap_com`. + // 2. Issuer calls `apply_snapshot_shares` (one or more times) to write + // holder shares for this snapshot into persistent storage. + // Each call appends a bounded batch of (holder, share_bps) pairs. + // Emits `snap_shr` per batch. + // 3. Issuer calls `deposit_revenue_with_snapshot` (existing) to deposit + // revenue tied to this snapshot_ref. + // + // Security assumptions: + // - `content_hash` is caller-supplied and stored verbatim. The contract + // does NOT verify it matches the on-chain holder entries. Off-chain + // consumers MUST recompute and compare the hash. + // - Snapshot refs are strictly monotonic per offering; replay is impossible. + // - `apply_snapshot_shares` is idempotent per (snapshot_ref, index): writing + // the same index twice overwrites with the same value (no double-credit). + // - Only the current offering issuer may commit or apply snapshots. + // - Frozen/paused contract blocks all snapshot writes. + + /// Maximum holders per `apply_snapshot_shares` batch. + /// Keeps per-call compute bounded within Soroban limits. + const MAX_SNAPSHOT_BATCH: u32 = 50; + + /// Commit a new snapshot entry for an offering. + /// + /// Records an immutable `SnapshotEntry` keyed by `(offering_id, snapshot_ref)`. + /// `snapshot_ref` must be strictly greater than the last committed ref for this + /// offering (monotonicity invariant). The `content_hash` is a 32-byte digest of + /// the off-chain holder-share dataset; it is stored verbatim and not verified + /// on-chain. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. Only the current offering issuer may commit. + /// + /// ### Errors + /// - `OfferingNotFound`: offering does not exist or caller is not current issuer. + /// - `SnapshotNotEnabled`: snapshot distribution is not enabled for this offering. + /// - `OutdatedSnapshot`: `snapshot_ref` ≤ last committed ref (replay / stale). + /// - `ContractFrozen` / paused: contract is not operational. + /// + /// ### Events + /// Emits `snap_com` with `(issuer, namespace, token)` topics and + /// `(snapshot_ref, content_hash, committed_at)` data. + pub fn commit_snapshot( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: u64, + content_hash: BytesN<32>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + // Verify offering exists and caller is current issuer. + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + // Snapshot distribution must be enabled for this offering. + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + if !env + .storage() + .persistent() + .get::(&DataKey::SnapshotConfig(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::SnapshotNotEnabled); + } - // ── Verify attestation ─────────────────────────────────────────────── - Self::verify_attestation_digest( - env.clone(), - attestation.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), - holder.clone(), - 0, - )?; + // Enforce strict monotonicity: snapshot_ref must exceed the last committed ref. + let last_ref_key = DataKey::LastSnapshotCommitRef(offering_id.clone()); + let last_ref: u64 = + env.storage().persistent().get(&last_ref_key).unwrap_or(0); + if snapshot_ref <= last_ref { + return Err(RevoraError::OutdatedSnapshot); + } - // ── Fetch and validate existing schedule ───────────────────────────── - let key = DataKey2::LockupSchedule(offering_id.clone()); - let existing: LockupSchedule = - env.storage().persistent().get(&key).ok_or(RevoraError::InvalidAmount)?; - - // ── Monotonic extension check ─────────────────────────────────────── - // Match on the existing variant and apply the extension. - let updated = match existing { - LockupSchedule::CliffTaper { cliff_ts, cliff_bps, taper_end_ts } => { - if new_taper_end_ts <= taper_end_ts { - return Err(RevoraError::InvalidAmount); - } - LockupSchedule::CliffTaper { cliff_ts, cliff_bps, taper_end_ts: new_taper_end_ts } - } - }; + let committed_at = env.ledger().timestamp(); + let entry = SnapshotEntry { + snapshot_ref, + committed_at, + content_hash: content_hash.clone(), + holder_count: 0, + total_bps: 0, + }; - env.storage().persistent().set(&key, &updated); + // Write-once: store the entry and advance the last-ref pointer atomically. + env.storage().persistent().set( + &DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref), + &entry, + ); + env.storage().persistent().set(&last_ref_key, &snapshot_ref); + + env.events().publish( + (EVENT_SNAP_COMMIT, issuer, namespace, token), + (snapshot_ref, content_hash, committed_at), + ); + Ok(()) + } + + /// Retrieve a committed snapshot entry. + /// + /// Returns `None` if no snapshot with `snapshot_ref` has been committed for this offering. + pub fn get_snapshot_entry( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: u64, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey::SnapshotEntry(offering_id, snapshot_ref)) + } + + /// Apply a batch of holder shares for a committed snapshot. + /// + /// Writes `(holder, share_bps)` pairs into persistent storage indexed by + /// `(offering_id, snapshot_ref, sequential_index)`. Batches are bounded by + /// `MAX_SNAPSHOT_BATCH` (50) per call. Updates `HolderShare` for each holder. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. Only the current offering issuer may apply. + /// + /// ### Errors + /// - `OfferingNotFound`, `SnapshotNotEnabled`, `OutdatedSnapshot`, + /// `LimitReached`, `InvalidShareBps`, `ContractFrozen`. + pub fn apply_snapshot_shares( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: u64, + start_index: u32, + holders: Vec<(Address, u32)>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + if !env + .storage() + .persistent() + .get::(&DataKey::SnapshotConfig(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::SnapshotNotEnabled); + } + + // Snapshot must have been committed first. + let entry_key = DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref); + let mut entry: SnapshotEntry = env + .storage() + .persistent() + .get(&entry_key) + .ok_or(RevoraError::OutdatedSnapshot)?; + + let batch_len = holders.len(); + if batch_len > Self::MAX_SNAPSHOT_BATCH { + return Err(RevoraError::LimitReached); + } + + // Validate all share_bps and jurisdiction rules before writing anything (fail-fast). + for i in 0..batch_len { + let (holder, share_bps) = holders.get(i).unwrap(); + if share_bps > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + Self::require_holder_jurisdiction_allowed( + &env, + &offering_id, + &holder, + EVENT_JUR_ACTION_SNAPSHOT, + )?; + } + + let mut added_bps: u32 = 0; + + // Maintain per-offering running total and validate aggregate cap. + let total_key = DataKey::HolderShareTotal(offering_id.clone()); + let mut current_total: u32 = + env.storage().persistent().get(&total_key).unwrap_or(0); + let mut slot_count: u32 = env + .storage() + .persistent() + .get(&DataKey::SnapshotHolderCount( + offering_id.clone(), + snapshot_ref, + )) + .unwrap_or(0); + + // Check max total supply shares cap first + let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); + let max_shares: i128 = + env.storage().persistent().get(&max_shares_key).unwrap_or(0); + let mut temp_total_shares: i128 = if max_shares > 0 { + env + .storage() + .persistent() + .get(&DataKey2::TotalSharesIssued(offering_id.clone())) + .unwrap_or(0) + } else { + 0 + }; + let mut temp_deltas: Vec<(Address, i128)> = Vec::new(&env); + + // First pass: calculate deltas and check cap + if max_shares > 0 { + for i in 0..batch_len { + let (holder, share_bps) = holders.get(i).unwrap(); + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + let delta = (share_bps as i128) - (old_share as i128); + temp_total_shares = temp_total_shares.saturating_add(delta); + temp_deltas.push_back((holder.clone(), delta)); + } + if temp_total_shares > max_shares { + return Err(RevoraError::MaxTotalSupplySharesExceeded); + } + if temp_total_shares == max_shares { env.events().publish( - (EVENT_LOCKUP_EXTEND, issuer, namespace, token), - (holder, new_taper_end_ts, attestation.digest), + ( + EVENT_SUPPLY_CAP_SATURATED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (temp_total_shares, max_shares), ); + } + } + + // Now apply the changes + for i in 0..batch_len { + let (holder, share_bps) = holders.get(i).unwrap(); + let slot = start_index.saturating_add(i); + + // Write indexed slot for deterministic enumeration. + env.storage().persistent().set( + &DataKey::SnapshotHolder(offering_id.clone(), snapshot_ref, slot), + &(holder.clone(), share_bps), + ); + + // Write address-keyed entry for O(1) vote-weight lookup (issue #557). + env.storage().persistent().set( + &DataKey::SnapshotHolderShare( + offering_id.clone(), + snapshot_ref, + holder.clone(), + ), + &share_bps, + ); + + if slot.saturating_add(1) > slot_count { + slot_count = slot.saturating_add(1); + } + + // Compute delta against previously persisted holder share. + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + + let new_total = current_total + .saturating_sub(old_share) + .saturating_add(share_bps); + if new_total > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + + Self::cache_holder_accrual_through_matured(&env, &offering_id, &holder); + + // Update live holder share so claim() works immediately. + env.storage().persistent().set( + &DataKey::HolderShare(offering_id.clone(), holder.clone()), + &share_bps, + ); + Self::record_holder_share_transition( + &env, + &offering_id, + &holder, + old_share, + share_bps, + ); + + current_total = new_total; + added_bps = added_bps.saturating_add(share_bps); + } + + // Update total shares issued + if max_shares > 0 { + env.storage().persistent().set( + &DataKey2::TotalSharesIssued(offering_id.clone()), + &temp_total_shares, + ); + } else { + // If no cap, still track total shares + let mut total_shares: i128 = env + .storage() + .persistent() + .get(&DataKey2::TotalSharesIssued(offering_id.clone())) + .unwrap_or(0); + for i in 0..batch_len { + let (holder, share_bps) = holders.get(i).unwrap(); + let old_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + total_shares = total_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); + } + env.storage().persistent().set( + &DataKey2::TotalSharesIssued(offering_id.clone()), + &total_shares, + ); + } + + // Update snapshot metadata. + if slot_count > entry.holder_count { + entry.holder_count = slot_count; + } + let new_total_bps = entry.total_bps.saturating_add(added_bps); + entry.total_bps = new_total_bps; + env.storage().persistent().set(&entry_key, &entry); + env.storage().persistent().set( + &DataKey::SnapshotHolderCount(offering_id.clone(), snapshot_ref), + &slot_count, + ); + + // Persist updated per-offering running total. + env.storage().persistent().set( + &DataKey::HolderShareTotal(offering_id.clone()), + ¤t_total, + ); + + env.events().publish( + (EVENT_SNAP_SHARES_APPLIED, issuer, namespace, token), + (snapshot_ref, start_index, batch_len, new_total_bps), + ); + Ok(()) + } + + /// Verify a snapshot proof while enforcing the maximum depth permitted by the + /// contract. Deep proofs can exhaust contract memory and gas, so the contract + /// rejects them early with [`RevoraError::ProofTooDeep`] and emits + /// [`EVENT_PROOF_REJECT_DEPTH`] when the bound is violated. + /// + /// The implementation is intentionally lightweight: it only validates the proof + /// length and returns `Ok(())` for proofs that fit within the hard limit. + /// Off-chain callers remain responsible for the actual proof verification and + /// root comparison logic. + pub fn verify_snapshot_proof( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: u64, + proof: Vec>, + ) -> Result<(), RevoraError> { + let proof_len = proof.len(); + if proof_len > MAX_PROOF_DEPTH { + env.events().publish( + ( + EVENT_PROOF_REJECT_DEPTH, + issuer.clone(), + namespace.clone(), + token.clone(), + snapshot_ref, + ), + (proof_len, MAX_PROOF_DEPTH), + ); + return Err(RevoraError::ProofTooDeep); + } + + Ok(()) + } + + /// Return the total number of holder entries recorded for a snapshot. + /// + /// Returns 0 if the snapshot has not been committed or no shares have been applied. + pub fn get_snapshot_holder_count( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: u64, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey::SnapshotHolderCount(offering_id, snapshot_ref)) + .unwrap_or(0) + } + + /// Read a single holder entry from a committed snapshot by its sequential index. + /// + /// Returns `None` if the slot has not been written. + pub fn get_snapshot_holder_at( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: u64, + index: u32, + ) -> Option<(Address, u32)> { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env.storage().persistent().get(&DataKey::SnapshotHolder( + offering_id, + snapshot_ref, + index, + )) + } + + /// Set a holder's revenue share in basis points for an offering. + /// + /// The `nonce` must be strictly greater than the last accepted nonce for this + /// `(offering_id, holder)` pair. This monotonicity guard prevents stale + /// off-chain updates from silently overwriting newer on-chain share state. + /// Use `nonce = 1` on the first call; increment by at least 1 on every + /// subsequent call. The contract persists the last accepted value so the + /// caller can derive the next valid nonce from `get_holder_share_nonce`. + pub fn set_holder_share( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_bps: u32, + nonce: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + // Delegate to internal writer which maintains the aggregate running total + // and enforces the per-offering sum invariant (≤ 10_000 bps). + Self::set_holder_share_internal( + &env, + issuer, + namespace, + token, + holder, + share_bps, + None, + Some(nonce), + ) + } + + /// Set a holder's revenue share in basis points for a specific class of an offering. + pub fn set_holder_share_class( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_bps: u32, + share_class: ShareClass, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + Self::get_current_issuer( + &env, + issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + Self::set_holder_share_internal( + &env, + issuer, + namespace, + token, + holder, + share_bps, + Some(share_class), + None, + ) + } + /// + /// The dispute ID is deterministic: `sha256(issuer || namespace || token || holder || meta_hash)`. + /// A holder may have at most [`MAX_OPEN_DISPUTES_PER_HOLDER`] open disputes per offering. + /// + /// ### Arguments + /// * `holder` — The address opening the dispute. Must authenticate. + /// * `issuer` — The offering's issuer address. + /// * `namespace` — The offering's namespace symbol. + /// * `token` — The offering's token address. + /// * `meta_hash` — A 32-byte hash pointing to off-chain dispute evidence (e.g. IPFS CID). + /// + /// ### Errors + /// - [`RevoraError::DisputeZeroShare`] if the holder holds zero shares. + /// - [`RevoraError::DisputeAlreadyOpen`] if an identical dispute already exists. + /// - [`RevoraError::MaxDisputesReached`] if the per-holder cap is exceeded. + pub fn open_dispute( + env: Env, + holder: Address, + issuer: Address, + namespace: Symbol, + token: Address, + meta_hash: BytesN<32>, + ) -> Result, RevoraError> { + holder.require_auth(); + Self::require_not_frozen(&env)?; + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; - Ok(()) + // Reject holders with zero shares (not a participant) + let share = Self::get_holder_share( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ); + if share == 0 { + return Err(RevoraError::DisputeZeroShare); + } + + // Deterministic dispute ID: sha256(issuer || namespace || token || holder || meta_hash) + let mut input = Bytes::new(&env); + input.append(&offering_id.issuer.to_xdr(&env)); + input.append(&offering_id.namespace.to_xdr(&env)); + 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).into(); + + // Reject duplicate + if env + .storage() + .persistent() + .has(&DataKey2::Dispute(dispute_id.clone())) + { + return Err(RevoraError::DisputeAlreadyOpen); } - /// Preview the total claimable amount for a holder without mutating state. - /// - /// This method respects the same blacklist, claim-window, and claim-delay gates that can block - /// `claim`, then sums only periods currently eligible for payout. - pub fn get_claimable( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> i128 { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - if Self::is_blacklisted(env.clone(), issuer, namespace, token, holder.clone()) { - return 0; - } - if Self::require_claim_window_open(&env, &offering_id).is_err() { - return 0; - } + // Enforce spam cap per (offering_id, holder) + let count_key = DataKey2::DisputeCount(offering_id.clone(), holder.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + if count >= MAX_OPEN_DISPUTES_PER_HOLDER { + return Err(RevoraError::MaxDisputesReached); + } - let (total, _) = Self::compute_claimable_preview(&env, &offering_id, &holder, 0, None); - total - } - - /// Read-only: compute claimable amount for a holder over a bounded index window. - /// - /// This function allows indexers, frontends, and reviewers to page through a holder's - /// currently claimable revenue without mutating contract state. It is the chunked companion - /// to `get_claimable`. - /// - /// # Arguments - /// - /// * `issuer` - The offering issuer address - /// * `namespace` - The offering namespace identifier - /// * `token` - The offering token address - /// * `holder` - The holder address to compute claimable amount for - /// * `start_idx` - The starting period index (cursor) for the chunk query - /// * `count` - The maximum number of periods to include in this chunk - /// - /// # Returns - /// - /// Returns `(total, next_cursor)` where: - /// - `total` is the sum of claimable amounts for the processed periods - /// - `next_cursor` is `Some(next_index)` if more eligible periods exist after the processed window, - /// or `None` if all eligible periods have been processed - /// - /// # Behavior - /// - /// - Caller-provided cursors (`start_idx`) are clamped to the holder's stored `LastClaimedIdx` - /// - The first delayed period stops iteration and becomes the returned `next_cursor` - /// - A blacklisted holder receives `0` from this function - /// - A closed claim window also yields `0` from this function - /// - Chunk size `0` or any size above `MAX_CHUNK_PERIODS` (200) is normalized to `MAX_CHUNK_PERIODS` - /// - Holders with zero share receive `0` claimable amount - /// - /// # Security Guarantees - /// - /// This implementation is intentionally conservative: previews never advertise more value - /// than the holder could actually claim at the current ledger state. - /// - /// # Cursor Idempotency - /// - /// Repeated queries with the same cursor yield identical results, ensuring reliable pagination. - /// - /// # Chunk Summation Parity - /// - /// Summing chunked claimable amounts equals the full claimable amount obtainable via `get_claimable`. - pub fn get_claimable_chunk( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - start_idx: u32, - count: u32, - ) -> (i128, Option) { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - if Self::is_blacklisted(env.clone(), issuer, namespace, token, holder.clone()) { - return (0, None); - } - if Self::require_claim_window_open(&env, &offering_id).is_err() { - return (0, None); - } + let opened_at = env.ledger().timestamp(); - Self::compute_claimable_preview(&env, &offering_id, &holder, start_idx, Some(count)) - } - - /// Per-holder accrual snapshot: returns the total unclaimed dividend balance - /// across all periods by walking the accrual ledger since the holder's last - /// claim checkpoint. - /// - /// This is a pure read-only query and does not mutate contract state. - /// The computation is bounded per-holder; it walks only the holder's share - /// schedule entries and accrual-index snapshots between `last_settled_idx` - /// and the current matured end, without iterating every period individually - /// through a per-period loop. - /// - /// # Arguments - /// - /// * `issuer` — The offering issuer address. - /// * `namespace` — The offering namespace identifier. - /// * `token` — The offering token address. - /// * `holder` — The holder address to compute accrued unclaimed for. - /// - /// # Returns - /// - /// Returns `i128` — the total amount currently accrued but unclaimed for this - /// holder. Returns `0` for blacklisted holders or holders with no unclaimed - /// periods. - /// - /// # Gas - /// - /// Gas scales with the number of share-schedule entries (share changes) and - /// the number of periods since the last settlement. Both are naturally bounded - /// by checkpoint compression (default threshold: 1 000 schedule entries) and - /// the offering's total period count. - pub fn get_holder_accrued_unclaimed( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> i128 { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + let dispute = Dispute { + id: dispute_id.clone(), + holder: holder.clone(), + offering_id: offering_id.clone(), + opened_at, + meta_hash: meta_hash.clone(), + status: DisputeStatus::Open, + }; - // Fast-path: blacklisted holders always return 0. - if Self::is_blacklisted(env.clone(), issuer, namespace, token, holder.clone()) { - return 0; - } + env + .storage() + .persistent() + .set(&DataKey2::Dispute(dispute_id.clone()), &dispute); + env.storage().persistent().set(&count_key, &(count + 1)); + + env.events().publish( + (Symbol::new(&env, "dispute_open"),), + (dispute_id.clone(), offering_id, holder.clone(), meta_hash), + ); + + Ok(dispute_id) + } + + /// Read an on-chain dispute record by its deterministic ID. + /// + /// Returns `None` if no dispute with the given ID exists. + pub fn get_dispute(env: Env, dispute_id: BytesN<32>) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::Dispute(dispute_id)) + } + + /// Get a holder's revenue share in basis points for an offering. + pub fn get_holder_share( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + if let Some(cls_vec) = env + .storage() + .persistent() + .get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key) + { + let mut total_share = 0; + for (sc, _) in cls_vec.iter() { + let share: u32 = env + .storage() + .persistent() + .get(&DataKey2::HolderShareClass( + offering_id.clone(), + holder.clone(), + sc, + )) + .unwrap_or(0); + total_share += share; + } + total_share + } else { + env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id, holder)) + .unwrap_or(0) + } + } + + /// Return the last accepted nonce for a holder's share in an offering. + /// + /// Returns `0` when no `set_holder_share` call has ever been accepted for + /// this `(offering_id, holder)` pair. The next valid nonce for a + /// `set_holder_share` call is any value strictly greater than this. + pub fn get_holder_share_nonce( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> u64 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::HolderShareNonce(offering_id, holder)) + .unwrap_or(0) + } + + /// Get a holder's revenue share in basis points for a specific class of an offering. + pub fn get_holder_share_class( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_class: ShareClass, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::HolderShareClass( + offering_id, + holder, + share_class, + )) + .unwrap_or(0) + } + + /// Set the conversion ratio (in bps) for rolling from one class to another. + pub fn set_class_conversion_ratio( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from_class: ShareClass, + to_class: ShareClass, + ratio_bps: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + if ratio_bps == 0 { + return Err(RevoraError::InvalidConversionRatio); + } + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::ClassConversionRatio(offering_id, from_class, to_class); + env.storage().persistent().set(&key, &ratio_bps); + Ok(()) + } + + /// Convert a holder's share from one class to another using the issuer-approved ratio. + pub fn convert_class( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + from_class: ShareClass, + to_class: ShareClass, + amount_bps: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + holder.require_auth(); + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; - // Get the holder's current accrual state. This includes the frozen - // `accrued_owed` from previous share-change settlements and correctly - // advances `last_settled_idx` past any `last_claimed_idx` advances. - let state = Self::get_holder_accrual_state(&env, &offering_id, &holder); + if let Some(schedule) = env + .storage() + .persistent() + .get::<_, crate::vesting::VestingSchedule>( + &crate::vesting::VestingKey::Schedule(holder.clone()), + ) { + let vested = crate::vesting::VestingContract::get_vested_amount( + env.clone(), + holder.clone(), + ) + .unwrap_or(0); + if schedule.total_amount > vested { + return Err(RevoraError::UnvestedConversionBlocked); + } + } + + let ratio_key = DataKey2::ClassConversionRatio( + offering_id.clone(), + from_class.clone(), + to_class.clone(), + ); + let ratio_bps: u32 = env + .storage() + .persistent() + .get(&ratio_key) + .ok_or(RevoraError::ConversionNotApproved)?; + + if ratio_bps == 0 { + return Err(RevoraError::InvalidConversionRatio); + } + + let from_key = DataKey2::HolderShareClass( + offering_id.clone(), + holder.clone(), + from_class.clone(), + ); + let to_key = DataKey2::HolderShareClass( + offering_id.clone(), + holder.clone(), + to_class.clone(), + ); + + let from_balance: u32 = + env.storage().persistent().get(&from_key).unwrap_or(0); + if from_balance < amount_bps { + return Err(RevoraError::InsufficientClassBalance); + } + + let converted_amount_bps = + ((amount_bps as u64).saturating_mul(ratio_bps as u64) / 10000) as u32; + + let to_balance: u32 = env.storage().persistent().get(&to_key).unwrap_or(0); + + let new_from = from_balance.saturating_sub(amount_bps); + let new_to = to_balance.saturating_add(converted_amount_bps); + + env.storage().persistent().set(&from_key, &new_from); + env.storage().persistent().set(&to_key, &new_to); + + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + if let Some(mut cls_vec) = env + .storage() + .persistent() + .get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key) + { + let mut from_idx = None; + let mut to_idx = None; + for (i, (sc, _)) in cls_vec.iter().enumerate() { + if sc == from_class { + from_idx = Some(i as u32); + } + if sc == to_class { + to_idx = Some(i as u32); + } + } + if let (Some(f_idx), Some(t_idx)) = (from_idx, to_idx) { + let (_, mut f_cfg) = cls_vec.get(f_idx).unwrap(); + let (_, mut t_cfg) = cls_vec.get(t_idx).unwrap(); + + f_cfg.bps = f_cfg + .bps + .checked_sub(amount_bps) + .ok_or(RevoraError::InvalidShareBps)?; + t_cfg.bps = t_cfg + .bps + .checked_add(amount_bps) + .ok_or(RevoraError::InvalidShareBps)?; + + cls_vec.set(f_idx, (from_class.clone(), f_cfg)); + cls_vec.set(t_idx, (to_class.clone(), t_cfg)); + env.storage().persistent().set(&classes_key, &cls_vec); + } + } + + env.events().publish( + (soroban_sdk::symbol_short!("cls_conv"), offering_id, holder), + ( + from_class, + from_balance, + new_from, + to_class, + to_balance, + new_to, + ), + ); - // Find how many periods have matured beyond their claim delay (if any). - let matured_end = - Self::find_matured_claim_end_idx(&env, &offering_id, state.last_settled_idx); + Ok(()) + } + + /// Set or update a holder's jurisdiction tag for an offering. + /// + /// When `effective_ts` is `0` or in the past, the jurisdiction is applied + /// immediately. When `effective_ts` is in the future, a migration event is + /// emitted and a compliance deadline is scheduled. The holder retains full + /// claim access until the deadline (`effective_ts + grace_period_secs`) is + /// reached; after that, claims are blocked if the new jurisdiction is not + /// in the offering's allowlist. + pub fn set_holder_jurisdiction( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + jurisdiction: Symbol, + effective_ts: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage().persistent().set( + &DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), + &jurisdiction, + ); + env.events().publish( + (Self::jurisdiction_set_event(&env), issuer, namespace, token), + (EVENT_JUR_SCOPE_HOLDER, holder, jurisdiction), + ); + Ok(()) + } + + /// Read a holder's configured jurisdiction tag for an offering. + pub fn get_holder_jurisdiction( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + Self::get_holder_jurisdiction_internal(&env, &offering_id, &holder) + } + + /// Configure a per-jurisdiction transfer cooldown for an offering. + /// + /// When a cooldown is set, holders whose jurisdiction matches the given + /// `jurisdiction` symbol must wait at least `cooldown_secs` between + /// successive transfers. The cooldown is tracked per `(offering_id, holder)` + /// and is enforced in the transfer path. + /// + /// Set `cooldown_secs` to `0` to disable the cooldown for this jurisdiction. + /// + /// # Auth + /// Requires issuer authentication. + /// + /// # Events + /// Emits [`EVENT_TRANSFER_COOLDOWN_SET`] on success. + pub fn set_transfer_cooldown( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + jurisdiction: Symbol, + cooldown_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Start total from already-frozen accrued_owed. - let mut total = state.accrued_owed; + let key = + DataKey2::TransferCooldownConfig(offering_id, jurisdiction.clone()); + env.storage().persistent().set(&key, &cooldown_secs); - // Compute additional accrual for periods since the last settlement. - if matured_end > state.last_settled_idx { - total = total.saturating_add(Self::compute_holder_payout_for_range( - &env, - &offering_id, - &holder, - state.last_settled_idx, - matured_end, - )); - } + env.events().publish( + (EVENT_TRANSFER_COOLDOWN_SET, issuer, namespace, token), + (jurisdiction, cooldown_secs), + ); + Ok(()) + } + + /// Return the per-jurisdiction transfer cooldown (in seconds) for an offering. + /// + /// Returns `0` when no cooldown is configured for the given jurisdiction. + pub fn get_transfer_cooldown( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + jurisdiction: Symbol, + ) -> u64 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::TransferCooldownConfig(offering_id, jurisdiction); + env + .storage() + .persistent() + .get::(&key) + .unwrap_or(0) + } + + /// Replace the offering's allowed jurisdiction set. + /// + /// An empty list disables jurisdiction gating for future share writes and snapshot inclusion. + pub fn set_allowed_jurisdictions( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + jurisdictions: Vec, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let normalized = Self::normalize_jurisdictions(&env, jurisdictions); + env + .storage() + .persistent() + .set(&DataKey2::AllowedJurisdictions(offering_id), &normalized); + env.events().publish( + (Self::jurisdiction_set_event(&env), issuer, namespace, token), + (EVENT_JUR_SCOPE_ALLOW, normalized), + ); + Ok(()) + } + + /// Return the offering's allowed jurisdiction list in stored order. + pub fn get_allowed_jurisdictions( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Vec { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + Self::get_allowed_jurisdictions_internal(&env, &offering_id) + } + + /// Set the claim delay in seconds for an offering. + pub fn set_claim_delay( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + delay_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::NotAuthorized); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace, + token, + }; + env + .storage() + .persistent() + .set(&DataKey::ClaimDelaySecs(offering_id), &delay_secs); + Ok(()) + } + + /// Get the claim delay in seconds for an offering. + pub fn get_claim_delay( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u64 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey::ClaimDelaySecs(offering_id)) + .unwrap_or(0) + } + + // ── Jurisdiction migration grace period (per-offering) ── + + /// Set the per-offering jurisdiction migration grace period in seconds. + /// + /// Must be between [`MIN_JURISDICTION_GRACE_SECS`] (1 hour) and + /// [`MAX_JURISDICTION_GRACE_SECS`] (90 days) inclusive. The grace period + /// applies to all holders relocating into a potentially disallowed jurisdiction. + pub fn set_jurisdiction_grace_period( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + grace_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::NotAuthorized); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + if grace_secs < MIN_JURISDICTION_GRACE_SECS + || grace_secs > MAX_JURISDICTION_GRACE_SECS + { + return Err(RevoraError::InvalidAmount); + } - // Handle accrual anchor: compressed share-schedule entries that were - // folded into a pre-computed sum during checkpoint compression. The - // anchor must be included if it covers periods after the last claim, - // even though those entries have been pruned from the live schedule. - let anchor_key = DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); - if let Some(anchor) = env.storage().persistent().get::<_, AccrualAnchor>(&anchor_key) { - let last_claimed_idx: u32 = env - .storage() - .persistent() - .get(&DataKey::LastClaimedIdx(offering_id.clone(), holder.clone())) - .unwrap_or(0); - if last_claimed_idx <= anchor.end_idx { - total = total.saturating_add(anchor.claimable_sum); - } - } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env + .storage() + .persistent() + .set(&DataKey2::JurisdictionGracePeriod(offering_id), &grace_secs); + + env.events().publish( + (EVENT_JUR_GRACE_SET, issuer, namespace, token), + (grace_secs,), + ); + Ok(()) + } + + /// Read the per-offering jurisdiction migration grace period. + /// Returns [`DEFAULT_JURISDICTION_GRACE_SECS`] (7 days) when not configured. + pub fn get_jurisdiction_grace_period( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u64 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey2::JurisdictionGracePeriod(offering_id)) + .unwrap_or(DEFAULT_JURISDICTION_GRACE_SECS) + } + + /// Read a holder's pending jurisdiction migration state, if any. + pub fn get_jurisdiction_migration( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::( + &DataKey2::JurisdictionMigration(offering_id, holder), + ) + } + + /// Return the current contract version as a semver triple (MAJOR, MINOR, PATCH) (#23). + pub fn get_version(_env: Env) -> (u32, u32, u32) { + CONTRACT_VERSION + } + + /// Migrate the contract storage to a new version. + /// + /// Reads the currently stored `DeployedVersion` (defaulting to [`CONTRACT_VERSION`] + /// if absent) and rejects the call if: + /// - The contract is not initialized (`NotInitialized`) + /// - The caller is not the admin (`NotAuthorized`) + /// - The contract is frozen (`ContractFrozen`) + /// - `target` equals the stored version (`AlreadyAtTargetVersion`) + /// - `target` is a semver downgrade (`MigrationDowngradeNotAllowed`) + /// + /// On success, persists `target` as the new `DeployedVersion` and emits a + /// `(symbol_short!("migrate"), (from, to))` event. + pub fn migrate_storage( + env: Env, + caller: Address, + target_major: u32, + target_minor: u32, + target_patch: u32, + ) -> Result<(), RevoraError> { + caller.require_auth(); + + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + + if env + .storage() + .persistent() + .get::(&DataKey::Frozen) + .unwrap_or(false) + { + return Err(RevoraError::ContractFrozen); + } - // Emit debug tracing event. Even though this is a read-only query, - // the event is visible in simulation responses, giving integrators - // full visibility into the computation breakdown. - env.events().publish( - (EVENT_ACC_SNAP, offering_id.issuer, offering_id.namespace, offering_id.token), - (holder.clone(), state.last_settled_idx, matured_end, state.accrued_owed, total), - ); + let from = env + .storage() + .persistent() + .get::(&DataKey::DeployedVersion) + .unwrap_or(CONTRACT_VERSION); + let to = (target_major, target_minor, target_patch); - total - } - - // ── Fiscal year configuration ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - - /// Set the fiscal year start month for an offering. - /// - /// `month` must be 1 (January) through 12 (December). The default is 1. - /// This determines how `get_holder_tax_year` maps payout timestamps to - /// fiscal years. - /// - /// # Access - /// Issuer-only (requires `require_issuer_quorum_auth`). - pub fn set_fiscal_year_start( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - month: u32, - ) -> Result<(), RevoraError> { - if month < 1 || month > 12 { - return Err(RevoraError::InvalidAmount); - } - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - Self::require_issuer_quorum_auth(&env, &offering.issuers); - - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().set(&DataKey2::FiscalYearStartMonth(offering_id), &month); - Ok(()) - } - - /// Read the fiscal year start month for an offering. - /// Returns 1 (January) if not configured. - pub fn get_fiscal_year_start( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::<_, u32>(&DataKey2::FiscalYearStartMonth(offering_id)) - .unwrap_or(crate::tax_bucket::DEFAULT_FISCAL_START_MONTH) - } - - /// Return the per-holder tax-year summary for a given fiscal year. - /// - /// The summary is accumulated on every `claim` and stored per offering, - /// per holder, per fiscal year. Returns a `TaxYearSummary` with - /// `return_of_capital` and `capital_gains` totals for the requested year. - /// - /// The fiscal year is determined by the offering's configured fiscal year - /// start month (see `set_fiscal_year_start`). If the start month is - /// April (4), then fiscal year 2024 covers Apr 2024 – Mar 2025. - /// - /// # Returns - /// A `TaxYearSummary` — never fails; returns zero-filled record for holders - /// with no activity in the given year. - pub fn get_holder_tax_year( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - year: u64, - ) -> crate::tax_bucket::TaxYearSummary { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::TaxYearEntry(offering_id, holder, year)) - .unwrap_or(crate::tax_bucket::TaxYearSummary { - ordinary_income: 0, - capital_gains: 0, - return_of_capital: 0, - }) - } - - // ── Time-delayed claim configuration (#27) ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - - /// Set the claim delay for an offering in seconds. - fn set_claim_delay_full( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - delay_secs: u64, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - // Verify offering exists and issuer is current - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; + assert_semver_forward(from, to)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } + env + .storage() + .persistent() + .set(&DataKey::DeployedVersion, &to); + env + .events() + .publish((symbol_short!("migrate"),), (from, to)); + Ok(()) + } + + /// Configure the reporting access window for an offering. If unset, always open. + pub fn set_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + let window = AccessWindow { + start_timestamp, + end_timestamp, + }; + Self::validate_window(&window)?; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env + .storage() + .persistent() + .set(&WindowDataKey::Report(offering_id), &window); + env.events().publish( + (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); + Ok(()) + } + + /// Configure the claiming access window for an offering. If unset, always open. + pub fn set_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + let window = AccessWindow { + start_timestamp, + end_timestamp, + }; + Self::validate_window(&window)?; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env + .storage() + .persistent() + .set(&WindowDataKey::Claim(offering_id), &window); + env.events().publish( + (EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); + Ok(()) + } + + /// Read configured reporting window (if any) for an offering. + pub fn get_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&WindowDataKey::Report(offering_id)) + } + + /// Read configured claiming window (if any) for an offering. + pub fn get_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&WindowDataKey::Claim(offering_id)) + } + + /// Configure the redemption window for an offering. If unset, always open. + /// Rejects the request if a stored redemption window overlaps with the new one. + pub fn set_redemption_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + let new_window = AccessWindow { + start_timestamp, + end_timestamp, + }; + Self::validate_window(&new_window)?; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - Self::require_not_frozen(&env)?; - issuer.require_auth(); - let key = DataKey::ClaimDelaySecs(offering_id); - env.storage().persistent().set(&key, &delay_secs); - env.events().publish((EVENT_CLAIM_DELAY_SET, issuer, namespace, token), delay_secs); - Ok(()) - } + // Reject if the new window overlaps with the stored window + let existing: Option = env + .storage() + .persistent() + .get(&WindowDataKey::Redemption(offering_id.clone())); + if let Some(existing) = existing { + if start_timestamp < existing.end_timestamp + && existing.start_timestamp < end_timestamp + { + return Err(RevoraError::RedemptionWindowOverlap); + } + } + + env + .storage() + .persistent() + .set(&WindowDataKey::Redemption(offering_id), &new_window); + env.events().publish( + (EVENT_REDEMPTION_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); + Ok(()) + } + + /// Read configured redemption window (if any) for an offering. + pub fn get_redemption_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&WindowDataKey::Redemption(offering_id)) + } + pub fn claim( + env: Env, + holder: Address, + issuer: Address, + namespace: Symbol, + token: Address, + max_periods: u32, + ) -> Result { + holder.require_auth(); + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; - /// Get per-offering claim delay in seconds. 0 = immediate claim. - fn get_claim_delay_internal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u64 { - let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::ClaimDelaySecs(offering_id); - env.storage().persistent().get(&key).unwrap_or(0) + // Halt claims while a critical dispute is active for this offering + if Self::is_dispute_freeze_active(&env, &offering_id) { + return Err(RevoraError::DisputeFreezeActive); } - /// Return the total number of deposited periods for an offering. - pub fn get_period_count(env: Env, issuer: Address, namespace: Symbol, token: Address) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - let count_key = DataKey::PeriodCount(offering_id); - env.storage().persistent().get(&count_key).unwrap_or(0) + // Initial blacklist check for early fail-fast + if Self::is_blacklisted( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ) { + return Err(RevoraError::HolderBlacklisted); } -} -// ── Test-only helpers (not part of the contract ABI) ───────────────────────── -impl RevoraRevenueShare { - /// Test helper: insert a period entry and revenue without transferring tokens. - /// Only compiled in test builds to avoid affecting production contract. - #[cfg(test)] - pub fn test_insert_period( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - period_id: u64, - amount: i128, - ) { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - // Append to indexed period list - let count_key = DataKey::PeriodCount(offering_id.clone()); - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - let entry_key = DataKey::PeriodEntry(offering_id.clone(), count); - env.storage().persistent().set(&entry_key, &period_id); - env.storage().persistent().set(&count_key, &(count + 1)); - - // Store period revenue and deposit time - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - env.storage().persistent().set(&rev_key, &amount); - let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); - let deposit_time = env.ledger().timestamp(); - env.storage().persistent().set(&time_key, &deposit_time); - - let normalized = Self::normalize_amount(amount, STELLAR_CANONICAL_DECIMALS); - let acc_delta_e18 = Self::accrual_delta_e18(normalized); - let global_acc_key = DataKey2::GlobalAccPerShareE18(offering_id.clone()); - let current_acc: i128 = env.storage().persistent().get(&global_acc_key).unwrap_or(0); - let next_acc = current_acc.saturating_add(acc_delta_e18); - env.storage().persistent().set(&global_acc_key, &next_acc); - env.storage() - .persistent() - .set(&DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), &next_acc); - - // Update cumulative deposited revenue - let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); - let deposited: i128 = env.storage().persistent().get(&deposited_key).unwrap_or(0); - let new_deposited = deposited.s_add(amount).unwrap_or(i128::MAX); - env.storage().persistent().set(&deposited_key, &new_deposited); - } - - /// Test helper: set a holder's claim cursor without performing token transfers. - #[cfg(test)] - pub fn test_set_last_claimed_idx( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - last_claimed_idx: u32, - ) { - let offering_id = OfferingId { issuer, namespace, token }; - let idx_key = DataKey::LastClaimedIdx(offering_id, holder); - env.storage().persistent().set(&idx_key, &last_claimed_idx); - } - // ── On-chain distribution simulation (#29) ──────────────────── - - /// Read-only: simulate distribution for sample inputs without mutating state. - /// Returns expected payouts per holder and total. Uses offering's rounding mode. - /// For integrators to preview outcomes before executing deposit/claim flows. - pub fn simulate_distribution( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - amount: i128, - holder_shares: Vec<(Address, u32)>, - ) -> SimulateDistributionResult { - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - let classes: Option> = - env.storage().persistent().get(&classes_key); - let mode = Self::get_rounding_mode(env.clone(), issuer, namespace, token.clone()); - - let n = holder_shares.len(); - - // Extract parallel vecs for the sort helper. - let mut addr_vec = Vec::
::new(&env); - let mut bps_vec = Vec::::new(&env); - for i in 0..n { - let (h, b) = holder_shares.get(i).unwrap(); - addr_vec.push_back(h); - bps_vec.push_back(b); - } + // Jurisdiction migration deadline enforcement + Self::require_jurisdiction_migration_not_expired( + &env, + &offering_id, + &holder, + symbol_short!("claim"), + )?; - let sorted_idx = Self::sort_holder_indices(&env, &bps_vec, &addr_vec, n); - - let mut total: i128 = 0; - let mut payouts = Vec::new(&env); - for k in 0..n { - let idx = sorted_idx.get(k).unwrap(); - let holder = addr_vec.get(idx).unwrap(); - let share_bps = bps_vec.get(idx).unwrap(); - let payout = if share_bps > 10_000 { - 0_i128 - } else { - if classes.is_some() { - let mut p = 0_i128; - if let Some(ref cls_vec) = classes { - for (sc, config) in cls_vec.iter() { - let holder_share = env - .storage() - .persistent() - .get(&DataKey2::HolderShareClass( - offering_id.clone(), - holder.clone(), - sc.clone(), - )) - .unwrap_or(0); - if holder_share > 0 { - let class_rev = - Self::compute_share(env.clone(), amount, config.bps, mode); - let holder_payout = - Self::compute_share(env.clone(), class_rev, holder_share, mode); - p = p.saturating_add(holder_payout); - } - } - } - p - } else { - Self::compute_share(env.clone(), amount, share_bps, mode) - } - }; - total = total.saturating_add(payout); - payouts.push_back((holder.clone(), payout)); - } - SimulateDistributionResult { total_distributed: total, payouts } + let share_bps = Self::get_holder_share( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ); + if share_bps == 0 { + return Err(RevoraError::NoPendingClaims); } - // ── Issuer two-step transfer (#258) ────────────────────────── + Self::require_claim_window_open(&env, &offering_id)?; - // ── Upgradeability guard and freeze (#32) ─────────────────── + let count_key = DataKey::PeriodCount(offering_id.clone()); + let period_count: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); - /// Set the admin address. May only be called once; caller must authorize as the new admin. - /// If multisig is initialized, this function is disabled in favor of execute_action(SetAdmin). - pub fn set_admin(env: Env, admin: Address) -> Result<(), RevoraError> { - if env.storage().persistent().has(&DataKey2::MultisigThreshold) { - return Err(RevoraError::LimitReached); - } - admin.require_auth(); - let key = DataKey::Admin; - if env.storage().persistent().has(&key) { - return Err(RevoraError::LimitReached); - } - env.storage().persistent().set(&key, &admin); - Self::emit_v2_event(&env, (EVENT_ADMIN_SET,), admin); - Ok(()) - } - - /// Get the admin address, if set. - pub fn get_admin(env: Env) -> Option
{ - let key = DataKey::Admin; - env.storage().persistent().get(&key) - } - - // ── Admin rotation safety flow (Issue #191, #557) ────────────── - - /// Propose a two-phase admin rotation to `new_admin`. - /// - /// The current admin initiates; `new_admin` must call finalize after - /// the configured delay to complete the transfer. The proposal timestamp is recorded for - /// delay enforcement. Only one rotation may be pending at a time. - /// - /// ### Auth - /// Current admin (`require_auth`). - /// - /// ### Errors - /// - `AdminRotationSameAddress` — `new_admin` equals current admin. - /// - `AdminRotationPending` — a rotation is already pending; cancel it first. - /// - `ContractFrozen` — contract is frozen. - /// - /// ### Events - /// Emits `adm_prop`: `(adm_prop, current_admin)` → `new_admin`. - pub fn propose_admin_rotation(env: Env, new_admin: Address) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - - admin.require_auth(); - - if new_admin == admin { - return Err(RevoraError::AdminRotationSameAddress); - } + let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); + let start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - if env.storage().persistent().has(&DataKey::PendingAdmin) { - return Err(RevoraError::AdminRotationPending); - } + if start_idx >= period_count { + return Err(RevoraError::NoPendingClaims); + } - let pending = PendingAdminRotation { - new_admin: new_admin.clone(), - proposed_at: env.ledger().timestamp(), - }; - env.storage().persistent().set(&DataKey::PendingAdmin, &pending); - - env.events().publish((symbol_short!("adm_prop"), admin), new_admin); - - Ok(()) - } - - /// Finalize a pending two-phase admin rotation after the configured delay has elapsed. - /// - /// The new admin must authorize and match the pending proposed address. The delay - /// (configured via [`set_admin_rotation_delay`]) is checked against the proposal - /// timestamp stored in `propose_admin_rotation`. - /// - /// ### Auth - /// `new_admin` must authorize (`require_auth`). Caller must match the pending proposed address. - /// - /// ### Errors - /// - `NoAdminRotationPending` — no rotation was proposed. - /// - `UnauthorizedRotationAccept` — caller does not match the pending proposed address. - /// - `AdminRotationDelayNotElapsed` — the configured delay has not yet passed. - /// - `ContractFrozen` — contract is frozen. - /// - /// ### Events - /// Emits `adm_fin`: `(adm_fin, old_admin)` → `new_admin`. - /// Emits `adm_log` (v2): the persisted `AdminRotationEntry`. - pub fn finalize_admin_rotation(env: Env, new_admin: Address) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let pending: PendingAdminRotation = env - .storage() - .persistent() - .get(&DataKey::PendingAdmin) - .ok_or(RevoraError::NoAdminRotationPending)?; + let effective_max = if max_periods == 0 || max_periods > MAX_CLAIM_PERIODS { + MAX_CLAIM_PERIODS + } else { + max_periods + }; + let end_idx = core::cmp::min(start_idx + effective_max, period_count); + + let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); + let delay_secs: u64 = + env.storage().persistent().get(&delay_key).unwrap_or(0); + let now = env.ledger().timestamp(); + + let mut total_payout: i128 = 0; + let mut claimed_periods = Vec::new(&env); + let mut last_claimed_idx = start_idx; + let mut previous_period_id: Option = None; + + for i in start_idx..end_idx { + // Enforce blacklist/whitelist decisiveness during partial claim sequences + // This ensures that if a holder becomes blacklisted mid-sequence, subsequent + // periods in the batch are not claimed + if Self::is_blacklisted( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ) { + break; + } + + let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); + let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap(); + + // Enforce index monotonicity: ensure periods are claimed in the exact + // order they were deposited in PeriodEntry + if let Some(prev_id) = previous_period_id { + if period_id <= prev_id { + // PeriodEntry order violated - this should never happen with correct + // deposit_revenue implementation, but we defensively check + return Err(RevoraError::NoPendingClaims); + } + } + previous_period_id = Some(period_id); + + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_time: u64 = + env.storage().persistent().get(&time_key).unwrap_or(0); + if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { + break; + } + let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); + let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap(); + let decimals = Self::get_payment_token_decimals( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ); + let normalized = Self::normalize_amount(revenue, decimals); + let payout = normalized * (share_bps as i128) / 10_000; + total_payout += payout; + claimed_periods.push_back(period_id); + last_claimed_idx = i + 1; + } + + if last_claimed_idx == start_idx { + return Err(RevoraError::ClaimDelayNotElapsed); + } + + if total_payout > 0 { + let bucket = crate::tax_bucket::rollover_distribution( + &env, + &offering_id, + &holder, + total_payout, + previous_period_id + .expect("rollover_distribution called with zero claimed periods; total_payout > 0 invariant broken"), + now, + ); + // Update the per-fiscal-year tax accumulator for year-end queries. + let fiscal_start_month = env + .storage() + .persistent() + .get::<_, u32>(&DataKey2::FiscalYearStartMonth(offering_id.clone())) + .unwrap_or(crate::tax_bucket::DEFAULT_FISCAL_START_MONTH); + let fy = crate::tax_bucket::fiscal_year_from_ts(now, fiscal_start_month); + crate::tax_bucket::update_tax_year_accumulator( + &env, + &offering_id, + &holder, + fy, + 0, // ordinary_income — reserved; currently always 0 + bucket.capital_gains, + bucket.return_of_capital, + ); + } + + // Transfer only if there is a positive payout + if total_payout > 0 { + let payment_token = + Self::get_locked_payment_token_for_offering(&env, &offering_id) + .ok_or(RevoraError::PaymentTokenMismatch)?; + let contract_addr = env.current_contract_address(); + if token::Client::new(&env, &payment_token) + .try_transfer(&contract_addr, &holder, &total_payout) + .is_err() + { + return Err(RevoraError::TransferFailed); + } + } + // Advance claim index only for periods actually claimed (respecting delay) + env.storage().persistent().set(&idx_key, &last_claimed_idx); + + let anchor_key = + DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); + if let Some(a) = env + .storage() + .persistent() + .get::(&anchor_key) + { + if start_idx <= a.end_idx && a.end_idx < last_claimed_idx { + total_payout = total_payout.saturating_add(a.claimable_sum); + env.storage().persistent().remove(&anchor_key); + } + } + + // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) + Self::emit_v2_event( + &env, + ( + EVENT_CLAIM_V2, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder.clone(), total_payout, claimed_periods.clone()), + ); + env.events().publish( + ( + EVENT_CLAIM_V2, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder, total_payout, claimed_periods), + ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_CLAIM, + issuer: offering_id.issuer, + namespace: offering_id.namespace, + token: offering_id.token, + period_id: 0, + }, + ), + (total_payout,), + ); + + Ok(total_payout) + } + + /// Read-only: check whether a proposal has reached quorum. + /// Returns `true` if total voted weight (sum of voter_weight_bps) >= quorum_bps. + /// Returns `false` (no panic) for empty votes (treated as zero). + pub fn check_quorum(env: Env, proposal_id: u32) -> bool { + let proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey2::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + Self::check_quorum_inner(&env, &proposal) + } + + /// Read-only: get a proposal by id. + pub fn get_proposal(env: Env, proposal_id: u32) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::MultisigProposal(proposal_id)) + } + + /// Open a formal on-chain dispute against an offering. + /// + /// The dispute ID is deterministic: `sha256(issuer || namespace || token || holder || meta_hash)`. + /// A holder may have at most [`MAX_OPEN_DISPUTES_PER_HOLDER`] open disputes per offering. + /// + /// When `severity` is [`DisputeSeverity::Critical`] the offering's claims are frozen + /// (blocked) until the dispute is resolved or rejected via [`resolve_dispute`]. + /// + /// ### Arguments + /// * `holder` — The address opening the dispute. Must authenticate. + /// * `issuer` — The offering's issuer address. + /// * `namespace` — The offering's namespace symbol. + /// * `token` — The offering's token address. + /// * `severity` — [`DisputeSeverity::Critical`] halts claims until resolved. + /// * `meta_hash` — A 32-byte hash pointing to off-chain dispute evidence (e.g. IPFS CID). + /// + /// ### Errors + /// - [`RevoraError::DisputeZeroShare`] if the holder holds zero shares. + /// - [`RevoraError::DisputeAlreadyOpen`] if an identical dispute already exists. + /// - [`RevoraError::MaxDisputesReached`] if the per-holder cap is exceeded. + pub fn open_dispute( + env: Env, + holder: Address, + issuer: Address, + namespace: Symbol, + token: Address, + severity: DisputeSeverity, + meta_hash: BytesN<32>, + ) -> Result, RevoraError> { + holder.require_auth(); + Self::require_not_frozen(&env)?; + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; - if new_admin != pending.new_admin { - return Err(RevoraError::UnauthorizedRotationAccept); - } + // Reject holders with zero shares (not a participant) + let share = Self::get_holder_share( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ); + if share == 0 { + return Err(RevoraError::DisputeZeroShare); + } + + // Deterministic dispute ID: sha256(issuer || namespace || token || holder || meta_hash) + let mut input = Bytes::new(&env); + input.append(&offering_id.issuer.to_xdr(&env)); + input.append(&offering_id.namespace.to_xdr(&env)); + 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).into(); + + // Reject duplicate + if env + .storage() + .persistent() + .has(&DataKey2::Dispute(dispute_id.clone())) + { + return Err(RevoraError::DisputeAlreadyOpen); + } - new_admin.require_auth(); + // Enforce spam cap per (offering_id, holder) + let count_key = DataKey2::DisputeCount(offering_id.clone(), holder.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + if count >= MAX_OPEN_DISPUTES_PER_HOLDER { + return Err(RevoraError::MaxDisputesReached); + } - // Enforce mandatory delay - let delay: u64 = - env.storage().persistent().get(&DataKey2::AdminRotationDelay).unwrap_or(0u64); - if delay > 0 { - let elapsed = env.ledger().timestamp().saturating_sub(pending.proposed_at); - if elapsed < delay { - return Err(RevoraError::AdminRotationDelayNotElapsed); - } - } + let opened_at = env.ledger().timestamp(); - let old_admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; + let dispute = Dispute { + id: dispute_id.clone(), + holder: holder.clone(), + offering_id: offering_id.clone(), + opened_at, + severity: severity.clone(), + meta_hash: meta_hash.clone(), + status: DisputeStatus::Open, + }; - env.storage().persistent().set(&DataKey::Admin, &new_admin); - env.storage().persistent().remove(&DataKey::PendingAdmin); - - // Persist append-only rotation log entry - let rotation_id: u64 = - env.storage().persistent().get(&DataKey2::AdminRotationCount).unwrap_or(0u64) + 1; - let rotated_at = env.ledger().timestamp(); - let entry = AdminRotationEntry { - prior_admin: old_admin.clone(), - new_admin: new_admin.clone(), - rotated_at, - }; - env.storage().persistent().set(&DataKey2::AdminRotationLog(rotation_id), &entry); - env.storage().persistent().set(&DataKey2::AdminRotationCount, &rotation_id); + env + .storage() + .persistent() + .set(&DataKey2::Dispute(dispute_id.clone()), &dispute); + env.storage().persistent().set(&count_key, &(count + 1)); + + // Track critical dispute for O(1) freeze lookups + if severity == DisputeSeverity::Critical { + let crit_key = DataKey2::CriticalDisputeCount(offering_id.clone()); + let crit_count: u32 = + env.storage().persistent().get(&crit_key).unwrap_or(0); + env.storage().persistent().set(&crit_key, &(crit_count + 1)); + if crit_count == 0 { + env.events().publish( + (EVENT_DISPUTE_FREEZE_ON,), + (offering_id.clone(), holder.clone(), dispute_id.clone()), + ); + } + } + + env.events().publish( + (Symbol::new(&env, "dispute_open"),), + ( + dispute_id.clone(), + offering_id, + holder.clone(), + meta_hash, + severity, + ), + ); + + Ok(dispute_id) + } + + /// Read an on-chain dispute record by its deterministic ID. + /// + /// Returns `None` if no dispute with the given ID exists. + pub fn get_dispute(env: Env, dispute_id: BytesN<32>) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::Dispute(dispute_id)) + } + + /// 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 { + let crit_key = DataKey2::CriticalDisputeCount(offering_id.clone()); + env + .storage() + .persistent() + .get::(&crit_key) + .unwrap_or(0) + > 0 + } + + /// Resolve or reject an open dispute. + /// + /// Only the issuer of the disputed offering may call this. + /// When the last critical dispute for an offering transitions from `Open` to + /// `Resolved` / `Rejected`, the dispute freeze is lifted and a `dispute_freeze_off` + /// event is emitted. + /// + /// ### Arguments + /// * `caller` — The address resolving the dispute. Must be the offering's issuer. + /// * `dispute_id` — The deterministic dispute ID to resolve. + /// * `resolution` — The target status: [`DisputeStatus::Resolved`] or [`DisputeStatus::Rejected`]. + /// + /// ### Errors + /// - [`RevoraError::NotDisputeIssuer`] if the caller is not the dispute's offering issuer. + /// - [`RevoraError::DisputeNotFound`] if no dispute with the given ID exists. + /// - [`RevoraError::DisputeAlreadyResolved`] if the dispute is not `Open`. + pub fn resolve_dispute( + env: Env, + caller: Address, + dispute_id: BytesN<32>, + resolution: DisputeStatus, + ) -> Result<(), RevoraError> { + caller.require_auth(); + + let mut dispute: Dispute = env + .storage() + .persistent() + .get(&DataKey2::Dispute(dispute_id.clone())) + .ok_or(RevoraError::DisputeNotFound)?; + + if dispute.status != DisputeStatus::Open { + return Err(RevoraError::DisputeAlreadyResolved); + } + if caller != dispute.offering_id.issuer { + return Err(RevoraError::NotDisputeIssuer); + } + + let was_critical = dispute.severity == DisputeSeverity::Critical; + dispute.status = resolution.clone(); + env + .storage() + .persistent() + .set(&DataKey2::Dispute(dispute_id), &dispute); + + // Decrement critical dispute count and emit freeze_off when it hits zero + if was_critical { + let crit_key = + DataKey2::CriticalDisputeCount(dispute.offering_id.clone()); + let crit_count: u32 = + env.storage().persistent().get(&crit_key).unwrap_or(0); + if crit_count > 0 { + let new_count = crit_count - 1; + env.storage().persistent().set(&crit_key, &new_count); + if new_count == 0 { + env.events().publish( + (EVENT_DISPUTE_FREEZE_OFF,), + (dispute.offering_id, dispute.holder, dispute.id), + ); + } + } + } - // Evict oldest entry to keep log bounded - if rotation_id > MAX_ADMIN_ROTATION_LOG { - let evict_id = rotation_id - MAX_ADMIN_ROTATION_LOG; - env.storage().persistent().remove(&DataKey2::AdminRotationLog(evict_id)); - } + Ok(()) + } +} - env.events().publish((symbol_short!("adm_acc"), old_admin), new_admin); - Self::emit_v2_event(&env, (EVENT_ADMIN_ROTATION_LOGGED,), entry); +// ── Holder shares, claims, admin, governance, and utility methods ────────── +// Plain impl block — excluded from the ABI spec to keep spec XDR within limit. +impl RevoraRevenueShare { + /// + /// The share determines the percentage of a period's revenue the holder can claim. + /// + /// ### Parameters + /// - `issuer`: The offering issuer. Must provide authentication. + /// - `token`: The token representing the offering. + /// - `holder`: The address of the token holder. + /// - `share_bps`: The holder's share in basis points (0-10000). + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::OfferingNotFound)` if the offering is not found. + /// - `Err(RevoraError::InvalidShareBps)` if `share_bps` exceeds 10000. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// Set a holder's revenue share (in basis points) for an offering. + fn set_holder_share_full( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_bps: u32, + share_class: Option, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + // Verify offering exists and issuer is current + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + Self::require_not_frozen(&env)?; + issuer.require_auth(); + let window = AccessWindow { + start_timestamp, + end_timestamp, + }; + Self::validate_window(&window)?; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env + .storage() + .persistent() + .set(&WindowDataKey::Report(offering_id), &window); + env.events().publish( + (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); + Ok(()) + } + + // ── Meta-authorization, claims, windows, and query methods ─────────────────── + + /// Register an ed25519 public key for a signer address. + /// The signer must authorize this binding. + pub fn register_meta_signer_key( + env: Env, + signer: Address, + public_key: BytesN<32>, + ) -> Result<(), RevoraError> { + signer.require_auth(); + env + .storage() + .persistent() + .set(&MetaDataKey::SignerKey(signer.clone()), &public_key); + Self::emit_v2_event(&env, (EVENT_META_SIGNER_SET, signer), public_key); + Ok(()) + } + + /// Configure the claiming access window for an offering. If unset, always open. + /// Read configured reporting window (if any) for an offering. + pub fn get_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&WindowDataKey::Report(offering_id)) + } + + /// Read configured claiming window (if any) for an offering. + pub fn get_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&WindowDataKey::Claim(offering_id)) + } + + /// @notice Claim accumulated revenue for a holder across multiple unclaimed periods. + /// @dev Payouts are calculated based on the holder's share at the time of claim. + /// Capped at MAX_CLAIM_PERIODS (50) per transaction for gas safety. + /// This function enforces strict security invariants for multi-period claims. + /// + /// @param holder The address of the token holder. Must provide authentication. + /// @param issuer The address of the offering issuer. + /// @param namespace A symbol identifying the namespace. + /// @param token The token representing the offering. + /// @param max_periods Maximum number of periods to process (0 = MAX_CLAIM_PERIODS). + /// + /// @return Ok(i128) The total payout amount on success. + /// @return Err(RevoraError::HolderBlacklisted) if the holder is blacklisted. + /// @return Err(RevoraError::NoPendingClaims) if no share is set or all periods are claimed. + /// @return Err(RevoraError::ClaimDelayNotElapsed) if the next period is still within the claim delay window. + /// + /// # Idempotency and Safety Invariants + /// + /// This function provides the following hard guarantees: + /// + /// 1. **No double-pay**: `LastClaimedIdx` is written to storage only *after* the token + /// transfer succeeds. If the transfer panics (e.g. insufficient contract balance), + /// the index is not advanced and the holder may retry. Soroban's atomic transaction + /// model ensures partial state is never committed. + /// + /// 2. **Index advances only on processed periods**: The index is set to + /// `last_claimed_idx`, which reflects only periods that passed the delay check. + /// Periods blocked by `ClaimDelaySecs` are not counted; the function returns + /// `ClaimDelayNotElapsed` without writing any state. + /// + /// 3. **Zero-payout periods advance the index**: A period with `revenue = 0` (or + /// where `revenue * share_bps / 10_000 == 0` due to truncation) still advances + /// `LastClaimedIdx`. No transfer is issued for zero amounts. This prevents + /// permanently stuck indices on dust periods. + /// + /// 4. **Exhausted state returns `NoPendingClaims`**: Once `LastClaimedIdx >= PeriodCount`, + /// every subsequent call returns `Err(NoPendingClaims)` without touching storage. + /// Callers may safely retry without risk of side effects. + /// + /// 5. **Per-holder isolation**: Each holder's `LastClaimedIdx` is keyed by + /// `(offering_id, holder)`. One holder's claim progress never affects another's. + /// + /// 6. **Auth checked first**: `holder.require_auth()` is the first operation. + /// All subsequent checks (blacklist, share, period count) are read-only and + /// produce no state changes on failure. + /// + /// 7. **Blacklist/whitelist decisiveness during partial sequences**: The blacklist + /// check is performed INSIDE the period iteration loop. If a holder becomes + /// blacklisted mid-sequence during a multi-period claim, the loop breaks immediately + /// and no subsequent periods in the batch are claimed. The index is only advanced + /// for periods successfully processed before the blacklist took effect. This ensures + /// blacklist/whitelist decisions remain decisive even during partial claim sequences. + /// + /// 8. **Index monotonicity enforced**: The function validates that period IDs are + /// strictly increasing as they are retrieved from `PeriodEntry`. This ensures + /// `LastClaimedIdx` advances only in ways that match the deposited period order, + /// preventing any possibility of skipping periods or claiming out of order. + /// + /// # Arguments + /// * `holder` - The address of the holder claiming revenue. + /// * `issuer` - The address of the offering issuer. + /// * `namespace` - A symbol identifying the namespace. + /// * `token` - The address of the token. + /// * `max_periods` - The maximum number of periods to claim in this call. + /// + /// # Events + + /// Claim pending share payouts for a holder on an offering. + pub fn claim( + env: Env, + holder: Address, + issuer: Address, + namespace: Symbol, + token: Address, + max_periods: u32, + ) -> Result { + holder.require_auth(); + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; - Ok(()) + // Halt claims while a critical dispute is active for this offering + if Self::is_dispute_freeze_active(&env, &offering_id) { + return Err(RevoraError::DisputeFreezeActive); } - /// Cancel a pending admin rotation before it is finalized. - /// - /// ### Auth - /// Current admin (`require_auth`). - /// - /// ### Errors - /// - `NoAdminRotationPending` — no rotation is pending. - /// - `ContractFrozen` — contract is frozen. - /// - /// ### Events - /// Emits `adm_canc`: `(adm_canc, current_admin)` → `proposed_new_admin`. - pub fn cancel_admin_rotation(env: Env) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; + // Initial blacklist and freeze checks for early fail-fast + if Self::is_blacklisted( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ) { + return Err(RevoraError::HolderBlacklisted); + } + Self::require_holder_not_frozen(&env, &offering_id, &holder)?; - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; + // Jurisdiction migration deadline enforcement + Self::require_jurisdiction_migration_not_expired( + &env, + &offering_id, + &holder, + symbol_short!("claim"), + )?; - admin.require_auth(); + let share_bps = Self::get_holder_share( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ); + if share_bps == 0 { + return Err(RevoraError::NoPendingClaims); + } - let pending: PendingAdminRotation = env - .storage() - .persistent() - .get(&DataKey::PendingAdmin) - .ok_or(RevoraError::NoAdminRotationPending)?; + Self::require_claim_window_open(&env, &offering_id)?; - env.storage().persistent().remove(&DataKey::PendingAdmin); + let count_key = DataKey::PeriodCount(offering_id.clone()); + let period_count: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); - env.events().publish((symbol_short!("adm_canc"), admin), pending.new_admin); + let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); + let start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - Ok(()) + if start_idx >= period_count { + return Err(RevoraError::NoPendingClaims); } - /// Revoke an in-progress admin rotation proposal, returning the contract to steady state. - /// - /// Allows the outgoing (current) admin to abort an in-progress rotation proposal. - /// - /// ### Auth - /// Current stored admin (`require_auth`). - /// - /// ### Errors - /// - `NoAdminRotationPending` — no rotation is pending. - /// - `NotInitialized` — contract admin is not initialized. - /// - `ContractFrozen` — contract is frozen. - /// - /// ### Events - /// Emits `EVENT_ADMIN_ROTATION_REVOKED` (`"adm_rvk"`): `(adm_rvk, current_admin)` → `proposed_new_admin`. - pub fn revoke_admin_rotation(env: Env) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; + let effective_max = if max_periods == 0 || max_periods > MAX_CLAIM_PERIODS { + MAX_CLAIM_PERIODS + } else { + max_periods + }; + let end_idx = core::cmp::min(start_idx + effective_max, period_count); + + let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); + let delay_secs: u64 = + env.storage().persistent().get(&delay_key).unwrap_or(0); + let now = env.ledger().timestamp(); + + let mut total_payout: i128 = 0; + let mut claimed_periods = Vec::new(&env); + let mut last_claimed_idx = start_idx; + let mut previous_period_id: Option = None; + + for i in start_idx..end_idx { + // Enforce blacklist/whitelist and freeze decisiveness during partial claim sequences + // This ensures that if a holder becomes blacklisted or frozen mid-sequence, subsequent + // periods in the batch are not claimed + if Self::is_blacklisted( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ) { + break; + } + if Self::is_frozen(&env, &offering_id, &holder) { + break; + } + + let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); + let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap(); + + // Enforce index monotonicity: ensure periods are claimed in the exact + // order they were deposited in PeriodEntry + if let Some(prev_id) = previous_period_id { + if period_id <= prev_id { + // PeriodEntry order violated - this should never happen with correct + // deposit_revenue implementation, but we defensively check + return Err(RevoraError::NoPendingClaims); + } + } + previous_period_id = Some(period_id); + + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_time: u64 = + env.storage().persistent().get(&time_key).unwrap_or(0); + if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { + break; + } + let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); + let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap(); + let decimals = Self::get_payment_token_decimals( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ); + let normalized = Self::normalize_amount(revenue, decimals); + let payout = normalized * (share_bps as i128) / 10_000; + total_payout += payout; + claimed_periods.push_back(period_id); + last_claimed_idx = i + 1; + } + + if last_claimed_idx == start_idx { + return Err(RevoraError::ClaimDelayNotElapsed); + } + + // Transfer only if there is a positive payout + if total_payout > 0 { + let payment_token = + Self::get_locked_payment_token_for_offering(&env, &offering_id) + .ok_or(RevoraError::PaymentTokenMismatch)?; + let contract_addr = env.current_contract_address(); + if token::Client::new(&env, &payment_token) + .try_transfer(&contract_addr, &holder, &total_payout) + .is_err() + { + return Err(RevoraError::TransferFailed); + } + } + + // Advance claim index only for periods actually claimed (respecting delay) + env.storage().persistent().set(&idx_key, &last_claimed_idx); + + let anchor_key2 = + DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); + if let Some(a2) = env + .storage() + .persistent() + .get::(&anchor_key2) + { + if start_idx <= a2.end_idx && a2.end_idx < last_claimed_idx { + total_payout = total_payout.saturating_add(a2.claimable_sum); + env.storage().persistent().remove(&anchor_key2); + } + } + + // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) + Self::emit_v2_event( + &env, + ( + EVENT_CLAIM_V2, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder.clone(), total_payout, claimed_periods.clone()), + ); + env.events().publish( + ( + EVENT_CLAIM_V2, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder, total_payout, claimed_periods), + ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_CLAIM, + issuer: offering_id.issuer, + namespace: offering_id.namespace, + token: offering_id.token, + period_id: 0, + }, + ), + (total_payout,), + ); + + Ok(total_payout) + } + + /// Seal a reporting period so that no further `report_revenue` overrides are accepted. + /// + /// Once closed, the period's deposited revenue remains claimable by holders; only + /// issuer-initiated corrections via `override_existing=true` are blocked. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. + /// + /// ### Errors + /// - `OfferingNotFound` – offering does not exist or caller is not the current issuer. + /// - `InvalidPeriodId` – `period_id` is 0. + /// - `PeriodAlreadyClosed` – period has already been sealed. + /// - `ContractFrozen` / `ContractPaused` – contract is not operational. + pub fn close_period( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - admin.require_auth(); + // Verify offering exists and caller is the current issuer. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + // If dual-signature mode is enabled for this offering, the single-sig + // `close_period` path is not available — callers must use `close_period_dual_sig`. + if env + .storage() + .persistent() + .get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::DualSigNotConfigured); + } - let pending: PendingAdminRotation = env - .storage() - .persistent() - .get(&DataKey::PendingAdmin) - .ok_or(RevoraError::NoAdminRotationPending)?; + let closed_key = DataKey2::ClosedPeriod(offering_id, period_id); + if env.storage().persistent().has(&closed_key) { + return Err(RevoraError::PeriodAlreadyClosed); + } - env.storage().persistent().remove(&DataKey::PendingAdmin); + Self::assert_close_period_invariants(&env, &offering_id)?; - env.events().publish((EVENT_ADMIN_ROTATION_REVOKED, admin), pending.new_admin); + let closed_at = env.ledger().timestamp(); + env.storage().persistent().set(&closed_key, &closed_at); - Ok(()) - } + // Track the most recently closed period timestamp for dispute window enforcement. + env.storage().persistent().set( + &DataKey2::LastClosedPeriodTimestamp(offering_id), + &closed_at, + ); - /// Return the proposed new admin address for a pending rotation, or `None` if none is pending. - /// - /// ### Auth - /// None — read-only. - pub fn get_pending_admin_rotation(env: Env) -> Option
{ - env.storage() - .persistent() - .get::(&DataKey::PendingAdmin) - .map(|p| p.new_admin) - } - - /// Return the full [`PendingAdminRotation`] details (new admin + proposal timestamp), - /// or `None` if no rotation is pending. - /// - /// ### Auth - /// None — read-only. - pub fn get_pending_admin_rotation_details(env: Env) -> Option { - env.storage().persistent().get(&DataKey::PendingAdmin) - } - - /// Return a page of the append-only admin rotation history log. - /// - /// Entries are returned in chronological order (earliest first). The log is bounded - /// to [`MAX_ADMIN_ROTATION_LOG`] entries — the oldest entries are evicted FIFO when - /// the limit is reached. - /// - /// ### Pagination - /// - `start`: zero-based index of the first entry to return (0 = most recent first). - /// - `limit`: maximum number of entries to return (capped at [`MAX_PAGE_LIMIT`]). - /// - /// ### Returns - /// `(entries, next_cursor)` where: - /// - `entries` is the page of [`AdminRotationEntry`] values. - /// - `next_cursor` is `Some(next_start)` if there are more entries, or `None` otherwise. - /// - /// ### Auth - /// None — read-only. - pub fn get_admin_rotation_history_page( - env: Env, - start: u32, - limit: u32, - ) -> (Vec, Option) { - let count: u64 = - env.storage().persistent().get(&DataKey2::AdminRotationCount).unwrap_or(0u64); - - let effective_limit = - if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; - - if start as u64 >= count { - return (Vec::new(&env), None); - } + env.events().publish( + (EVENT_PERIOD_CLOSED, issuer, namespace, token), + (period_id, closed_at), + ); - // Compute the first surviving rotation ID (evicted entries are skipped). - let first_surviving: u64 = - if count > MAX_ADMIN_ROTATION_LOG { count - MAX_ADMIN_ROTATION_LOG + 1 } else { 1 }; - let end = core::cmp::min(start as u64 + effective_limit as u64, count); - let mut results = Vec::new(&env); - - for i in start as u64..end { - let rotation_id = first_surviving + i; - let log_key = DataKey2::AdminRotationLog(rotation_id); - let entry: AdminRotationEntry = env.storage().persistent().get(&log_key).unwrap(); - results.push_back(entry); - } + // Compute and persist the canonical per-class payout order (#523). + // Done after the period is sealed so the storage write is monotonic + // and the emitted pay order matches the on-chain sealed state. + Self::record_and_emit_pay_order(&env, &offering_id, period_id); - let next_cursor = if end < count { Some(end as u32) } else { None }; - (results, next_cursor) + Ok(()) + } + + fn assert_close_period_invariants( + env: &Env, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + let total_share_bps: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShareTotal(offering_id.clone())) + .unwrap_or(0); + + let total_shares_issued: i128 = env + .storage() + .persistent() + .get(&DataKey2::TotalSharesIssued(offering_id.clone())) + .unwrap_or(0); + + if total_share_bps > 10_000 + || total_shares_issued < 0 + || total_shares_issued > 10_000 + || total_share_bps as i128 != total_shares_issued + { + return Err(RevoraError::CloseAbortInvariantsViolated); } + Ok(()) + } + + /// Return `true` if the given period has been sealed by `close_period`. + pub fn is_period_closed( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + ) -> bool { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .has(&DataKey2::ClosedPeriod(offering_id, period_id)) + } + + // ── Per-class dividend priority ordering (#523) ──────────────────────── + + /// Compute the canonical class payout order for an offering (#523). + /// + /// Reads the offering's registered `Vec<(ShareClass, ClassConfig)>` and the + /// per-class priority index stored under `DataKey2::ClassPriority`. Classes + /// are sorted ascending by `(priority_index, share_class.to_xdr().bytes)`; + /// ties on priority are broken canonically by XDR-serialized bytes of the + /// `ShareClass`, which gives a stable, deterministic ordering identical + /// across reruns and across dual-sig / single-sig close paths. + /// + /// Classes without an explicit priority index resolve to `DEFAULT_CLASS_PRIORITY = 0`. + /// Returns an empty `Vec` when the offering has no classes registered. + fn resolve_class_pay_order( + env: &Env, + offering_id: &OfferingId, + ) -> Vec { + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + let classes_opt: Option> = + env.storage().persistent().get(&classes_key); + + let classes = match classes_opt { + Some(c) if !c.is_empty() => c, + _ => return Vec::new(env), + }; - /// Freeze the contract with a specific reason: no further state-changing operations allowed. - /// Only admin may call. Emits freeze_reason_v1 event with reason and target address so - /// indexers can categorize halts without inspecting storage. - /// If multisig is initialized, this function is disabled in favor of execute_action(Freeze). - pub fn set_freeze(env: Env, reason: FreezeReason) -> Result<(), RevoraError> { - if env.storage().persistent().has(&DataKey2::MultisigThreshold) { - return Err(RevoraError::LimitReached); - } - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::LimitReached)?; - admin.require_auth(); - env.storage().persistent().set(&DataKey::Frozen, &true); - env.storage().persistent().set(&DataKey2::GlobalFreezeReason, &reason); - env.events().publish((symbol_short!("frz_set"),), (admin, reason)); - Self::emit_v2_event(&env, (EVENT_FREEZE_V2,), true); - Self::emit_v2_event(&env, (EVENT_FREEZE_REASON_V1, admin), reason); - Ok(()) - } - - /// Freeze the contract with the default `Compliance` reason. - /// - /// Convenience wrapper around [`set_freeze`] for callers that do not need to - /// specify a reason explicitly. Existing integrations that call `freeze()` - /// continue to work without modification. - /// - /// ### Auth / Errors / Events - /// Identical to `set_freeze(env, FreezeReason::Compliance)`. - pub fn freeze(env: Env) -> Result<(), RevoraError> { - Self::set_freeze(env, FreezeReason::Compliance) - } - - /// Return the stored global freeze reason, if the contract is globally frozen. - /// - /// Returns `None` when the contract has never been frozen via `set_freeze`. - pub fn get_freeze_reason(env: Env) -> Option { - env.storage().persistent().get(&DataKey2::GlobalFreezeReason) - } - - /// Freeze a single offering while keeping other offerings operational. - /// - /// Authorization boundary: - /// - Current issuer for the offering, or - /// - Global admin - /// - /// Security posture: - /// - This action is blocked when the whole contract is globally frozen (fail-closed). - /// - Claims remain intentionally allowed for frozen offerings so users can exit. - pub fn freeze_offering( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - caller.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + // Build the keyed list: (priority, xdr_bytes, ShareClass). + let mut keyed: Vec<(u32, Bytes, ShareClass)> = Vec::new(env); + for entry in classes.iter() { + let sc = entry.0.clone(); + let priority: u32 = env + .storage() + .persistent() + .get::(&DataKey2::ClassPriority( + offering_id.clone(), + sc.clone(), + )) + .unwrap_or(DEFAULT_CLASS_PRIORITY); + let xdr_bytes: Bytes = sc.to_xdr(env); + keyed.push_back((priority, xdr_bytes, sc)); + } + + // Deterministic ascending sort by (priority, xdr_bytes). + // Implemented as an in-place bubble sort for `soroban_sdk::Vec` (which + // lacks `sort_by`). n is bounded by the per-offering class count, which + // the contract keeps small via ClassConfig. + let n = keyed.len(); + if n > 1 { + let mut i: u32 = 0; + while i < n.saturating_sub(1) { + let mut j: u32 = 0; + let stop = n.saturating_sub(1).saturating_sub(i); + while j < stop { + let cur = keyed.get(j).expect("index valid"); + let nxt = keyed.get(j.saturating_add(1)).expect("index valid"); + let should_swap = match cur.0.cmp(&nxt.0) { + core::cmp::Ordering::Greater => true, + core::cmp::Ordering::Equal => { + cur.1.cmp(&nxt.1) == core::cmp::Ordering::Greater + } + core::cmp::Ordering::Less => false, + }; + if should_swap { + keyed.set(j, nxt); + keyed.set(j.saturating_add(1), cur); + } + j = j.saturating_add(1); + } + i = i.saturating_add(1); + } + } + + let mut out: Vec = Vec::new(env); + for entry in keyed.iter() { + out.push_back(entry.2.clone()); + } + out + } + + /// Persist the resolved pay order and emit the `EVENT_CLASS_PAY_ORDER` event. + /// Called from both single-sig and dual-sig `close_period` paths after + /// existing validation/sealing logic so the canonical ordering is recorded + /// once per closed period and downstream auditors/indexers see a stable + /// per-period distribution order. + /// + /// Always emits, including when no classes are registered for the offering + /// — a `Vec::new()` payload is the documented fallback for legacy or + /// classless offerings. Indexers should treat empty orders as the + /// pre-deployment / no-class baseline. + fn record_and_emit_pay_order( + env: &Env, + offering_id: &OfferingId, + period_id: u64, + ) { + let ordered = Self::resolve_class_pay_order(env, offering_id); + env.storage().persistent().set( + &DataKey2::ClassPayOrder(offering_id.clone(), period_id), + &ordered, + ); + env.events().publish( + ( + EVENT_CLASS_PAY_ORDER, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (period_id, ordered), + ); + } + + // ── Close-of-period preflight (#563) ────────────────────────────────────────── + // + // Pure (no side effects) helper that computes the per-period preview + // returned by `preflight_close_period`. Reads from storage and never writes + // or emits events. The write-path `close_period` / + // `close_period_dual_sig` keep their inline pay-order resolver for tight + // coupling — callers verify parity by computing identical `class_pay_order` + // values (the very property the existing single-sig vs. dual-sig test + // suite asserts). + // + // Math: `compute_share(period_revenue, share_bps, rounding_mode)`, the + // same math the closest analogue `simulate_distribution` applies for the + // no-classes path. Blacklisted holders are silently skipped. Holders + // with zero `share_bps` are emitted with `normalized_payout = 0` so + // callers can detect them. Total is a saturating sum so it can never + // exceed `period_revenue`. + fn compute_period_close_preview( + env: &Env, + offering_id: &OfferingId, + period_id: u64, + holders: Vec
, + ) -> PreflightCloseResult { + let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); + let period_revenue: i128 = env + .storage() + .persistent() + .get::(&rev_key) + .unwrap_or(0); + + let class_pay_order = Self::resolve_class_pay_order(env, offering_id); + + let mode: RoundingMode = Self::get_rounding_mode( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ); + + let n = holders.len(); + let mut payout_rows: std::vec::Vec<(u32, u32, Address, i128)> = + std::vec::Vec::new(); + let mut total: i128 = 0; + + for i in 0..n { + let holder = holders.get(i).unwrap(); + + // Blacklist precedence rule: a blacklisted address is ALWAYS + // excluded from payouts, regardless of any whitelist or + // investor registration. Dropped silently from the preview. + if Self::is_blacklisted( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + ) { + continue; + } + + let share_bps: u32 = env + .storage() + .persistent() + .get::(&DataKey::HolderShare( + offering_id.clone(), + holder.clone(), + )) + .unwrap_or(0); + + // Saturate share_bps to 10_000 — matches simulate_distribution's + // defence against malformed shares. With 0 share_bps the entry + // is still emitted (with payout 0) so callers can detect it. + let bounded_bps: u32 = if share_bps > 10_000 { + 10_000 + } else { + share_bps + }; + let normalized_payout = + Self::compute_share(env.clone(), period_revenue, bounded_bps, mode); + + total = total.saturating_add(normalized_payout); + payout_rows.push(( + bounded_bps, + share_bps, + holder.clone(), + normalized_payout, + )); + } + + payout_rows.sort_by(|a, b| match b.0.cmp(&a.0) { + core::cmp::Ordering::Equal => a.2.cmp(&b.2), + other => other, + }); + + let mut payouts: Vec = Vec::new(env); + for (bounded_bps, share_bps, holder, normalized_payout) in payout_rows { + let _ = bounded_bps; + payouts.push_back(DistributionEntry { + holder, + share_bps, + normalized_payout, + }); + } + + PreflightCloseResult { + period_id, + period_revenue, + class_pay_order, + payouts, + total_distributed: total, + } + } + + /// Set the dividend priority index for a registered class on an offering (#523). + /// + /// Lower `priority_index` values resolve to earlier payout positions under + /// `close_period`. The configured class is recorded and an + /// `EVENT_CLASS_PRIORITY_SET` event is emitted so indexers and dashboards + /// can track priority changes. + /// + /// ### Auth + /// Requires issuer-quorum authentication matching the contract-wide + /// `Issuers.quorum` policy used by `set_holder_share` and similar mutations. + /// A priority change effectively reorders how distributions are paid out, so + /// we treat it as governance-equivalent to a holder-share mutation. + /// + /// ### Errors + /// - [`RevoraError::OfferingNotFound`] if the offering does not exist or the + /// caller is not the current issuer. + /// - [`RevoraError::InvalidShareClass`] if `share_class` is not a registered + /// class on the offering (i.e. absent from `DataKey2::OfferingClasses`). + /// - [`RevoraError::ContractFrozen`] / [`RevoraError::ContractPaused`] when + /// the contract is not operational. + #[allow(clippy::too_many_arguments)] + pub fn set_class_priority( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + share_class: ShareClass, + priority_index: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + // Issuer-quorum requires the primary signer and the configured number + // of co-signers to have authorized. This matches `set_holder_share` + // and other governance-equivalent mutations. + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } + // Verify the share_class is registered for this offering. Rejecting + // unregistered classes prevents storage pollution and Denial-of-Service + // via arbitrarily large priority-index entries. + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + let classes_opt: Option> = + env.storage().persistent().get(&classes_key); + let registered = classes_opt + .as_ref() + .map(|v| v.iter().any(|(sc, _)| sc == share_class)) + .unwrap_or(false); + if !registered { + return Err(RevoraError::InvalidShareClass); + } + + env.storage().persistent().set( + &DataKey2::ClassPriority(offering_id.clone(), share_class.clone()), + &priority_index, + ); + + env.events().publish( + ( + EVENT_CLASS_PRIORITY_SET, + issuer, + namespace, + token, + share_class, + ), + priority_index, + ); - let key = DataKey2::FrozenOffering(offering_id); - env.storage().persistent().set(&key, &true); - env.events().publish((EVENT_FREEZE_OFFERING, issuer, namespace, token), (caller, true)); - Ok(()) - } - - /// Unfreeze a single offering. - /// - /// Authorization mirrors `freeze_offering`: issuer or admin. - pub fn unfreeze_offering( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - caller.require_auth(); + Ok(()) + } + + /// Read the dividend priority index for a class on an offering (#523). + /// Returns `DEFAULT_CLASS_PRIORITY = 0` when no explicit priority has been set. + pub fn get_class_priority( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + share_class: ShareClass, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey2::ClassPriority(offering_id, share_class)) + .unwrap_or(DEFAULT_CLASS_PRIORITY) + } + + /// Read the canonical class payout order resolved at `close_period` time (#523). + /// Returns an empty `Vec` if the period was never closed via the + /// updated `close_period` / `close_period_dual_sig` implementation. + pub fn get_class_pay_order( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + ) -> Vec { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::>(&DataKey2::ClassPayOrder( + offering_id, + period_id, + )) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Enable or disable dual-signature close-of-period mode for an offering (#565). + /// + /// When enabled, `close_period` will reject with `DualSigNotConfigured` and the + /// issuer must use `close_period_dual_sig` instead, which requires two distinct + /// authorized signers. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. + /// + /// ### Errors + /// - `OfferingNotFound` – offering does not exist or caller is not the current issuer. + /// - `ContractFrozen` / `ContractPaused` – contract is not operational. + pub fn set_dual_sig_config( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + enabled: bool, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + // Verify offering exists and caller is the current issuer. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + env + .storage() + .persistent() + .set(&DataKey2::DualSigEnabled(offering_id), &enabled); + + env.events().publish( + (symbol_short!("dual_cfg"), issuer, namespace, token), + (enabled,), + ); + Ok(()) + } + + /// Close a period using dual-signature authorization. + /// + /// For high-value periods, this function requires two distinct signers to + /// authorize the close. Both signers must be valid issuers of the offering + /// (the primary issuer or a co-issuer). + /// + /// ### Auth + /// Requires both `sig_a.require_auth()` and `sig_b.require_auth()`. + /// + /// ### Errors + /// - `DualSigSameSigner` – `sig_a` and `sig_b` are the same address. + /// - `DualSigNotConfigured` – dual-signature mode has not been enabled for this offering. + /// - `OfferingNotFound` – offering does not exist or a signer is not a valid issuer. + /// - `InvalidPeriodId` – `period_id` is 0. + /// - `PeriodAlreadyClosed` – period has already been sealed. + /// - `ContractFrozen` / `ContractPaused` – contract is not operational. + pub fn close_period_dual_sig( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + sig_a: Address, + sig_b: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + sig_a.require_auth(); + sig_b.require_auth(); + + // Both signers must be distinct. + if sig_a == sig_b { + return Err(RevoraError::DualSigSameSigner); + } + + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } + // Verify offering exists and retrieve the full Offering (including issuers). + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + // Both signers must be valid issuers (primary or co-issuer). + let is_valid = |addr: &Address| -> bool { + if &offering.issuers.primary == addr { + return true; + } + offering.issuers.co.iter().any(|co| co == addr) + }; + if !is_valid(&sig_a) || !is_valid(&sig_b) { + return Err(RevoraError::OfferingNotFound); + } - let key = DataKey2::FrozenOffering(offering_id); - env.storage().persistent().set(&key, &false); - env.events().publish((EVENT_UNFREEZE_OFFERING, issuer, namespace, token), (caller, false)); - Ok(()) - } - - /// Return true if an individual offering is frozen. - pub fn is_offering_frozen( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> bool { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey2::FrozenOffering(offering_id)) - .unwrap_or(false) - } - - /// Return true if the contract is frozen. - pub fn is_frozen(env: Env) -> bool { - env.storage().persistent().get::(&DataKey::Frozen).unwrap_or(false) - } - - /// Emergency freeze a holder for an offering. - /// - /// Authorization boundary: - /// - Current issuer for the offering, or - /// - Global admin - /// - /// Security posture: - /// - This action is blocked when the whole contract is globally frozen (fail-closed). - /// - Claims and transfers are blocked for the holder. - pub fn emergency_freeze_holder( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - reason: FreezeReason, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - caller.require_auth(); + // Dual-signature mode must be enabled for this offering. + if !env + .storage() + .persistent() + .get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::DualSigNotConfigured); + } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + let closed_key = DataKey2::ClosedPeriod(offering_id, period_id); + if env.storage().persistent().has(&closed_key) { + return Err(RevoraError::PeriodAlreadyClosed); + } - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } + let closed_at = env.ledger().timestamp(); + env.storage().persistent().set(&closed_key, &closed_at); - // 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) - }); + // Track the most recently closed period timestamp for dispute window enforcement. + env.storage().persistent().set( + &DataKey2::LastClosedPeriodTimestamp(offering_id), + &closed_at, + ); - 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(()); - } + env.events().publish( + (EVENT_DUAL_SIG_CLOSE, issuer, namespace, token), + (period_id, closed_at, sig_a, sig_b), + ); - current_mask |= reason_bit; + // Compute and persist the canonical per-class payout order (#523). + // Mirrors the single-sig `close_period` path so both close flows + // resolve to the identical deterministic order. + Self::record_and_emit_pay_order(&env, &offering_id, period_id); - // Clean up legacy key if present. - if env.storage().persistent().has(&legacy_key) { - env.storage().persistent().remove(&legacy_key); - } + Ok(()) + } + + /// Read-only: preview the canonical class pay order and per-holder + /// payouts that a successful `close_period` / `close_period_dual_sig` + /// call would expose for the given period — without touching storage or + /// emitting any event (#563). + /// + /// Mirrors the precondition chain of [`Self::close_period`] so that any + /// error the write path would surface (`ContractFrozen`, `ContractPaused`, + /// `InvalidPeriodId`, `OfferingNotFound`, `PeriodAlreadyClosed`) is also + /// surfaced here. A successful preflight guarantees the atomic close + /// would succeed for the same `(offering_id, period_id)` assuming the + /// caller passes `issuer.require_auth()` on the actual write call. + /// + /// `holders` is required because Soroban persistent maps cannot be + /// iterated by key — this matches the codebase idiom set by + /// [`Self::simulate_distribution`]. Pass the same holder set the + /// eventual `claim` flow will iterate. Blacklisted addresses are + /// silently dropped from the returned `payouts` (precedence rule: + /// blacklist wins over whitelist and over any non-zero share). + /// + /// # Compute parity + /// + /// The `class_pay_order` returned by this entrypoint is byte-identical + /// to what `close_period` would persist via `record_and_emit_pay_order` + /// for the same storage snapshot. The `payouts[i].normalized_payout` + /// values are produced by `compute_share(revenue, share_bps, mode)`, + /// matching the math `simulate_distribution` applies. There is no + /// `normalize_amount` step here because the surrounding + /// `simulate_distribution` flow does not apply one either; indexers + /// should treat this preview as the canonical non-normalized + /// distribution shape. + pub fn preflight_close_period( + env: Env, + offering_id: OfferingId, + period_id: u64, + holders: Vec
, + ) -> Result { + // Mirror `close_period`'s precondition chain exactly. NOTE: we + // intentionally do NOT execute `issuer.require_auth()` here — the + // preflight is a read-only view callable by anyone, aligning with + // `get_offering`, `simulate_distribution`, and `get_class_pay_order`. + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + + // Offering existence. We use `DataKey2::OfferingRecord` (introduced + // for O(1) issuance lookups) rather than `get_offering` so we do + // not charge for a full offering struct deserialisation when we + // only need the existence check. + let offering_record_key = DataKey2::OfferingRecord(offering_id.clone()); + if !env.storage().persistent().has(&offering_record_key) { + return Err(RevoraError::OfferingNotFound); + } + + // Period already sealed — close would reject too, so the preflight + // must reject too. Operators calling preflight then close on the + // same period therefore see a deterministic `PeriodAlreadyClosed` + // rather than a stale preview. + let closed_key = DataKey2::ClosedPeriod(offering_id.clone(), period_id); + if env.storage().persistent().has(&closed_key) { + return Err(RevoraError::PeriodAlreadyClosed); + } + + Ok(Self::compute_period_close_preview( + &env, + &offering_id, + period_id, + holders, + )) + } + + /// Attach or replace off-chain disclosure metadata for an offering (#485). + /// + /// Issuers use this to bind a private placement memorandum (PPM), K-1 template, + /// or any other off-chain document to the on-chain record so investors can verify + /// the document's integrity via the stored hash. + /// + /// ### Validation + /// - `uri` must be at most 256 bytes; longer values return `DisclosureUriTooLong`. + /// - An empty `uri` paired with a non-zero `hash` returns `InconsistentDisclosure`. + /// (A zero-hash with an empty URI clears any previous disclosure.) + /// + /// ### Auth ordering + /// `issuer.require_auth()` is called immediately after the frozen guard. + pub fn update_disclosure( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + uri: Bytes, + hash: BytesN<32>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - env.storage().persistent().set(&key, ¤t_mask); - env.events().publish((EVENT_FRZ_SET, issuer, namespace, token), (caller, holder, reason)); - Ok(()) - } - - // ── TWAP window configurability (#546) ─────────────────────────────────── - - /// Configure the TWAP (Time-Weighted Average Price) smoothing window for an - /// offering's NAV computation. - /// - /// # Authorization - /// Caller must be either: - /// - the **current issuer** of the offering, or - /// - the **contract admin**. - /// - /// `caller.require_auth()` is called before any state reads, following the - /// auth-first pattern used throughout this contract. - /// - /// # Bounds - /// `window_secs` must satisfy: - /// ```text - /// MIN_TWAP_WINDOW_SECS (60 s) <= window_secs <= MAX_TWAP_WINDOW_SECS (30 days) - /// ``` - /// Values outside this range are rejected with a structured [`RevoraError`]: - /// - Below minimum → [`RevoraError::TwapWindowTooShort`] - /// - Above maximum → [`RevoraError::TwapWindowTooLong`] - /// - /// # Errors - /// | Error | Condition | - /// |---|---| - /// | `ContractFrozen` | Contract is globally frozen | - /// | `OfferingNotFound` | No offering exists for `(issuer, namespace, token)` | - /// | `NotAuthorized` | Caller is neither issuer nor admin | - /// | `TwapWindowTooShort` | `window_secs < MIN_TWAP_WINDOW_SECS` | - /// | `TwapWindowTooLong` | `window_secs > MAX_TWAP_WINDOW_SECS` | - /// - /// # Events - /// On success emits: - /// ```text - /// topic: (twap_win_set, issuer, namespace, token) - /// data: (caller, window_secs) - /// ``` - pub fn set_twap_window( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - window_secs: u64, - ) -> Result<(), RevoraError> { - // Auth-first: authenticate before any state reads. - Self::require_not_frozen(&env)?; - caller.require_auth(); - - // Bounds check — reject values outside the documented min/max range. - if window_secs < MIN_TWAP_WINDOW_SECS { - return Err(RevoraError::TwapWindowTooShort); - } - if window_secs > MAX_TWAP_WINDOW_SECS { - return Err(RevoraError::TwapWindowTooLong); - } + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + // URI length guard: max 256 bytes. + if uri.len() > 256 { + return Err(RevoraError::DisclosureUriTooLong); + } - // Verify offering exists and resolve authorization. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } + // Coherence guard: non-zero hash requires a URI. + let zero_hash = BytesN::from_array(&env, &[0u8; 32]); + if uri.is_empty() && hash != zero_hash { + return Err(RevoraError::InconsistentDisclosure); + } - let config = TwapConfig { - twap_window_secs: window_secs, - updated_at: env.ledger().timestamp(), - updated_by: caller.clone(), - }; - env.storage().persistent().set(&DataKey2::TwapWindowSecs(offering_id), &config); - - env.events() - .publish((EVENT_TWAP_WINDOW_SET, issuer, namespace, token), (caller, window_secs)); - Ok(()) - } - - /// Return the current [`TwapConfig`] for an offering, or `None` if no TWAP - /// window has been configured yet. - /// - /// This is a read-only view — no auth required. - pub fn get_twap_window( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get::(&DataKey2::TwapWindowSecs(offering_id)) - } - - /// Emergency unfreeze a holder for an offering. - /// - /// Authorization boundary matches `emergency_freeze_holder`. - /// - /// Security posture: - /// - Requires the exact same `reason` that was used to freeze the 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, - namespace: Symbol, - token: Address, - holder: Address, - reason: FreezeReason, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - caller.require_auth(); + let key = DataKey2::DisclosureMeta(offering_id); + env.storage().persistent().set( + &key, + &DisclosureMeta { + uri: uri.clone(), + hash: hash.clone(), + }, + ); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + Self::emit_v2_event( + &env, + (EVENT_DISCLOSURE_UPDATED, issuer, namespace, token), + (uri, hash), + ); - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - let admin = Self::get_admin(env.clone()); - let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); - if caller != current_issuer && !is_admin { - return Err(RevoraError::NotAuthorized); - } + Ok(()) + } + + /// Return the off-chain disclosure metadata for an offering, if set. + pub fn get_disclosure( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::DisclosureMeta(offering_id)) + } +} - let key = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()); - let legacy_key = DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()); +// ── Holder shares, claims, admin, governance, and utility methods ───────────── +// Plain impl block — excluded from the ABI spec to keep spec XDR within limit. +impl RevoraRevenueShare { + /// + /// The share determines the percentage of a period's revenue the holder can claim. + /// + /// ### Parameters + /// - `issuer`: The offering issuer. Must provide authentication. + /// - `token`: The token representing the offering. + /// - `holder`: The address of the token holder. + /// - `share_bps`: The holder's share in basis points (0-10000). + /// + /// ### Returns + /// - `Ok(())` on success. + /// - `Err(RevoraError::OfferingNotFound)` if the offering is not found. + /// - `Err(RevoraError::InvalidShareBps)` if `share_bps` exceeds 10000. + /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. + /// Set a holder's revenue share (in basis points) for an offering. + fn set_holder_share_full( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_bps: u32, + share_class: Option, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + // Verify offering exists and issuer is current + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + Self::require_not_frozen(&env)?; + issuer.require_auth(); + Self::set_holder_share_internal( + &env, + offering_id.issuer, + offering_id.namespace, + offering_id.token, + holder, + share_bps, + share_class, + None, + ) + } + + // ── Meta-authorization, claims, windows, and query methods ─────────────────── + + /// Register an ed25519 public key for a signer address. + /// The signer must authorize this binding. + pub fn register_meta_signer_key( + env: Env, + signer: Address, + public_key: BytesN<32>, + ) -> Result<(), RevoraError> { + signer.require_auth(); + env + .storage() + .persistent() + .set(&MetaDataKey::SignerKey(signer.clone()), &public_key); + Self::emit_v2_event(&env, (EVENT_META_SIGNER_SET, signer), public_key); + Ok(()) + } + + /// Set or update an offering-level delegate signer for off-chain authorizations. + /// Only the current issuer may set this value. + pub fn set_meta_delegate( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + delegate: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env + .storage() + .persistent() + .set(&MetaDataKey::Delegate(offering_id), &delegate); + Self::emit_v2_event( + &env, + (EVENT_META_DELEGATE_SET, issuer, namespace, token), + delegate, + ); + Ok(()) + } + + /// Get the configured offering-level delegate signer. + pub fn get_meta_delegate( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&MetaDataKey::Delegate(offering_id)) + } + + /// Meta-transaction variant of `set_holder_share`. + /// A registered delegate signer authorizes this action via off-chain ed25519 signature. + #[allow(clippy::too_many_arguments)] + pub fn meta_set_holder_share( + env: Env, + signer: Address, + payload: MetaSetHolderSharePayload, + nonce: u64, + expiry: u64, + signature: BytesN<64>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + let current_issuer = Self::get_current_issuer( + &env, + payload.issuer.clone(), + payload.namespace.clone(), + payload.token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != payload.issuer { + return Err(RevoraError::OfferingNotFound); + } + let offering_id = OfferingId { + issuer: payload.issuer.clone(), + namespace: payload.namespace.clone(), + token: payload.token.clone(), + }; + Self::require_not_frozen(&env)?; + let configured_delegate: Address = env + .storage() + .persistent() + .get(&MetaDataKey::Delegate(offering_id)) + .ok_or(RevoraError::NotAuthorized)?; + if configured_delegate != signer { + return Err(RevoraError::NotAuthorized); + } + let action = MetaAction::SetHolderShare(payload.clone()); + Self::verify_meta_signature( + &env, &signer, nonce, expiry, action, &signature, + )?; + Self::set_holder_share_internal( + &env, + payload.issuer.clone(), + payload.namespace.clone(), + payload.token.clone(), + payload.holder.clone(), + payload.share_bps, + None, + None, + )?; + Self::mark_meta_nonce_used(&env, &signer, nonce); + env.events().publish( + ( + EVENT_META_SHARE_SET, + payload.issuer, + payload.namespace, + payload.token, + ), + (signer, payload.holder, payload.share_bps, nonce, expiry), + ); + Ok(()) + } + + /// Meta-transaction authorization for a revenue report payload. + /// This does not mutate revenue data directly; it records a signed approval. + #[allow(clippy::too_many_arguments)] + pub fn meta_approve_revenue_report( + env: Env, + signer: Address, + payload: MetaRevenueApprovalPayload, + nonce: u64, + expiry: u64, + signature: BytesN<64>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + let current_issuer = Self::get_current_issuer( + &env, + payload.issuer.clone(), + payload.namespace.clone(), + payload.token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != payload.issuer { + return Err(RevoraError::OfferingNotFound); + } + let offering_id = OfferingId { + issuer: payload.issuer.clone(), + namespace: payload.namespace.clone(), + token: payload.token.clone(), + }; + Self::require_not_frozen(&env)?; + let configured_delegate: Address = env + .storage() + .persistent() + .get(&MetaDataKey::Delegate(offering_id.clone())) + .ok_or(RevoraError::NotAuthorized)?; + if configured_delegate != signer { + return Err(RevoraError::NotAuthorized); + } + let action = MetaAction::ApproveRevenueReport(payload.clone()); + Self::verify_meta_signature( + &env, &signer, nonce, expiry, action, &signature, + )?; + env.storage().persistent().set( + &MetaDataKey::RevenueApproved(offering_id, payload.period_id), + &true, + ); + Self::mark_meta_nonce_used(&env, &signer, nonce); + env.events().publish( + ( + EVENT_META_REV_APPROVE, + payload.issuer, + payload.namespace, + payload.token, + ), + ( + signer, + payload.payout_asset, + payload.amount, + payload.period_id, + payload.override_existing, + nonce, + expiry, + ), + ); + Ok(()) + } + + /// Return a holder's share in basis points for an offering (0 if unset). + fn get_holder_share_internal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::HolderShare(offering_id, holder); + env.storage().persistent().get(&key).unwrap_or(0) + } + + /// @notice Claim accumulated revenue for a holder across multiple unclaimed periods. + /// @dev Payouts are calculated based on the holder's share at the time of claim. + /// Capped at MAX_CLAIM_PERIODS (50) per transaction for gas safety. + /// This function enforces strict security invariants for multi-period claims. + /// + /// @param holder The address of the token holder. Must provide authentication. + /// @param issuer The address of the offering issuer. + /// @param namespace A symbol identifying the namespace. + /// @param token The token representing the offering. + /// @param max_periods Maximum number of periods to process (0 = MAX_CLAIM_PERIODS). + /// + /// @return Ok(i128) The total payout amount on success. + /// @return Err(RevoraError::HolderBlacklisted) if the holder is blacklisted. + /// @return Err(RevoraError::NoPendingClaims) if no share is set or all periods are claimed. + /// @return Err(RevoraError::ClaimDelayNotElapsed) if the next period is still within the claim delay window. + /// + /// # Idempotency and Safety Invariants + /// + /// This function provides the following hard guarantees: + /// + /// 1. **No double-pay**: `LastClaimedIdx` is written to storage only *after* the token + /// transfer succeeds. If the transfer panics (e.g. insufficient contract balance), + /// the index is not advanced and the holder may retry. Soroban's atomic transaction + /// model ensures partial state is never committed. + /// + /// 2. **Index advances only on processed periods**: The index is set to + /// `last_claimed_idx`, which reflects only periods that passed the delay check. + /// Periods blocked by `ClaimDelaySecs` are not counted; the function returns + /// `ClaimDelayNotElapsed` without writing any state. + /// + /// 3. **Zero-payout periods advance the index**: A period with `revenue = 0` (or + /// where `revenue * share_bps / 10_000 == 0` due to truncation) still advances + /// `LastClaimedIdx`. No transfer is issued for zero amounts. This prevents + /// permanently stuck indices on dust periods. + /// + /// 4. **Exhausted state returns `NoPendingClaims`**: Once `LastClaimedIdx >= PeriodCount`, + /// every subsequent call returns `Err(NoPendingClaims)` without touching storage. + /// Callers may safely retry without risk of side effects. + /// + /// 5. **Per-holder isolation**: Each holder's `LastClaimedIdx` is keyed by + /// `(offering_id, holder)`. One holder's claim progress never affects another's. + /// + /// 6. **Auth checked first**: `holder.require_auth()` is the first operation. + /// All subsequent checks (blacklist, share, period count) are read-only and + /// produce no state changes on failure. + /// + /// 7. **Blacklist/whitelist decisiveness during partial sequences**: The blacklist + /// check is performed INSIDE the period iteration loop. If a holder becomes + /// blacklisted mid-sequence during a multi-period claim, the loop breaks immediately + /// and no subsequent periods in the batch are claimed. The index is only advanced + /// for periods successfully processed before the blacklist took effect. This ensures + /// blacklist/whitelist decisions remain decisive even during partial claim sequences. + /// + /// 8. **Index monotonicity enforced**: The function validates that period IDs are + /// strictly increasing as they are retrieved from `PeriodEntry`. This ensures + /// `LastClaimedIdx` advances only in ways that match the deposited period order, + /// preventing any possibility of skipping periods or claiming out of order. + /// + /// # Arguments + /// * `holder` - The address of the holder claiming revenue. + /// * `issuer` - The address of the offering issuer. + /// * `namespace` - A symbol identifying the namespace. + /// * `token` - The address of the token. + /// * `max_periods` - The maximum number of periods to claim in this call. + /// + /// # Events + /// Read-only: return a page of pending period IDs for a holder, bounded by `limit`. + /// Returns `(periods_page, next_cursor)` where `next_cursor` is `Some(next_index)` when more + /// periods remain, otherwise `None`. `limit` of 0 or greater than `MAX_PAGE_LIMIT` will be + /// capped to `MAX_PAGE_LIMIT` to keep calls predictable. + #[allow(clippy::too_many_arguments)] + pub fn get_pending_periods_page( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + start: u32, + limit: u32, + ) -> (Vec, Option) { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let count_key = DataKey::PeriodCount(offering_id.clone()); + let period_count: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); - // 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) - }); + let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder); + let holder_start_idx: u32 = + env.storage().persistent().get(&idx_key).unwrap_or(0); - if current_mask == 0 { - return Err(RevoraError::HolderFrozen); - } + let actual_start = core::cmp::max(start, holder_start_idx); - let reason_bit = reason.to_bitmask(); - if current_mask & reason_bit == 0 { - return Err(RevoraError::FreezeReasonMismatch); - } + if actual_start >= period_count { + return (Vec::new(&env), None); + } - let new_mask = current_mask & !reason_bit; + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; + let end = core::cmp::min(actual_start + effective_limit, period_count); + + let mut results = Vec::new(&env); + for i in actual_start..end { + let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); + let period_id: u64 = + env.storage().persistent().get(&entry_key).unwrap_or(0); + if period_id == 0 { + continue; + } + results.push_back(period_id); + } + + let next_cursor = if end < period_count { Some(end) } else { None }; + (results, next_cursor) + } + + /// Read-only: return a paginated statement page for a holder. + /// + /// Each entry is ordered by the persisted `PeriodEntry` index, which is monotonic in + /// `period_id` for valid offering state. The cursor is the zero-based period-entry index + /// and is clamped to the holder's current `LastClaimedIdx`, so stale callers cannot page + /// back into already-claimed history. + /// + /// Security assumptions: + /// - Returning an empty page for a cursor past the end must be safe and deterministic. + /// - The first delayed period forms a hard stop because later periods are not claimable yet. + /// - `limit` is capped to `MAX_PAGE_LIMIT` to keep read gas bounded. + #[allow(clippy::too_many_arguments)] + pub fn get_holder_statement_page( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + cursor: u32, + limit: u32, + ) -> (Vec, Option) { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Clean up legacy key if present. - if env.storage().persistent().has(&legacy_key) { - env.storage().persistent().remove(&legacy_key); - } + if Self::is_blacklisted( + env.clone(), + issuer, + namespace, + token, + holder.clone(), + ) { + return (Vec::new(&env), None); + } + if Self::require_claim_window_open(&env, &offering_id).is_err() { + return (Vec::new(&env), None); + } - 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), - ); - } + let count_key = DataKey::PeriodCount(offering_id.clone()); + let period_count: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); - 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, - issuer: Address, - namespace: Symbol, - 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::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. - /// Returns `None` if no dispute with the given ID exists. - pub fn get_dispute(env: Env, dispute_id: u64) -> Option { - env.storage().persistent().get(&DataKey2::DisputeEntry(dispute_id)) - } - - // ── Multisig admin logic ─────────────────────────────────── - - /// Process an OFAC attestation and automatically freeze the targeted holder. - /// - /// When an OFAC attestation naming a currently-active holder is submitted, - /// this function freezes the holder's account immediately and emits a structured - /// event referencing the attestation hash. This operation is idempotent per - /// attestation hash to prevent replay attacks. - /// - /// ### Parameters - /// - `attestation_hash`: The 32-byte hash of the OFAC attestation. Used for idempotency. - /// - `issuer`: The issuer address of the offering. - /// - `namespace`: The namespace of the offering. - /// - `token`: The token representing the offering. - /// - `holder`: The holder address to be frozen if named in the attestation. - /// - /// ### Security Assumptions - /// - The caller is authorized to submit OFAC attestations (typically an admin or oracle). - /// - The attestation hash uniquely identifies a specific OFAC attestation. - /// - Idempotency is enforced by tracking processed attestation hashes. - /// - /// ### Returns - /// - `Ok(())` on success (or if the attestation was already processed). - /// - `Err(RevoraError::ContractFrozen)` if the contract is globally frozen. - /// - /// ### Events - /// - Emits `auto_frz` with `(issuer, namespace, token)` topics and `(holder, attestation_hash)` data. - #[allow(clippy::too_many_arguments)] - pub fn process_ofac_attestation( - env: Env, - attestation_hash: BytesN<32>, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - - // Check idempotency: if this attestation hash was already processed, return success - let hash_key = DataKey2::ProcessedAttestationHash(attestation_hash.clone()); - if env.storage().persistent().has(&hash_key) { - return Ok(()); - } + let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); + let holder_start_idx: u32 = + env.storage().persistent().get(&idx_key).unwrap_or(0); + let start_idx = core::cmp::max(cursor, holder_start_idx); + if start_idx >= period_count { + return (Vec::new(&env), None); + } - // Mark the attestation hash as processed - if !Self::is_event_only(&env) { - env.storage().persistent().set(&hash_key, &true); - } + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); + let delay_secs: u64 = + env.storage().persistent().get(&delay_key).unwrap_or(0); + let now = env.ledger().timestamp(); + + let mut entries = Vec::new(&env); + let mut processed: u32 = 0; + let mut idx = start_idx; + let mut previous_period_id: Option = None; + + while idx < period_count && processed < effective_limit { + let entry_key = DataKey::PeriodEntry(offering_id.clone(), idx); + let period_id: u64 = + env.storage().persistent().get(&entry_key).unwrap_or(0); + if period_id == 0 { + idx = idx.saturating_add(1); + continue; + } + + if let Some(previous) = previous_period_id { + if period_id <= previous { + break; + } + } + previous_period_id = Some(period_id); + + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_timestamp: u64 = + env.storage().persistent().get(&time_key).unwrap_or(0); + if delay_secs > 0 && now < deposit_timestamp.saturating_add(delay_secs) { + return (entries, Some(idx)); + } + + let claimable_amount = Self::compute_holder_payout_for_range( + &env, + &offering_id, + &holder, + idx, + idx.saturating_add(1), + ); + entries.push_back(HolderStatementEntry { + period_id, + deposit_timestamp, + claimable_amount, + }); + + processed = processed.saturating_add(1); + idx = idx.saturating_add(1); + } + + let next_cursor = if idx < period_count { Some(idx) } else { None }; + (entries, next_cursor) + } + + /// Shared claim-preview engine used by both full and chunked read-only views. + /// + /// Security assumptions: + /// - Previews must never overstate what `claim` could legally pay at the current ledger state. + /// - Callers may provide stale or adversarial cursors, so we clamp to the holder's current + /// `LastClaimedIdx` before iterating. + /// - The first delayed period forms a hard stop because later periods are not claimable either. + /// + /// Returns `(total, next_cursor)` where `next_cursor` resumes from the first unprocessed index. + fn compute_claimable_preview( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + requested_start_idx: u32, + count: Option, + ) -> (i128, Option) { + let count_key = DataKey::PeriodCount(offering_id.clone()); + let period_count: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); + + let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder.clone()); + let holder_start_idx: u32 = + env.storage().persistent().get(&idx_key).unwrap_or(0); + let actual_start = core::cmp::max(requested_start_idx, holder_start_idx); + + if actual_start >= period_count { + return (0, None); + } + + let effective_cap = count.map(|requested| { + if requested == 0 || requested > MAX_CHUNK_PERIODS { + MAX_CHUNK_PERIODS + } else { + requested + } + }); + + let delay_key = DataKey::ClaimDelaySecs(offering_id.clone()); + let delay_secs: u64 = + env.storage().persistent().get(&delay_key).unwrap_or(0); + let now = env.ledger().timestamp(); + + let mut total: i128 = 0; + let mut processed: u32 = 0; + let mut idx = actual_start; + + let anchor_key = + DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); + let anchor: Option = + env.storage().persistent().get(&anchor_key); + + if let Some(a) = anchor { + if holder_start_idx <= a.end_idx { + total = total.saturating_add(a.claimable_sum); + let anchor_periods = + a.end_idx.saturating_sub(holder_start_idx).saturating_add(1); + processed = processed.saturating_add(anchor_periods); + idx = core::cmp::max(idx, a.end_idx.saturating_add(1)); + env.storage().persistent().remove(&anchor_key); + } + } + + let effective_end = count + .map(|c| core::cmp::min(actual_start + c, period_count)) + .unwrap_or(period_count); + + while idx < effective_end { + if let Some(cap) = effective_cap { + if processed >= cap { + return (total, Some(idx)); + } + } + + let entry_key = DataKey::PeriodEntry(offering_id.clone(), idx); + let period_id: u64 = + env.storage().persistent().get(&entry_key).unwrap_or(0); + if period_id == 0 { + idx = idx.saturating_add(1); + continue; + } + + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_time: u64 = + env.storage().persistent().get(&time_key).unwrap_or(0); + if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { + return (total, Some(idx)); + } + + total = total.saturating_add(Self::compute_holder_payout_for_range( + env, + offering_id, + holder, + idx, + idx.saturating_add(1), + )); + processed = processed.saturating_add(1); + idx = idx.saturating_add(1); + } + + (total, None) + } + + /// Request redemption of a portion of the caller's holder shares. + /// + /// The holder submits a request specifying `shares_bps` to redeem. Only one + /// pending request per holder per offering is allowed. The redemption window + /// must be open (if configured). Blacklisted holders are rejected. + pub fn request_redemption( + env: Env, + holder: Address, + issuer: Address, + namespace: Symbol, + token: Address, + shares_bps: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + holder.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Freeze the holder with SanctionsMatch reason (OR into bitmask). - if !Self::is_event_only(&env) { - 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); - } + // Verify offering exists + Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + // Check redemption window is open + Self::require_redemption_window_open(&env, &offering_id)?; + + // Check holder is not blacklisted + if Self::is_blacklisted( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + holder.clone(), + ) { + return Err(RevoraError::HolderBlacklisted); + } - env.storage() - .persistent() - .set(&fk, &(current_mask | FreezeReason::SanctionsMatch.to_bitmask())); - } + // Check holder has shares to redeem + let current_share = Self::get_holder_share_internal( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + holder.clone(), + ); + if current_share == 0 { + return Err(RevoraError::NoPendingClaims); + } + if shares_bps == 0 || shares_bps > current_share { + return Err(RevoraError::InvalidShareBps); + } - // Emit structured event for audit trail - env.events() - .publish((EVENT_AUTO_FRZ, issuer, namespace, token), (holder, attestation_hash)); - - Ok(()) - } - - pub const MAX_MULTISIG_OWNERS: u32 = 20; - /// Maximum proposal duration: 365 days in seconds. - pub const MAX_PROPOSAL_DURATION: u64 = 365 * 24 * 60 * 60; - - /// Initialize the multisig admin system. May only be called once. - /// Only the caller (deployer/admin) needs to authorize; owners are registered - /// without requiring their individual signatures at init time. - /// - /// # Soroban Limitation Note - /// Soroban does not support requiring multiple signers in a single transaction - /// invocation. Each owner must separately call `approve_action` to sign proposals. - /// - /// # Validation Rules - /// - `owners` must not be empty and must contain ≤ 20 unique addresses - /// - `threshold` must be in range [1, owners.len()] - /// - `proposal_duration` must be in range [1, 31,536,000] seconds (365 days) - /// - /// # Errors - /// - `NotAuthorized`: Caller is not the admin - /// - `NotInitialized`: Admin not set (contract not initialized) - /// - `LimitReached`: Already initialized, empty owners, too many owners, invalid threshold, or duplicate owners - /// - `InvalidAmount`: Duration is zero or exceeds maximum - /// - /// # Events - /// Emits `ms_init` with `(caller, (owners_count, threshold))` on success. - pub fn init_multisig( - env: Env, - caller: Address, - owners: Vec
, - threshold: u32, - proposal_duration: u64, - quorum_bps: u32, - ) -> Result<(), RevoraError> { - caller.require_auth(); - - // Must be the initialized admin - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if caller != admin { - return Err(RevoraError::NotAuthorized); - } + // Check no pending request already exists + let request_key = + DataKey2::RedemptionRequest(offering_id.clone(), holder.clone()); + if env.storage().persistent().has(&request_key) { + return Err(RevoraError::LimitReached); + } - if env.storage().persistent().has(&DataKey2::MultisigThreshold) { - return Err(RevoraError::LimitReached); // Already initialized - } - if owners.is_empty() { - return Err(RevoraError::LimitReached); // Must have at least one owner - } - if owners.len() > Self::MAX_MULTISIG_OWNERS { - return Err(RevoraError::LimitReached); - } - if threshold == 0 || threshold > owners.len() { - return Err(RevoraError::LimitReached); // Improper threshold - } - if proposal_duration == 0 { - return Err(RevoraError::InvalidAmount); - } - if quorum_bps == 0 || quorum_bps > 10_000 { - return Err(RevoraError::InvalidShareBps); - } + // Store pending request + let pending = PendingRedemption { + shares_bps, + timestamp: env.ledger().timestamp(), + }; + env.storage().persistent().set(&request_key, &pending); - // Check for duplicate owners - for i in 0..owners.len() { - let owner_i = owners.get(i).unwrap(); - for j in (i + 1)..owners.len() { - if owner_i == owners.get(j).unwrap() { - return Err(RevoraError::LimitReached); - } - } - } + // Emit event + env.events().publish( + (EVENT_REDEMPTION_REQUESTED, issuer, namespace, token), + (holder, shares_bps), + ); + Ok(()) + } + + /// Fulfill a pending redemption request. + /// + /// The issuer transfers `amount` of the offering's locked payment token from + /// the contract to the holder and reduces the holder's share by the requested + /// `shares_bps`. The redemption window must be open. Blacklisted holders are + /// rejected even if they had a pending request. + pub fn fulfill_redemption( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + amount: i128, + ) -> Result { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Validate proposal duration - if proposal_duration == 0 || proposal_duration > Self::MAX_PROPOSAL_DURATION { - return Err(RevoraError::InvalidAmount); - } + // Verify caller is the current issuer + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + // Check redemption window is open + Self::require_redemption_window_open(&env, &offering_id)?; + + // Reject blacklisted holders even if they had a pending request + if Self::is_blacklisted( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + holder.clone(), + ) { + return Err(RevoraError::HolderBlacklisted); + } + + // Validate amount + if amount <= 0 { + return Err(RevoraError::InvalidAmount); + } + + // Read pending request + let request_key = + DataKey2::RedemptionRequest(offering_id.clone(), holder.clone()); + let pending: PendingRedemption = env + .storage() + .persistent() + .get(&request_key) + .ok_or(RevoraError::NoTransferPending)?; + + // Read holder's current share + let current_share = Self::get_holder_share_internal( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + holder.clone(), + ); + if current_share == 0 { + return Err(RevoraError::NoPendingClaims); + } + + // Compute effective redeem bps (capped to what holder actually has) + let redeem_bps = core::cmp::min(pending.shares_bps, current_share); + let new_share = current_share - redeem_bps; + + // Transfer amount (minus redemption fee if configured) from contract to holder + let payment_token = + Self::get_locked_payment_token_for_offering(&env, &offering_id) + .ok_or(RevoraError::PaymentTokenMismatch)?; + let contract_addr = env.current_contract_address(); + + let fee_config = Self::get_redemption_fee_config( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ); + let (net_amount, fee_amount, treasury_addr) = if let Some(cfg) = fee_config + { + if cfg.fee_bps > 0 { + let fee = + amount.checked_mul(cfg.fee_bps as i128).unwrap_or(0) / 10_000i128; + let net = amount.saturating_sub(fee); + (net, fee, Some(cfg.treasury)) + } else { + (amount, 0i128, None) + } + } else { + (amount, 0i128, None) + }; - env.storage().persistent().set(&DataKey2::MultisigThreshold, &threshold); - env.storage().persistent().set(&DataKey2::MultisigOwners, &owners.clone()); - env.storage().persistent().set(&DataKey2::MultisigProposalCount, &0_u32); - env.storage().persistent().set(&DataKey2::MultisigProposalDuration, &proposal_duration); - env.events().publish((EVENT_MULTISIG_INIT, caller.clone()), (owners.len(), threshold)); - Ok(()) - } - - /// Create a governance proposal bound to an offering and an issuer-authenticated metadata hash. - /// - /// The proposal id is deterministic per offering and increments from a per-offering counter. - /// The entrypoint is issuer-authenticated so the on-chain record is bound to the issuer's - /// signed transaction and can be audited off-chain. - pub fn create_proposal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - meta_hash: BytesN<32>, - quorum_bps: u32, - voting_window: u64, - ) -> Result { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); + let token_client = token::Client::new(&env, &payment_token); + if net_amount > 0 + && token_client + .try_transfer(&contract_addr, &holder, &net_amount) + .is_err() + { + return Err(RevoraError::TransferFailed); + } - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - if quorum_bps == 0 || quorum_bps > 10_000 { - return Err(RevoraError::InvalidAmount); - } - if voting_window == 0 { - return Err(RevoraError::InvalidAmount); - } - if env - .storage() - .persistent() - .has(&DataKey2::GovernanceProposalMeta(offering_id.clone(), meta_hash.clone())) + if fee_amount > 0 { + if let Some(treasury) = treasury_addr { + if token_client + .try_transfer(&contract_addr, &treasury, &fee_amount) + .is_err() { - return Err(RevoraError::LimitReached); + return Err(RevoraError::TransferFailed); } - - let count_key = DataKey2::GovernanceProposalCount(offering_id.clone()); - let proposal_id: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - let ends_at = env - .ledger() - .timestamp() - .checked_add(voting_window) - .ok_or(RevoraError::InvalidAmount)?; - let proposal = GovernanceProposal { - id: proposal_id, - meta_hash: meta_hash.clone(), - quorum_bps, - ends_at, - }; - - env.storage() - .persistent() - .set(&DataKey2::GovernanceProposal(offering_id.clone(), proposal_id), &proposal); - env.storage().persistent().set(&count_key, &(proposal_id + 1)); - env.storage() - .persistent() - .set(&DataKey2::GovernanceProposalMeta(offering_id.clone(), meta_hash.clone()), &true); env.events().publish( - (EVENT_PROPOSAL_CREATED_GOV, issuer.clone(), namespace.clone(), token.clone()), - (proposal_id, meta_hash, quorum_bps, ends_at), + ( + EVENT_REDEMPTION_FEE, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (holder.clone(), treasury, fee_amount, net_amount), ); - Ok(proposal_id) - } - - /// Return a previously created governance proposal for an offering. - pub fn get_proposal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - proposal_id: u32, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::GovernanceProposal(offering_id, proposal_id)) - } - - /// Propose a sensitive administrative action. - /// The proposer's address is automatically counted as the first approval. - pub fn propose_action( - env: Env, - proposer: Address, - action: ProposalAction, - ) -> Result { - proposer.require_auth(); - Self::require_multisig_owner(&env, &proposer)?; - - let count_key = DataKey2::MultisigProposalCount; - let id: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let duration: u64 = env - .storage() - .persistent() - .get(&DataKey2::MultisigProposalDuration) - .ok_or(RevoraError::NotInitialized)?; - let current_epoch: u64 = - env.storage().persistent().get(&DataKey2::MultisigEpoch).unwrap_or(0); - let now = env.ledger().timestamp(); - let expiry = now.checked_add(duration).ok_or(RevoraError::InvalidAmount)?; - - // Proposer's vote counts as the first approval automatically - let mut initial_approvals = Vec::new(&env); - initial_approvals.push_back(proposer.clone()); - - let quorum_bps: u32 = - env.storage().persistent().get(&DataKey2::MultisigQuorumBps).unwrap_or(5100); - - let proposal = Proposal { - id, - action, - proposer: proposer.clone(), - approvals: initial_approvals, - executed: false, - expiry, - }; - - env.storage().persistent().set(&DataKey2::MultisigProposal(id), &proposal); - env.storage().persistent().set(&count_key, &(id + 1)); + } + } + + // Reduce holder's share by the redeemed bps + Self::set_holder_share_internal( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + holder.clone(), + new_share, + None, + None, + )?; + + // Remove the pending request + env.storage().persistent().remove(&request_key); + + // Emit fulfillment event + env.events().publish( + (EVENT_REDEMPTION_FULFILLED, issuer, namespace, token), + (holder, redeem_bps, amount), + ); + Ok(amount) + } + + /// Set the redemption fee configuration for an offering. + /// + /// Auth: Issuer only. Must be current issuer of a registered offering. + /// Fee must not exceed `MAX_REDEMPTION_FEE_BPS` (5 000 BPS / 50%). + #[allow(clippy::too_many_arguments)] + pub fn set_redemption_fee_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + fee_bps: u32, + treasury: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - env.events().publish((EVENT_PROPOSAL_CREATED, proposer.clone()), (id, expiry)); - env.events().publish((EVENT_PROPOSAL_APPROVED, proposer), id); - Ok(id) + // Verify offering exists and caller is current issuer + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); } - /// Approve an existing multisig proposal. - pub fn approve_action( - env: Env, - approver: Address, - proposal_id: u32, - ) -> Result<(), RevoraError> { - approver.require_auth(); - Self::require_multisig_owner(&env, &approver)?; - - let key = DataKey2::MultisigProposal(proposal_id); - let mut proposal: Proposal = - env.storage().persistent().get(&key).ok_or(RevoraError::OfferingNotFound)?; - - if proposal.executed { - return Err(RevoraError::LimitReached); - } - - if env.ledger().timestamp() >= proposal.expiry { - return Err(RevoraError::ProposalExpired); - } - - // Check for duplicate approvals - for i in 0..proposal.approvals.len() { - if proposal.approvals.get(i).unwrap() == approver { - return Err(RevoraError::AlreadyApproved); - } - } - - proposal.approvals.push_back(approver.clone()); - env.events().publish((EVENT_PROPOSAL_APPROVED, approver.clone()), proposal_id); - - let _threshold: u32 = env - .storage() - .persistent() - .get(&DataKey2::MultisigThreshold) - .ok_or(RevoraError::NotInitialized)?; - - env.storage().persistent().set(&key, &proposal); - Ok(()) + // Validate fee_bps cap + if fee_bps > MAX_REDEMPTION_FEE_BPS { + return Err(RevoraError::InvalidRevenueShareBps); } - /// Execute a multisig proposal once the approval threshold is reached. - pub fn execute_action( - env: Env, - executor: Address, - proposal_id: u32, - ) -> Result<(), RevoraError> { - executor.require_auth(); - Self::require_multisig_owner(&env, &executor)?; - - let key = DataKey2::MultisigProposal(proposal_id); - let mut proposal: Proposal = - env.storage().persistent().get(&key).ok_or(RevoraError::OfferingNotFound)?; - - if proposal.executed { - return Err(RevoraError::LimitReached); - } - - if env.ledger().timestamp() >= proposal.expiry { - return Err(RevoraError::ProposalExpired); - } + let config = RedemptionFeeConfig { + fee_bps, + treasury: treasury.clone(), + }; + let key = DataKey2::RedemptionFeeConfig(offering_id); + env.storage().persistent().set(&key, &config); - let current_epoch: u64 = - env.storage().persistent().get(&DataKey2::MultisigEpoch).unwrap_or(0); - if proposal.epoch != current_epoch { - env.events().publish( - (EVENT_STALE_PROPOSAL_REJECT, executor.clone()), - (proposal_id, proposal.epoch, current_epoch), - ); - return Err(RevoraError::StaleProposal); - } + env.events().publish( + (EVENT_REDEMPTION_FEE_SET, issuer, namespace, token), + (fee_bps, treasury), + ); + Ok(()) + } + + /// Return the stored redemption fee configuration for an offering. + pub fn get_redemption_fee_config( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::RedemptionFeeConfig(offering_id); + env.storage().persistent().get(&key) + } + + /// Return the configured redemption fee BPS for an offering (0 if unset). + pub fn get_redemption_fee_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + Self::get_redemption_fee_config(env, issuer, namespace, token) + .map(|cfg| cfg.fee_bps) + .unwrap_or(0) + } + + /// Configure the lockup schedule for an offering. + /// + /// Auth: Issuer only. Must be current issuer of a registered offering. + pub fn set_lockup_schedule( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + schedule: LockupSchedule, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let threshold: u32 = env - .storage() - .persistent() - .get(&DataKey2::MultisigThreshold) - .ok_or(RevoraError::NotInitialized)?; - if proposal.approvals.len() < threshold { - return Err(RevoraError::NotAuthorized); - } + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } - // Quorum check: summed voter weight must meet or exceed quorum_bps - if !Self::check_quorum_inner(&env, &proposal) { - return Err(RevoraError::NotAuthorized); - } + schedule.validate()?; - proposal.executed = true; - env.storage().persistent().set(&key, &proposal); + let key = DataKey2::LockupSchedule(offering_id); + env.storage().persistent().set(&key, &schedule); - match proposal.action.clone() { - ProposalAction::SetAdmin(new_admin) => { - env.storage().persistent().set(&DataKey::Admin, &new_admin); - } - ProposalAction::Freeze => { - env.storage().persistent().set(&DataKey::Frozen, &true); - Self::emit_v2_event(&env, (EVENT_FREEZE_V2, proposal.proposer.clone()), true); - } - ProposalAction::SetThreshold(new_threshold) => { - let owners: Vec
= - env.storage().persistent().get(&DataKey2::MultisigOwners).unwrap(); - if new_threshold == 0 || new_threshold > owners.len() { - return Err(RevoraError::InvalidShareBps); - } - env.storage().persistent().set(&DataKey2::MultisigThreshold, &new_threshold); - let next_epoch = current_epoch + 1; - env.storage().persistent().set(&DataKey2::MultisigEpoch, &next_epoch); - } - ProposalAction::AddOwner(new_owner) => { - let mut owners: Vec
= - env.storage().persistent().get(&DataKey2::MultisigOwners).unwrap(); - if owners.len() >= Self::MAX_MULTISIG_OWNERS { - return Err(RevoraError::LimitReached); - } - if owners.contains(&new_owner) { - return Err(RevoraError::LimitReached); - } - owners.push_back(new_owner); - env.storage().persistent().set(&DataKey2::MultisigOwners, &owners); - let next_epoch = current_epoch + 1; - env.storage().persistent().set(&DataKey2::MultisigEpoch, &next_epoch); - } - ProposalAction::RemoveOwner(old_owner) => { - let owners: Vec
= - env.storage().persistent().get(&DataKey2::MultisigOwners).unwrap(); - if !owners.contains(&old_owner) { - return Err(RevoraError::NotAuthorized); - } - // Threshold invariant: remaining owners must still satisfy threshold. - if (owners.len() - 1) < threshold { - return Err(RevoraError::LimitReached); - } + env + .events() + .publish((EVENT_LOCKUP_SET, issuer, namespace, token), schedule); + Ok(()) + } + + /// Return the stored lockup schedule for an offering. + pub fn get_lockup_schedule( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey2::LockupSchedule(offering_id); + env.storage().persistent().get(&key) + } + + /// Return the unlocked BPS (0 to 10 000) for an offering at the current ledger timestamp. + /// + /// Returns 10 000 (100% unlocked) if no lockup schedule is configured. + pub fn get_unlocked_bps( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let schedule = + Self::get_lockup_schedule(env.clone(), issuer, namespace, token); + let now = env.ledger().timestamp(); + schedule + .map(|s| s.calculate_unlocked_bps(now)) + .unwrap_or(10_000) + } + + /// Extend the taper end timestamp of a lockup schedule with holder consent. + /// + /// Insider lockups sometimes need to be extended for regulatory or contractual + /// reasons. This entrypoint pushes `taper_end_ts` forward; it may **never** + /// shorten an existing lockup. The holder must sign an off-chain attestation + /// consenting to the extension. + /// + /// # Parameters + /// + /// * `issuer` — the offering issuer (must authorise). + /// * `namespace` / `token` — identify the offering. + /// * `holder` — the locked holder who consents to the extension. + /// * `new_taper_end_ts` — new taper end timestamp (must be > current `taper_end_ts`). + /// * `attestation` — a [`SignedAttestation`] committing to the extension parameters. + /// + /// # Returns + /// + /// * `Ok(())` — the extension was applied and persisted. + /// * `Err(RevoraError::InvalidAmount)` — `new_taper_end_ts <= current taper_end_ts` + /// or no lockup schedule exists for this offering. + /// * `Err(RevoraError::NetworkIdMismatch)` — the attestation was produced for a + /// different network. + /// + /// # Events + /// + /// Emits [`EVENT_LOCKUP_EXTEND`] with `(offering_id, new_taper_end_ts, attestation.digest)`. + #[allow(clippy::too_many_arguments)] + pub fn extend_lockup( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + new_taper_end_ts: u64, + attestation: SignedAttestation, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let mut new_owners = Vec::new(&env); - for i in 0..owners.len() { - let owner = owners.get(i).unwrap(); - if owner != old_owner { - new_owners.push_back(owner); - } - } - env.storage().persistent().set(&DataKey2::MultisigOwners, &new_owners); - let next_epoch = current_epoch + 1; - env.storage().persistent().set(&DataKey2::MultisigEpoch, &next_epoch); - } - ProposalAction::SetProposalDuration(new_duration) => { - if new_duration == 0 { - return Err(RevoraError::InvalidAmount); - } - env.storage().persistent().set(&DataKey2::MultisigProposalDuration, &new_duration); - env.events().publish((EVENT_DURATION_SET, proposal.proposer.clone()), new_duration); - } - } + // ── Verify attestation ─────────────────────────────────────────────── + Self::verify_attestation_digest( + env.clone(), + attestation.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + holder.clone(), + holder.clone(), + 0, + )?; + + // ── Fetch and validate existing schedule ───────────────────────────── + let key = DataKey2::LockupSchedule(offering_id.clone()); + let existing: LockupSchedule = env + .storage() + .persistent() + .get(&key) + .ok_or(RevoraError::InvalidAmount)?; + + // ── Monotonic extension check ─────────────────────────────────────── + // Match on the existing variant and apply the extension. + let updated = match existing { + LockupSchedule::CliffTaper { + cliff_ts, + cliff_bps, + taper_end_ts, + } => { + if new_taper_end_ts <= taper_end_ts { + return Err(RevoraError::InvalidAmount); + } + LockupSchedule::CliffTaper { + cliff_ts, + cliff_bps, + taper_end_ts: new_taper_end_ts, + } + } + }; - env.events().publish((EVENT_PROPOSAL_EXECUTED, executor), proposal_id); - Ok(()) - } + env.storage().persistent().set(&key, &updated); - /// Check whether a proposal's total voted weight meets or exceeds its configured quorum. - /// - /// Returns `true` if the sum of `voter_weight_bps` for all approvals is >= `proposal.quorum_bps`. - /// Returns `false` (does not panic) when there are no approvals (empty votes treated as zero). - /// The proposal must exist; if not found, this will panic. - pub fn check_quorum_inner(env: &Env, proposal: &Proposal) -> bool { - if proposal.approvals.is_empty() { - return false; - } - let mut total_voted_bps: u32 = 0; - for i in 0..proposal.approvals.len() { - let voter = proposal.approvals.get(i).unwrap(); - let weight: u32 = - env.storage().persistent().get(&DataKey2::VoterWeight(voter)).unwrap_or(0); - total_voted_bps = total_voted_bps.saturating_add(weight); - } - total_voted_bps >= proposal.quorum_bps - } - - /// Read a proposal by id (internal helper). - pub fn get_proposal_inner(env: &Env, proposal_id: u32) -> Option { - env.storage().persistent().get(&DataKey2::MultisigProposal(proposal_id)) - } - - /// Return the list of registered multisig owners. - pub fn get_multisig_owners(env: &Env) -> Option> { - env.storage().persistent().get(&DataKey2::MultisigOwners) - } - - /// Return the current multisig approval threshold. - pub fn get_multisig_threshold(env: &Env) -> Option { - env.storage().persistent().get(&DataKey2::MultisigThreshold) - } - - // ── Testnet faucet ──────────────────────────────────────────────────────── - - /// Allocate `count` deterministic holder seed slots for an offering. - /// - /// Each seed is derived as `sha256(issuer_xdr || namespace_xdr || token_xdr || idx_xdr)` - /// and can be treated as a raw 32-byte ed25519 public key by external test suites. - /// The equal BPS split (`10_000 / count`, remainder to last slot) is documented in - /// each emitted `fct_seed` event so test suites can pin share expectations. - /// - /// ### Metrics - /// Every successful call accumulates towards the hourly `fct_mtr1` summary event: - /// - `total_dispensed` incremented by `count`. - /// - `unique_addresses` incremented once per distinct requester per window. - /// - `fct_mtr1` is emitted **at the end of the call that first crosses a window - /// boundary** (i.e. when `window_id > last_emitted_window_id`). At most one - /// `fct_mtr1` event is emitted per window — subsequent calls in the same window - /// only update counters; they do **not** re-emit the event. - /// - /// ### Security - /// Panics (via `RevoraError::TestnetOnly`) when `testnet_mode == false`. - /// Must never be callable on mainnet. - /// - /// ### Parameters - /// - `issuer` / `namespace` / `token`: offering identity. - /// - `count`: number of deterministic seed slots to generate (0 returns empty vec). - /// - /// ### Returns - /// `Vec>` of per-slot seeds in index order. - pub fn faucet_seed_holders( - env: Env, - requester: Address, - issuer: Address, - namespace: Symbol, - token: Address, - count: u32, - ) -> Result>, RevoraError> { - if !Self::is_testnet_mode(env.clone()) { - return Err(RevoraError::TestnetOnly); - } + env.events().publish( + (EVENT_LOCKUP_EXTEND, issuer, namespace, token), + (holder, new_taper_end_ts, attestation.digest), + ); - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + Ok(()) + } + + /// Preview the total claimable amount for a holder without mutating state. + /// + /// This method respects the same blacklist, claim-window, and claim-delay gates that can block + /// `claim`, then sums only periods currently eligible for payout. + pub fn get_claimable( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> i128 { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + if Self::is_blacklisted( + env.clone(), + issuer, + namespace, + token, + holder.clone(), + ) { + return 0; + } + if Self::require_claim_window_open(&env, &offering_id).is_err() { + return 0; + } + + let (total, _) = + Self::compute_claimable_preview(&env, &offering_id, &holder, 0, None); + total + } + + /// Read-only: compute claimable amount for a holder over a bounded index window. + /// + /// This function allows indexers, frontends, and reviewers to page through a holder's + /// currently claimable revenue without mutating contract state. It is the chunked companion + /// to `get_claimable`. + /// + /// # Arguments + /// + /// * `issuer` - The offering issuer address + /// * `namespace` - The offering namespace identifier + /// * `token` - The offering token address + /// * `holder` - The holder address to compute claimable amount for + /// * `start_idx` - The starting period index (cursor) for the chunk query + /// * `count` - The maximum number of periods to include in this chunk + /// + /// # Returns + /// + /// Returns `(total, next_cursor)` where: + /// - `total` is the sum of claimable amounts for the processed periods + /// - `next_cursor` is `Some(next_index)` if more eligible periods exist after the processed window, + /// or `None` if all eligible periods have been processed + /// + /// # Behavior + /// + /// - Caller-provided cursors (`start_idx`) are clamped to the holder's stored `LastClaimedIdx` + /// - The first delayed period stops iteration and becomes the returned `next_cursor` + /// - A blacklisted holder receives `0` from this function + /// - A closed claim window also yields `0` from this function + /// - Chunk size `0` or any size above `MAX_CHUNK_PERIODS` (200) is normalized to `MAX_CHUNK_PERIODS` + /// - Holders with zero share receive `0` claimable amount + /// + /// # Security Guarantees + /// + /// This implementation is intentionally conservative: previews never advertise more value + /// than the holder could actually claim at the current ledger state. + /// + /// # Cursor Idempotency + /// + /// Repeated queries with the same cursor yield identical results, ensuring reliable pagination. + /// + /// # Chunk Summation Parity + /// + /// Summing chunked claimable amounts equals the full claimable amount obtainable via `get_claimable`. + pub fn get_claimable_chunk( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + start_idx: u32, + count: u32, + ) -> (i128, Option) { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + if Self::is_blacklisted( + env.clone(), + issuer, + namespace, + token, + holder.clone(), + ) { + return (0, None); + } + if Self::require_claim_window_open(&env, &offering_id).is_err() { + return (0, None); + } + + Self::compute_claimable_preview( + &env, + &offering_id, + &holder, + start_idx, + Some(count), + ) + } + + /// Per-holder accrual snapshot: returns the total unclaimed dividend balance + /// across all periods by walking the accrual ledger since the holder's last + /// claim checkpoint. + /// + /// This is a pure read-only query and does not mutate contract state. + /// The computation is bounded per-holder; it walks only the holder's share + /// schedule entries and accrual-index snapshots between `last_settled_idx` + /// and the current matured end, without iterating every period individually + /// through a per-period loop. + /// + /// # Arguments + /// + /// * `issuer` — The offering issuer address. + /// * `namespace` — The offering namespace identifier. + /// * `token` — The offering token address. + /// * `holder` — The holder address to compute accrued unclaimed for. + /// + /// # Returns + /// + /// Returns `i128` — the total amount currently accrued but unclaimed for this + /// holder. Returns `0` for blacklisted holders or holders with no unclaimed + /// periods. + /// + /// # Gas + /// + /// Gas scales with the number of share-schedule entries (share changes) and + /// the number of periods since the last settlement. Both are naturally bounded + /// by checkpoint compression (default threshold: 1 000 schedule entries) and + /// the offering's total period count. + pub fn get_holder_accrued_unclaimed( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> i128 { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - if !env.storage().persistent().has(&DataKey2::OfferingRecord(offering_id.clone())) { - return Err(RevoraError::OfferingNotFound); - } + // Fast-path: blacklisted holders always return 0. + if Self::is_blacklisted( + env.clone(), + issuer, + namespace, + token, + holder.clone(), + ) { + return 0; + } + + // Get the holder's current accrual state. This includes the frozen + // `accrued_owed` from previous share-change settlements and correctly + // advances `last_settled_idx` past any `last_claimed_idx` advances. + let state = Self::get_holder_accrual_state(&env, &offering_id, &holder); + + // Find how many periods have matured beyond their claim delay (if any). + let matured_end = Self::find_matured_claim_end_idx( + &env, + &offering_id, + state.last_settled_idx, + ); + + // Start total from already-frozen accrued_owed. + let mut total = state.accrued_owed; + + // Compute additional accrual for periods since the last settlement. + if matured_end > state.last_settled_idx { + total = total.saturating_add(Self::compute_holder_payout_for_range( + &env, + &offering_id, + &holder, + state.last_settled_idx, + matured_end, + )); + } + + // Handle accrual anchor: compressed share-schedule entries that were + // folded into a pre-computed sum during checkpoint compression. The + // anchor must be included if it covers periods after the last claim, + // even though those entries have been pruned from the live schedule. + let anchor_key = + DataKey2::AccrualAnchor(offering_id.clone(), holder.clone()); + if let Some(anchor) = env + .storage() + .persistent() + .get::<_, AccrualAnchor>(&anchor_key) + { + let last_claimed_idx: u32 = env + .storage() + .persistent() + .get(&DataKey::LastClaimedIdx( + offering_id.clone(), + holder.clone(), + )) + .unwrap_or(0); + if last_claimed_idx <= anchor.end_idx { + total = total.saturating_add(anchor.claimable_sum); + } + } + + // Emit debug tracing event. Even though this is a read-only query, + // the event is visible in simulation responses, giving integrators + // full visibility into the computation breakdown. + env.events().publish( + ( + EVENT_ACC_SNAP, + offering_id.issuer, + offering_id.namespace, + offering_id.token, + ), + ( + holder.clone(), + state.last_settled_idx, + matured_end, + state.accrued_owed, + total, + ), + ); + + total + } + + // ── Fiscal year configuration ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + /// Set the fiscal year start month for an offering. + /// + /// `month` must be 1 (January) through 12 (December). The default is 1. + /// This determines how `get_holder_tax_year` maps payout timestamps to + /// fiscal years. + /// + /// # Access + /// Issuer-only (requires `require_issuer_quorum_auth`). + pub fn set_fiscal_year_start( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + month: u32, + ) -> Result<(), RevoraError> { + if month < 1 || month > 12 { + return Err(RevoraError::InvalidAmount); + } + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .set(&DataKey2::FiscalYearStartMonth(offering_id), &month); + Ok(()) + } + + /// Read the fiscal year start month for an offering. + /// Returns 1 (January) if not configured. + pub fn get_fiscal_year_start( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::<_, u32>(&DataKey2::FiscalYearStartMonth(offering_id)) + .unwrap_or(crate::tax_bucket::DEFAULT_FISCAL_START_MONTH) + } + + /// Return the per-holder tax-year summary for a given fiscal year. + /// + /// The summary is accumulated on every `claim` and stored per offering, + /// per holder, per fiscal year. Returns a `TaxYearSummary` with + /// `return_of_capital` and `capital_gains` totals for the requested year. + /// + /// The fiscal year is determined by the offering's configured fiscal year + /// start month (see `set_fiscal_year_start`). If the start month is + /// April (4), then fiscal year 2024 covers Apr 2024 – Mar 2025. + /// + /// # Returns + /// A `TaxYearSummary` — never fails; returns zero-filled record for holders + /// with no activity in the given year. + pub fn get_holder_tax_year( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + year: u64, + ) -> crate::tax_bucket::TaxYearSummary { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::TaxYearEntry(offering_id, holder, year)) + .unwrap_or(crate::tax_bucket::TaxYearSummary { + ordinary_income: 0, + capital_gains: 0, + return_of_capital: 0, + }) + } + + // ── Time-delayed claim configuration (#27) ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + /// Set the claim delay for an offering in seconds. + fn set_claim_delay_full( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + delay_secs: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + // Verify offering exists and issuer is current + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + Self::require_not_frozen(&env)?; + issuer.require_auth(); + let key = DataKey::ClaimDelaySecs(offering_id); + env.storage().persistent().set(&key, &delay_secs); + env.events().publish( + (EVENT_CLAIM_DELAY_SET, issuer, namespace, token), + delay_secs, + ); + Ok(()) + } + + /// Get per-offering claim delay in seconds. 0 = immediate claim. + fn get_claim_delay_internal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u64 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let key = DataKey::ClaimDelaySecs(offering_id); + env.storage().persistent().get(&key).unwrap_or(0) + } + + /// Return the total number of deposited periods for an offering. + pub fn get_period_count( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let count_key = DataKey::PeriodCount(offering_id); + env.storage().persistent().get(&count_key).unwrap_or(0) + } +} - let now = env.ledger().timestamp(); - let last_request_ts: Option = - env.storage().persistent().get(&DataKey2::FaucetLastRequest(requester.clone())); - if let Some(last_ts) = last_request_ts { - if now.saturating_sub(last_ts) < DEFAULT_FAUCET_COOLDOWN_SECONDS { - env.events().publish( - ( - EVENT_FAUCET_COOLDOWN_REJECT, - requester.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - ), - (last_ts, now, DEFAULT_FAUCET_COOLDOWN_SECONDS), +// ── Test-only helpers (not part of the contract ABI) ───────────────────────── +impl RevoraRevenueShare { + /// Test helper: insert a period entry and revenue without transferring tokens. + /// Only compiled in test builds to avoid affecting production contract. + #[cfg(test)] + pub fn test_insert_period( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + amount: i128, + ) { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + // Append to indexed period list + let count_key = DataKey::PeriodCount(offering_id.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + let entry_key = DataKey::PeriodEntry(offering_id.clone(), count); + env.storage().persistent().set(&entry_key, &period_id); + env.storage().persistent().set(&count_key, &(count + 1)); + + // Store period revenue and deposit time + let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); + env.storage().persistent().set(&rev_key, &amount); + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_time = env.ledger().timestamp(); + env.storage().persistent().set(&time_key, &deposit_time); + + let normalized = Self::normalize_amount(amount, STELLAR_CANONICAL_DECIMALS); + let acc_delta_e18 = Self::accrual_delta_e18(normalized); + let global_acc_key = DataKey2::GlobalAccPerShareE18(offering_id.clone()); + let current_acc: i128 = + env.storage().persistent().get(&global_acc_key).unwrap_or(0); + let next_acc = current_acc.saturating_add(acc_delta_e18); + env.storage().persistent().set(&global_acc_key, &next_acc); + env.storage().persistent().set( + &DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), + &next_acc, + ); + + // Update cumulative deposited revenue + let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); + let deposited: i128 = + env.storage().persistent().get(&deposited_key).unwrap_or(0); + let new_deposited = deposited.s_add(amount).unwrap_or(i128::MAX); + env + .storage() + .persistent() + .set(&deposited_key, &new_deposited); + } + + /// Test helper: set a holder's claim cursor without performing token transfers. + #[cfg(test)] + pub fn test_set_last_claimed_idx( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + last_claimed_idx: u32, + ) { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let idx_key = DataKey::LastClaimedIdx(offering_id, holder); + env.storage().persistent().set(&idx_key, &last_claimed_idx); + } + // ── On-chain distribution simulation (#29) ──────────────────── + + /// Read-only: simulate distribution for sample inputs without mutating state. + /// Returns expected payouts per holder and total. Uses offering's rounding mode. + /// For integrators to preview outcomes before executing deposit/claim flows. + pub fn simulate_distribution( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + amount: i128, + holder_shares: Vec<(Address, u32)>, + ) -> SimulateDistributionResult { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let classes_key = DataKey2::OfferingClasses(offering_id.clone()); + let classes: Option> = + env.storage().persistent().get(&classes_key); + let mode = + Self::get_rounding_mode(env.clone(), issuer, namespace, token.clone()); + + let n = holder_shares.len(); + + // Extract parallel vecs for the sort helper. + let mut addr_vec = Vec::
::new(&env); + let mut bps_vec = Vec::::new(&env); + for i in 0..n { + let (h, b) = holder_shares.get(i).unwrap(); + addr_vec.push_back(h); + bps_vec.push_back(b); + } + + let sorted_idx = Self::sort_holder_indices(&env, &bps_vec, &addr_vec, n); + + let mut total: i128 = 0; + let mut payouts = Vec::new(&env); + for k in 0..n { + let idx = sorted_idx.get(k).unwrap(); + let holder = addr_vec.get(idx).unwrap(); + let share_bps = bps_vec.get(idx).unwrap(); + let payout = if share_bps > 10_000 { + 0_i128 + } else { + if classes.is_some() { + let mut p = 0_i128; + if let Some(ref cls_vec) = classes { + for (sc, config) in cls_vec.iter() { + let holder_share = env + .storage() + .persistent() + .get(&DataKey2::HolderShareClass( + offering_id.clone(), + holder.clone(), + sc.clone(), + )) + .unwrap_or(0); + if holder_share > 0 { + let class_rev = + Self::compute_share(env.clone(), amount, config.bps, mode); + let holder_payout = Self::compute_share( + env.clone(), + class_rev, + holder_share, + mode, ); - - // ── Metrics: count the cooldown reject ──────────────────────── - let rejects: u32 = env - .storage() - .persistent() - .get(&DataKey2::FaucetMetricsCooldownRejects) - .unwrap_or(0u32); - env.storage() - .persistent() - .set(&DataKey2::FaucetMetricsCooldownRejects, &rejects.saturating_add(1)); - - return Err(RevoraError::FaucetCooldownActive); + p = p.saturating_add(holder_payout); + } } + } + p + } else { + Self::compute_share(env.clone(), amount, share_bps, mode) } + }; + total = total.saturating_add(payout); + payouts.push_back((holder.clone(), payout)); + } + SimulateDistributionResult { + total_distributed: total, + payouts, + } + } - env.storage().persistent().set(&DataKey2::FaucetLastRequest(requester), &now); - - if count == 0 { - return Ok(Vec::new(&env)); - } + // ── Issuer two-step transfer (#258) ────────────────────────── - // ── Metrics: count unique addresses ─────────────────────────────────── - let current_window_id = now / FAUCET_METRICS_WINDOW_SECS; - let addr_seen_key = DataKey2::FaucetMetricsAddrSeen(current_window_id, requester.clone()); - if !env.storage().persistent().has(&addr_seen_key) { - env.storage().persistent().set(&addr_seen_key, &true); - let unique: u32 = - env.storage().persistent().get(&DataKey2::FaucetMetricsUniqueAddrs).unwrap_or(0u32); - env.storage() - .persistent() - .set(&DataKey2::FaucetMetricsUniqueAddrs, &unique.saturating_add(1)); - } + // ── Upgradeability guard and freeze (#32) ─────────────────── - // ── Metrics: accumulate dispensed count ─────────────────────────────── - let dispensed: u32 = - env.storage().persistent().get(&DataKey2::FaucetMetricsTotalDispensed).unwrap_or(0u32); - env.storage() - .persistent() - .set(&DataKey2::FaucetMetricsTotalDispensed, &dispensed.saturating_add(count)); + /// Set the admin address. May only be called once; caller must authorize as the new admin. + /// If multisig is initialized, this function is disabled in favor of execute_action(SetAdmin). + pub fn set_admin(env: Env, admin: Address) -> Result<(), RevoraError> { + if env.storage().persistent().has(&DataKey2::MultisigThreshold) { + return Err(RevoraError::LimitReached); + } + admin.require_auth(); + let key = DataKey::Admin; + if env.storage().persistent().has(&key) { + return Err(RevoraError::LimitReached); + } + env.storage().persistent().set(&key, &admin); + Self::emit_v2_event(&env, (EVENT_ADMIN_SET,), admin); + Ok(()) + } + + /// Get the admin address, if set. + pub fn get_admin(env: Env) -> Option
{ + let key = DataKey::Admin; + env.storage().persistent().get(&key) + } + + // ── Admin rotation safety flow (Issue #191, #557) ────────────── + + /// Propose a two-phase admin rotation to `new_admin`. + /// + /// The current admin initiates; `new_admin` must call finalize after + /// the configured delay to complete the transfer. The proposal timestamp is recorded for + /// delay enforcement. Only one rotation may be pending at a time. + /// + /// ### Auth + /// Current admin (`require_auth`). + /// + /// ### Errors + /// - `AdminRotationSameAddress` — `new_admin` equals current admin. + /// - `AdminRotationPending` — a rotation is already pending; cancel it first. + /// - `ContractFrozen` — contract is frozen. + /// + /// ### Events + /// Emits `adm_prop`: `(adm_prop, current_admin)` → `new_admin`. + pub fn propose_admin_rotation( + env: Env, + new_admin: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + + admin.require_auth(); + + if new_admin == admin { + return Err(RevoraError::AdminRotationSameAddress); + } + + if env.storage().persistent().has(&DataKey::PendingAdmin) { + return Err(RevoraError::AdminRotationPending); + } + + let pending = PendingAdminRotation { + new_admin: new_admin.clone(), + proposed_at: env.ledger().timestamp(), + }; + env + .storage() + .persistent() + .set(&DataKey::PendingAdmin, &pending); - // Build a per-offering prefix: sha256(issuer || namespace || token) - let mut prefix_input = Bytes::new(&env); - prefix_input.append(&issuer.to_xdr(&env)); - prefix_input.append(&namespace.to_xdr(&env)); - prefix_input.append(&token.to_xdr(&env)); + env + .events() + .publish((symbol_short!("adm_prop"), admin), new_admin); - let bps_floor: u32 = 10_000u32 / count; - let bps_remainder: u32 = 10_000u32 % count; + Ok(()) + } + + /// Finalize a pending two-phase admin rotation after the configured delay has elapsed. + /// + /// The new admin must authorize and match the pending proposed address. The delay + /// (configured via [`set_admin_rotation_delay`]) is checked against the proposal + /// timestamp stored in `propose_admin_rotation`. + /// + /// ### Auth + /// `new_admin` must authorize (`require_auth`). Caller must match the pending proposed address. + /// + /// ### Errors + /// - `NoAdminRotationPending` — no rotation was proposed. + /// - `UnauthorizedRotationAccept` — caller does not match the pending proposed address. + /// - `AdminRotationDelayNotElapsed` — the configured delay has not yet passed. + /// - `ContractFrozen` — contract is frozen. + /// + /// ### Events + /// Emits `adm_fin`: `(adm_fin, old_admin)` → `new_admin`. + /// Emits `adm_log` (v2): the persisted `AdminRotationEntry`. + pub fn finalize_admin_rotation( + env: Env, + new_admin: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let pending: PendingAdminRotation = env + .storage() + .persistent() + .get(&DataKey::PendingAdmin) + .ok_or(RevoraError::NoAdminRotationPending)?; + + if new_admin != pending.new_admin { + return Err(RevoraError::UnauthorizedRotationAccept); + } + + new_admin.require_auth(); + + // Enforce mandatory delay + let delay: u64 = env + .storage() + .persistent() + .get(&DataKey2::AdminRotationDelay) + .unwrap_or(0u64); + if delay > 0 { + let elapsed = + env.ledger().timestamp().saturating_sub(pending.proposed_at); + if elapsed < delay { + return Err(RevoraError::AdminRotationDelayNotElapsed); + } + } + + let old_admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + + env.storage().persistent().set(&DataKey::Admin, &new_admin); + env.storage().persistent().remove(&DataKey::PendingAdmin); + + // Persist append-only rotation log entry + let rotation_id: u64 = env + .storage() + .persistent() + .get(&DataKey2::AdminRotationCount) + .unwrap_or(0u64) + + 1; + let rotated_at = env.ledger().timestamp(); + let entry = AdminRotationEntry { + prior_admin: old_admin.clone(), + new_admin: new_admin.clone(), + rotated_at, + }; + env + .storage() + .persistent() + .set(&DataKey2::AdminRotationLog(rotation_id), &entry); + env + .storage() + .persistent() + .set(&DataKey2::AdminRotationCount, &rotation_id); + + // Evict oldest entry to keep log bounded + if rotation_id > MAX_ADMIN_ROTATION_LOG { + let evict_id = rotation_id - MAX_ADMIN_ROTATION_LOG; + env + .storage() + .persistent() + .remove(&DataKey2::AdminRotationLog(evict_id)); + } + + env + .events() + .publish((symbol_short!("adm_acc"), old_admin), new_admin); + Self::emit_v2_event(&env, (EVENT_ADMIN_ROTATION_LOGGED,), entry); - let mut seeds: Vec> = Vec::new(&env); + Ok(()) + } + + /// Cancel a pending admin rotation before it is finalized. + /// + /// ### Auth + /// Current admin (`require_auth`). + /// + /// ### Errors + /// - `NoAdminRotationPending` — no rotation is pending. + /// - `ContractFrozen` — contract is frozen. + /// + /// ### Events + /// Emits `adm_canc`: `(adm_canc, current_admin)` → `proposed_new_admin`. + pub fn cancel_admin_rotation(env: Env) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + + admin.require_auth(); + + let pending: PendingAdminRotation = env + .storage() + .persistent() + .get(&DataKey::PendingAdmin) + .ok_or(RevoraError::NoAdminRotationPending)?; + + env.storage().persistent().remove(&DataKey::PendingAdmin); + + env + .events() + .publish((symbol_short!("adm_canc"), admin), pending.new_admin); - for idx in 0..count { - // 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).into(); + Ok(()) + } + + /// Revoke an in-progress admin rotation proposal, returning the contract to steady state. + /// + /// Allows the outgoing (current) admin to abort an in-progress rotation proposal. + /// + /// ### Auth + /// Current stored admin (`require_auth`). + /// + /// ### Errors + /// - `NoAdminRotationPending` — no rotation is pending. + /// - `NotInitialized` — contract admin is not initialized. + /// - `ContractFrozen` — contract is frozen. + /// + /// ### Events + /// Emits `EVENT_ADMIN_ROTATION_REVOKED` (`"adm_rvk"`): `(adm_rvk, current_admin)` → `proposed_new_admin`. + pub fn revoke_admin_rotation(env: Env) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + + admin.require_auth(); + + let pending: PendingAdminRotation = env + .storage() + .persistent() + .get(&DataKey::PendingAdmin) + .ok_or(RevoraError::NoAdminRotationPending)?; + + env.storage().persistent().remove(&DataKey::PendingAdmin); + + env + .events() + .publish((EVENT_ADMIN_ROTATION_REVOKED, admin), pending.new_admin); - let share_bps: u32 = - if idx == count - 1 { bps_floor + bps_remainder } else { bps_floor }; + Ok(()) + } + + /// Return the proposed new admin address for a pending rotation, or `None` if none is pending. + /// + /// ### Auth + /// None — read-only. + pub fn get_pending_admin_rotation(env: Env) -> Option
{ + env + .storage() + .persistent() + .get::(&DataKey::PendingAdmin) + .map(|p| p.new_admin) + } + + /// Return the full [`PendingAdminRotation`] details (new admin + proposal timestamp), + /// or `None` if no rotation is pending. + /// + /// ### Auth + /// None — read-only. + pub fn get_pending_admin_rotation_details( + env: Env, + ) -> Option { + env.storage().persistent().get(&DataKey::PendingAdmin) + } + + /// Return a page of the append-only admin rotation history log. + /// + /// Entries are returned in chronological order (earliest first). The log is bounded + /// to [`MAX_ADMIN_ROTATION_LOG`] entries — the oldest entries are evicted FIFO when + /// the limit is reached. + /// + /// ### Pagination + /// - `start`: zero-based index of the first entry to return (0 = most recent first). + /// - `limit`: maximum number of entries to return (capped at [`MAX_PAGE_LIMIT`]). + /// + /// ### Returns + /// `(entries, next_cursor)` where: + /// - `entries` is the page of [`AdminRotationEntry`] values. + /// - `next_cursor` is `Some(next_start)` if there are more entries, or `None` otherwise. + /// + /// ### Auth + /// None — read-only. + pub fn get_admin_rotation_history_page( + env: Env, + start: u32, + limit: u32, + ) -> (Vec, Option) { + let count: u64 = env + .storage() + .persistent() + .get(&DataKey2::AdminRotationCount) + .unwrap_or(0u64); + + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; - // Store seed for test-suite retrieval without forcing a full scan. - env.storage() - .persistent() - .set(&DataKey2::FaucetSeedEntry(offering_id.clone(), idx), &seed); + if start as u64 >= count { + return (Vec::new(&env), None); + } - env.events().publish( - (EVENT_FAUCET_SEED, issuer.clone(), namespace.clone(), token.clone()), - (idx, seed.clone(), share_bps), - ); + // Compute the first surviving rotation ID (evicted entries are skipped). + let first_surviving: u64 = if count > MAX_ADMIN_ROTATION_LOG { + count - MAX_ADMIN_ROTATION_LOG + 1 + } else { + 1 + }; + let end = core::cmp::min(start as u64 + effective_limit as u64, count); + let mut results = Vec::new(&env); + + for i in start as u64..end { + let rotation_id = first_surviving + i; + let log_key = DataKey2::AdminRotationLog(rotation_id); + let entry: AdminRotationEntry = + env.storage().persistent().get(&log_key).unwrap(); + results.push_back(entry); + } + + let next_cursor = if end < count { Some(end as u32) } else { None }; + (results, next_cursor) + } + + /// Freeze the contract with a specific reason: no further state-changing operations allowed. + /// Only admin may call. Emits freeze_reason_v1 event with reason and target address so + /// indexers can categorize halts without inspecting storage. + /// If multisig is initialized, this function is disabled in favor of execute_action(Freeze). + pub fn set_freeze(env: Env, reason: FreezeReason) -> Result<(), RevoraError> { + if env.storage().persistent().has(&DataKey2::MultisigThreshold) { + return Err(RevoraError::LimitReached); + } + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::LimitReached)?; + admin.require_auth(); + env.storage().persistent().set(&DataKey::Frozen, &true); + env + .storage() + .persistent() + .set(&DataKey2::GlobalFreezeReason, &reason); + env + .events() + .publish((symbol_short!("frz_set"),), (admin, reason)); + Self::emit_v2_event(&env, (EVENT_FREEZE_V2,), true); + Self::emit_v2_event(&env, (EVENT_FREEZE_REASON_V1, admin), reason); + Ok(()) + } + + /// Freeze the contract with the default `Compliance` reason. + /// + /// Convenience wrapper around [`set_freeze`] for callers that do not need to + /// specify a reason explicitly. Existing integrations that call `freeze()` + /// continue to work without modification. + /// + /// ### Auth / Errors / Events + /// Identical to `set_freeze(env, FreezeReason::Compliance)`. + pub fn freeze(env: Env) -> Result<(), RevoraError> { + Self::set_freeze(env, FreezeReason::Compliance) + } + + /// Return the stored global freeze reason, if the contract is globally frozen. + /// + /// Returns `None` when the contract has never been frozen via `set_freeze`. + pub fn get_freeze_reason(env: Env) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::GlobalFreezeReason) + } + + /// Freeze a single offering while keeping other offerings operational. + /// + /// Authorization boundary: + /// - Current issuer for the offering, or + /// - Global admin + /// + /// Security posture: + /// - This action is blocked when the whole contract is globally frozen (fail-closed). + /// - Claims remain intentionally allowed for frozen offerings so users can exit. + pub fn freeze_offering( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + caller.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - seeds.push_back(seed); - } + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + let key = DataKey2::FrozenOffering(offering_id); + env.storage().persistent().set(&key, &true); + env.events().publish( + (EVENT_FREEZE_OFFERING, issuer, namespace, token), + (caller, true), + ); + Ok(()) + } + + /// Unfreeze a single offering. + /// + /// Authorization mirrors `freeze_offering`: issuer or admin. + pub fn unfreeze_offering( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + caller.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Persist the highest slot count so faucet_reset can clear entries without - // an unbounded storage scan. We take the max in case this is a re-call with - // a smaller count. - let prev_count: u32 = env - .storage() - .persistent() - .get::(&DataKey2::FaucetSeedCount(offering_id.clone())) - .unwrap_or(0); - if count > prev_count { - env.storage() - .persistent() - .set::(&DataKey2::FaucetSeedCount(offering_id.clone()), &count); - } + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + let key = DataKey2::FrozenOffering(offering_id); + env.storage().persistent().set(&key, &false); + env.events().publish( + (EVENT_UNFREEZE_OFFERING, issuer, namespace, token), + (caller, false), + ); + Ok(()) + } + + /// Return true if an individual offering is frozen. + pub fn is_offering_frozen( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> bool { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey2::FrozenOffering(offering_id)) + .unwrap_or(false) + } + + /// Return true if the contract is frozen. + pub fn is_frozen(env: Env) -> bool { + env + .storage() + .persistent() + .get::(&DataKey::Frozen) + .unwrap_or(false) + } + + /// Emergency freeze a holder for an offering. + /// + /// Authorization boundary: + /// - Current issuer for the offering, or + /// - Global admin + /// + /// Security posture: + /// - This action is blocked when the whole contract is globally frozen (fail-closed). + /// - Claims and transfers are blocked for the holder. + pub fn emergency_freeze_holder( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + reason: FreezeReason, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + caller.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - Ok(seeds) - } - - /// Deterministically reset the faucet state for an offering (testnet only). - /// - /// `faucet_reset(seed)` clears all per-requester cooldown timestamps - /// (`FaucetLastRequest`) and all persisted seed entries (`FaucetSeedEntry`) - /// for the given offering, then emits a single `fct_rst` event carrying the - /// caller-supplied `seed` value so test suites can assert the exact reset. - /// - /// ### Security - /// - **Strictly testnet-only.** Returns `RevoraError::TestnetOnly` when - /// `testnet_mode == false`. Must never be callable on mainnet. - /// - Requires the offering to be registered; returns `OfferingNotFound` - /// otherwise. - /// - Requires admin authorisation (`admin.require_auth()`). - /// - /// ### Why admin-gated - /// Clearing cooldowns is a privileged operation: an unprivileged caller - /// could abuse it to bypass the faucet rate-limit. Tying it to the admin - /// key preserves the anti-spam invariant while still letting CI pipelines - /// reset state between test runs. - /// - /// ### Parameters - /// - `caller` — the admin address (must match the stored admin key). - /// - `issuer` / `namespace` / `token` — offering identity. - /// - `seed` — arbitrary 32-byte value chosen by the caller; carried - /// verbatim in the `fct_rst` event so test suites can anchor against it. - /// - /// ### State mutations - /// 1. Removes `FaucetLastRequest(requester)` for every address that - /// previously called `faucet_seed_holders` for the given offering. - /// Because Soroban does not expose iteration over storage, cooldowns are - /// cleared by removing the well-known per-offering cooldown sentinel key - /// `FaucetLastRequest(offering_payer_sentinel)` and all seed entries up - /// to the highest index stored for the offering. - /// - /// Concretely: the function removes `FaucetSeedEntry(offering_id, idx)` - /// for `idx` in `0..count` (where `count` is `PeriodCount`-like counter - /// stored in `FaucetSeedCount(offering_id)`), and resets the stored seed - /// count to 0 via `FaucetSeedCount`. - /// - /// 2. Emits `fct_rst` event. - /// - /// ### Returns - /// `Ok(())` on success; `Err(RevoraError)` on any validation failure. - pub fn faucet_reset( - env: Env, - caller: Address, - issuer: Address, - namespace: Symbol, - token: Address, - seed: BytesN<32>, - ) -> Result<(), RevoraError> { - // ── Testnet gate ────────────────────────────────────────────────────── - if !Self::is_testnet_mode(env.clone()) { - return Err(RevoraError::TestnetOnly); - } + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + // 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(()) + } + + // ── TWAP window configurability (#546) ─────────────────────────────────── + + /// Configure the TWAP (Time-Weighted Average Price) smoothing window for an + /// offering's NAV computation. + /// + /// # Authorization + /// Caller must be either: + /// - the **current issuer** of the offering, or + /// - the **contract admin**. + /// + /// `caller.require_auth()` is called before any state reads, following the + /// auth-first pattern used throughout this contract. + /// + /// # Bounds + /// `window_secs` must satisfy: + /// ```text + /// MIN_TWAP_WINDOW_SECS (60 s) <= window_secs <= MAX_TWAP_WINDOW_SECS (30 days) + /// ``` + /// Values outside this range are rejected with a structured [`RevoraError`]: + /// - Below minimum → [`RevoraError::TwapWindowTooShort`] + /// - Above maximum → [`RevoraError::TwapWindowTooLong`] + /// + /// # Errors + /// | Error | Condition | + /// |---|---| + /// | `ContractFrozen` | Contract is globally frozen | + /// | `OfferingNotFound` | No offering exists for `(issuer, namespace, token)` | + /// | `NotAuthorized` | Caller is neither issuer nor admin | + /// | `TwapWindowTooShort` | `window_secs < MIN_TWAP_WINDOW_SECS` | + /// | `TwapWindowTooLong` | `window_secs > MAX_TWAP_WINDOW_SECS` | + /// + /// # Events + /// On success emits: + /// ```text + /// topic: (twap_win_set, issuer, namespace, token) + /// data: (caller, window_secs) + /// ``` + pub fn set_twap_window( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + window_secs: u64, + ) -> Result<(), RevoraError> { + // Auth-first: authenticate before any state reads. + Self::require_not_frozen(&env)?; + caller.require_auth(); + + // Bounds check — reject values outside the documented min/max range. + if window_secs < MIN_TWAP_WINDOW_SECS { + return Err(RevoraError::TwapWindowTooShort); + } + if window_secs > MAX_TWAP_WINDOW_SECS { + return Err(RevoraError::TwapWindowTooLong); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // ── Admin authorisation ─────────────────────────────────────────────── - let admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - admin.require_auth(); - if caller != admin { - return Err(RevoraError::NotAuthorized); - } + // Verify offering exists and resolve authorization. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + let config = TwapConfig { + twap_window_secs: window_secs, + updated_at: env.ledger().timestamp(), + updated_by: caller.clone(), + }; + env + .storage() + .persistent() + .set(&DataKey2::TwapWindowSecs(offering_id), &config); + + env.events().publish( + (EVENT_TWAP_WINDOW_SET, issuer, namespace, token), + (caller, window_secs), + ); + Ok(()) + } + + /// Return the current [`TwapConfig`] for an offering, or `None` if no TWAP + /// window has been configured yet. + /// + /// This is a read-only view — no auth required. + pub fn get_twap_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get::(&DataKey2::TwapWindowSecs(offering_id)) + } + + /// Emergency unfreeze a holder for an offering. + /// + /// Authorization boundary matches `emergency_freeze_holder`. + /// + /// Security posture: + /// - Requires the exact same `reason` that was used to freeze the 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, + namespace: Symbol, + token: Address, + holder: Address, + reason: FreezeReason, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + caller.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // ── Offering must exist ─────────────────────────────────────────────── - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - if !env.storage().persistent().has(&DataKey2::OfferingRecord(offering_id.clone())) { - return Err(RevoraError::OfferingNotFound); - } + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()); + let is_admin = admin.as_ref().map(|a| caller == *a).unwrap_or(false); + if caller != current_issuer && !is_admin { + return Err(RevoraError::NotAuthorized); + } + + 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); + } + + 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), + ); + } - // ── Clear seed entries ──────────────────────────────────────────────── - // Remove every persisted FaucetSeedEntry for this offering. - // We track the highest slot index via FaucetSeedCount(offering_id) so we - // can iterate without unbounded storage scans. - let seed_count: u32 = env - .storage() - .persistent() - .get::(&DataKey2::FaucetSeedCount(offering_id.clone())) - .unwrap_or(0); + 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, + issuer: Address, + namespace: Symbol, + 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::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. + /// Returns `None` if no dispute with the given ID exists. + pub fn get_dispute(env: Env, dispute_id: u64) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::DisputeEntry(dispute_id)) + } + + // ── Multisig admin logic ─────────────────────────────────── + + /// Process an OFAC attestation and automatically freeze the targeted holder. + /// + /// When an OFAC attestation naming a currently-active holder is submitted, + /// this function freezes the holder's account immediately and emits a structured + /// event referencing the attestation hash. This operation is idempotent per + /// attestation hash to prevent replay attacks. + /// + /// ### Parameters + /// - `attestation_hash`: The 32-byte hash of the OFAC attestation. Used for idempotency. + /// - `issuer`: The issuer address of the offering. + /// - `namespace`: The namespace of the offering. + /// - `token`: The token representing the offering. + /// - `holder`: The holder address to be frozen if named in the attestation. + /// + /// ### Security Assumptions + /// - The caller is authorized to submit OFAC attestations (typically an admin or oracle). + /// - The attestation hash uniquely identifies a specific OFAC attestation. + /// - Idempotency is enforced by tracking processed attestation hashes. + /// + /// ### Returns + /// - `Ok(())` on success (or if the attestation was already processed). + /// - `Err(RevoraError::ContractFrozen)` if the contract is globally frozen. + /// + /// ### Events + /// - Emits `auto_frz` with `(issuer, namespace, token)` topics and `(holder, attestation_hash)` data. + #[allow(clippy::too_many_arguments)] + pub fn process_ofac_attestation( + env: Env, + attestation_hash: BytesN<32>, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + + // Check idempotency: if this attestation hash was already processed, return success + let hash_key = DataKey2::ProcessedAttestationHash(attestation_hash.clone()); + if env.storage().persistent().has(&hash_key) { + return Ok(()); + } + + // Mark the attestation hash as processed + if !Self::is_event_only(&env) { + env.storage().persistent().set(&hash_key, &true); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - for idx in 0..seed_count { - env.storage().persistent().remove(&DataKey2::FaucetSeedEntry(offering_id.clone(), idx)); - } + // Freeze the holder with SanctionsMatch reason (OR into bitmask). + if !Self::is_event_only(&env) { + let fk = DataKey2::HolderFreezeMask(offering_id.clone(), holder.clone()); + let legacy_key = + DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone()); - // Reset the seed count to 0 so future faucet_seed_holders calls start fresh. - env.storage() + // Read existing mask, migrating legacy key if present. + let current_mask: u32 = + env.storage().persistent().get(&fk).unwrap_or_else(|| { + env + .storage() .persistent() - .set::(&DataKey2::FaucetSeedCount(offering_id.clone()), &0); - - // ── Emit reset event ────────────────────────────────────────────────── - env.events().publish( - (EVENT_FAUCET_RESET, issuer.clone(), namespace.clone(), token.clone()), - (caller, seed, seed_count), - ); + .get::(&legacy_key) + .map(|r| r.to_bitmask()) + .unwrap_or(0) + }); - Ok(()) - } -} // end impl RevoraRevenueShare (plain) + // Clean up legacy key if present. + if env.storage().persistent().has(&legacy_key) { + env.storage().persistent().remove(&legacy_key); + } -#[cfg(test)] -mod issue_455_fx_oracle_tests { - use super::*; - use soroban_sdk::{ - contract, contractimpl, - testutils::{Address as _, Ledger}, - Address, Env, Symbol, - }; - - pub mod fresh { - use super::*; - #[contract] - pub struct FreshFxOracleStub; - - #[contractimpl] - impl FreshFxOracleStub { - pub fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64) { - assert_eq!(from, Symbol::new(&env, "EUR")); - assert_eq!(to, Symbol::new(&env, "USDC")); - (12_000, env.ledger().timestamp()) - } - } + env.storage().persistent().set( + &fk, + &(current_mask | FreezeReason::SanctionsMatch.to_bitmask()), + ); } - use fresh::FreshFxOracleStub; - pub mod stale { - use super::*; - #[contract] - pub struct StaleFxOracleStub; + // Emit structured event for audit trail + env.events().publish( + (EVENT_AUTO_FRZ, issuer, namespace, token), + (holder, attestation_hash), + ); - #[contractimpl] - impl StaleFxOracleStub { - pub fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64) { - assert_eq!(from, Symbol::new(&env, "EUR")); - assert_eq!(to, Symbol::new(&env, "USDC")); - (12_000, env.ledger().timestamp().saturating_sub(120)) - } - } - } - use stale::StaleFxOracleStub; - - fn setup() -> (Env, RevoraRevenueShareClient<'static>, Address, Symbol, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|ledger| ledger.timestamp = 1_000); - - let contract_id = env.register_contract(None, RevoraRevenueShare); - let client = RevoraRevenueShareClient::new(&env, &contract_id); - let issuer = Address::generate(&env); - let namespace = Symbol::new(&env, "def"); - let token = Address::generate(&env); - let payout_asset = Address::generate(&env); - - client.register_offering(&issuer, &namespace, &token, &5_000, &payout_asset, &0); - (env, client, issuer, namespace, token, payout_asset) - } - - #[test] - fn report_revenue_converts_cross_currency_amount_with_registered_oracle() { - let (env, client, issuer, namespace, token, _payout_asset) = setup(); - let oracle = env.register_contract(None, FreshFxOracleStub); - let reported_asset = Address::generate(&env); - - client.set_fx_oracle( - &issuer, - &namespace, - &token, - &oracle, - &Symbol::new(&env, "EUR"), - &Symbol::new(&env, "USDC"), - &60, - ); + Ok(()) + } + + pub const MAX_MULTISIG_OWNERS: u32 = 20; + /// Maximum proposal duration: 365 days in seconds. + pub const MAX_PROPOSAL_DURATION: u64 = 365 * 24 * 60 * 60; + + /// Initialize the multisig admin system. May only be called once. + /// Only the caller (deployer/admin) needs to authorize; owners are registered + /// without requiring their individual signatures at init time. + /// + /// # Soroban Limitation Note + /// Soroban does not support requiring multiple signers in a single transaction + /// invocation. Each owner must separately call `approve_action` to sign proposals. + /// + /// # Validation Rules + /// - `owners` must not be empty and must contain ≤ 20 unique addresses + /// - `threshold` must be in range [1, owners.len()] + /// - `proposal_duration` must be in range [1, 31,536,000] seconds (365 days) + /// + /// # Errors + /// - `NotAuthorized`: Caller is not the admin + /// - `NotInitialized`: Admin not set (contract not initialized) + /// - `LimitReached`: Already initialized, empty owners, too many owners, invalid threshold, or duplicate owners + /// - `InvalidAmount`: Duration is zero or exceeds maximum + /// + /// # Events + /// Emits `ms_init` with `(caller, (owners_count, threshold))` on success. + pub fn init_multisig( + env: Env, + caller: Address, + owners: Vec
, + threshold: u32, + proposal_duration: u64, + quorum_bps: u32, + ) -> Result<(), RevoraError> { + caller.require_auth(); + + // Must be the initialized admin + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + + if env.storage().persistent().has(&DataKey2::MultisigThreshold) { + return Err(RevoraError::LimitReached); // Already initialized + } + if owners.is_empty() { + return Err(RevoraError::LimitReached); // Must have at least one owner + } + if owners.len() > Self::MAX_MULTISIG_OWNERS { + return Err(RevoraError::LimitReached); + } + if threshold == 0 || threshold > owners.len() { + return Err(RevoraError::LimitReached); // Improper threshold + } + if proposal_duration == 0 { + return Err(RevoraError::InvalidAmount); + } + if quorum_bps == 0 || quorum_bps > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + + // Check for duplicate owners + for i in 0..owners.len() { + let owner_i = owners.get(i).unwrap(); + for j in (i + 1)..owners.len() { + if owner_i == owners.get(j).unwrap() { + return Err(RevoraError::LimitReached); + } + } + } + + // Validate proposal duration + if proposal_duration == 0 || proposal_duration > Self::MAX_PROPOSAL_DURATION + { + return Err(RevoraError::InvalidAmount); + } + + env + .storage() + .persistent() + .set(&DataKey2::MultisigThreshold, &threshold); + env + .storage() + .persistent() + .set(&DataKey2::MultisigOwners, &owners.clone()); + env + .storage() + .persistent() + .set(&DataKey2::MultisigProposalCount, &0_u32); + env + .storage() + .persistent() + .set(&DataKey2::MultisigProposalDuration, &proposal_duration); + env.events().publish( + (EVENT_MULTISIG_INIT, caller.clone()), + (owners.len(), threshold), + ); + Ok(()) + } + + /// Create a governance proposal bound to an offering and an issuer-authenticated metadata hash. + /// + /// The proposal id is deterministic per offering and increments from a per-offering counter. + /// The entrypoint is issuer-authenticated so the on-chain record is bound to the issuer's + /// signed transaction and can be audited off-chain. + pub fn create_proposal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + meta_hash: BytesN<32>, + quorum_bps: u32, + voting_window: u64, + ) -> Result { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + if quorum_bps == 0 || quorum_bps > 10_000 { + return Err(RevoraError::InvalidAmount); + } + if voting_window == 0 { + return Err(RevoraError::InvalidAmount); + } + if env + .storage() + .persistent() + .has(&DataKey2::GovernanceProposalMeta( + offering_id.clone(), + meta_hash.clone(), + )) + { + return Err(RevoraError::LimitReached); + } + + let count_key = DataKey2::GovernanceProposalCount(offering_id.clone()); + let proposal_id: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); + let ends_at = env + .ledger() + .timestamp() + .checked_add(voting_window) + .ok_or(RevoraError::InvalidAmount)?; + let proposal = GovernanceProposal { + id: proposal_id, + meta_hash: meta_hash.clone(), + quorum_bps, + ends_at, + }; - client.report_revenue(&issuer, &namespace, &token, &reported_asset, &1_000, &1, &false); + env.storage().persistent().set( + &DataKey2::GovernanceProposal(offering_id.clone(), proposal_id), + &proposal, + ); + env + .storage() + .persistent() + .set(&count_key, &(proposal_id + 1)); + env.storage().persistent().set( + &DataKey2::GovernanceProposalMeta(offering_id.clone(), meta_hash.clone()), + &true, + ); + env.events().publish( + ( + EVENT_PROPOSAL_CREATED_GOV, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (proposal_id, meta_hash, quorum_bps, ends_at), + ); + Ok(proposal_id) + } + + /// Return a previously created governance proposal for an offering. + pub fn get_proposal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + proposal_id: u32, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::GovernanceProposal(offering_id, proposal_id)) + } + + /// Propose a sensitive administrative action. + /// The proposer's address is automatically counted as the first approval. + pub fn propose_action( + env: Env, + proposer: Address, + action: ProposalAction, + ) -> Result { + proposer.require_auth(); + Self::require_multisig_owner(&env, &proposer)?; + + let count_key = DataKey2::MultisigProposalCount; + let id: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + + let duration: u64 = env + .storage() + .persistent() + .get(&DataKey2::MultisigProposalDuration) + .ok_or(RevoraError::NotInitialized)?; + let current_epoch: u64 = env + .storage() + .persistent() + .get(&DataKey2::MultisigEpoch) + .unwrap_or(0); + let now = env.ledger().timestamp(); + let expiry = now + .checked_add(duration) + .ok_or(RevoraError::InvalidAmount)?; + + // Proposer's vote counts as the first approval automatically + let mut initial_approvals = Vec::new(&env); + initial_approvals.push_back(proposer.clone()); + + let quorum_bps: u32 = env + .storage() + .persistent() + .get(&DataKey2::MultisigQuorumBps) + .unwrap_or(5100); + + let proposal = Proposal { + id, + action, + proposer: proposer.clone(), + approvals: initial_approvals, + executed: false, + expiry, + }; - assert_eq!(client.get_revenue_by_period(&issuer, &namespace, &token, &1), 1_200); - assert_eq!( - client.get_audit_summary(&issuer, &namespace, &token).unwrap().total_revenue, - 1_200 - ); - } + env + .storage() + .persistent() + .set(&DataKey2::MultisigProposal(id), &proposal); + env.storage().persistent().set(&count_key, &(id + 1)); + + env + .events() + .publish((EVENT_PROPOSAL_CREATED, proposer.clone()), (id, expiry)); + env + .events() + .publish((EVENT_PROPOSAL_APPROVED, proposer), id); + Ok(id) + } + + /// Approve an existing multisig proposal. + pub fn approve_action( + env: Env, + approver: Address, + proposal_id: u32, + ) -> Result<(), RevoraError> { + approver.require_auth(); + Self::require_multisig_owner(&env, &approver)?; + + let key = DataKey2::MultisigProposal(proposal_id); + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&key) + .ok_or(RevoraError::OfferingNotFound)?; + + if proposal.executed { + return Err(RevoraError::LimitReached); + } + + if env.ledger().timestamp() >= proposal.expiry { + return Err(RevoraError::ProposalExpired); + } + + // Check for duplicate approvals + for i in 0..proposal.approvals.len() { + if proposal.approvals.get(i).unwrap() == approver { + return Err(RevoraError::AlreadyApproved); + } + } + + proposal.approvals.push_back(approver.clone()); + env + .events() + .publish((EVENT_PROPOSAL_APPROVED, approver.clone()), proposal_id); + + let _threshold: u32 = env + .storage() + .persistent() + .get(&DataKey2::MultisigThreshold) + .ok_or(RevoraError::NotInitialized)?; + + env.storage().persistent().set(&key, &proposal); + Ok(()) + } - #[test] - fn stale_oracle_quote_rejects_report_without_state_change() { - let (env, client, issuer, namespace, token, _payout_asset) = setup(); - let oracle = env.register_contract(None, StaleFxOracleStub); - let reported_asset = Address::generate(&env); - - client.set_fx_oracle( - &issuer, - &namespace, - &token, - &oracle, - &Symbol::new(&env, "EUR"), - &Symbol::new(&env, "USDC"), - &60, - ); + /// Execute a multisig proposal once the approval threshold is reached. + pub fn execute_action( + env: Env, + executor: Address, + proposal_id: u32, + ) -> Result<(), RevoraError> { + executor.require_auth(); + Self::require_multisig_owner(&env, &executor)?; - let result = client.try_report_revenue( - &issuer, - &namespace, - &token, - &reported_asset, - &1_000, - &1, - &false, - ); + let key = DataKey2::MultisigProposal(proposal_id); + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&key) + .ok_or(RevoraError::OfferingNotFound)?; - assert_eq!(result, Err(Ok(RevoraError::OracleQuoteStale))); - assert_eq!(client.get_revenue_by_period(&issuer, &namespace, &token, &1), 0); - assert_eq!(client.get_audit_summary(&issuer, &namespace, &token), None); + if proposal.executed { + return Err(RevoraError::LimitReached); } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Oracle fallback-chain tests (issue #547) -// ───────────────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod oracle_chain_tests { - use super::*; - use soroban_sdk::{ - contract, contractimpl, - testutils::{Address as _, Ledger}, - Address, Env, Symbol, Vec, - }; - // ── Stub contracts ──────────────────────────────────────────────────────── - - /// Oracle that always returns a fresh quote (rate = 1.2 × 10_000 = 12_000 bps, - /// timestamp = current ledger time). - #[contract] - pub struct FreshOracle; - #[contractimpl] - impl FreshOracle { - pub fn quote(env: Env, _from: Symbol, _to: Symbol) -> (i128, u64) { - (12_000, env.ledger().timestamp()) - } + if env.ledger().timestamp() >= proposal.expiry { + return Err(RevoraError::ProposalExpired); } - /// Oracle that always returns a stale quote (timestamp 200 s in the past). - #[contract] - pub struct StaleOracle; - #[contractimpl] - impl StaleOracle { - pub fn quote(env: Env, _from: Symbol, _to: Symbol) -> (i128, u64) { - (10_000, env.ledger().timestamp().saturating_sub(200)) - } + let current_epoch: u64 = env + .storage() + .persistent() + .get(&DataKey2::MultisigEpoch) + .unwrap_or(0); + if proposal.epoch != current_epoch { + env.events().publish( + (EVENT_STALE_PROPOSAL_REJECT, executor.clone()), + (proposal_id, proposal.epoch, current_epoch), + ); + return Err(RevoraError::StaleProposal); } - /// Oracle that returns a different fresh rate (rate = 0.9 × 10_000 = 9_000 bps). - #[contract] - pub struct SecondaryFreshOracle; - #[contractimpl] - impl SecondaryFreshOracle { - pub fn quote(env: Env, _from: Symbol, _to: Symbol) -> (i128, u64) { - (9_000, env.ledger().timestamp()) - } + let threshold: u32 = env + .storage() + .persistent() + .get(&DataKey2::MultisigThreshold) + .ok_or(RevoraError::NotInitialized)?; + if proposal.approvals.len() < threshold { + return Err(RevoraError::NotAuthorized); } - // ── Helpers ─────────────────────────────────────────────────────────────── - - fn setup(env: &Env) -> (RevoraRevenueShareClient<'static>, Address, Symbol, Address, Address) { - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000); - let id = env.register_contract(None, RevoraRevenueShare); - let client = RevoraRevenueShareClient::new(env, &id); - let issuer = Address::generate(env); - let ns = Symbol::new(env, "ns"); - let token = Address::generate(env); - let payout = Address::generate(env); - client.register_offering(&issuer, &ns, &token, &5_000, &payout, &0); - (client, issuer, ns, token, payout) + // Quorum check: summed voter weight must meet or exceed quorum_bps + if !Self::check_quorum_inner(&env, &proposal) { + return Err(RevoraError::NotAuthorized); } - fn make_entry(env: &Env, oracle: &Address, max_age: u64) -> OracleEntry { - OracleEntry { - oracle: oracle.clone(), - revenue_symbol: Symbol::new(env, "EUR"), - payout_symbol: Symbol::new(env, "USD"), - max_age_secs: max_age, - } - } + proposal.executed = true; + env.storage().persistent().set(&key, &proposal); - // ── Tests ───────────────────────────────────────────────────────────────── - - /// Setting and reading an oracle chain round-trips correctly. - #[test] - fn set_and_get_oracle_chain_round_trips() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let oracle = env.register_contract(None, FreshOracle); - - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &oracle, 60)); - client.set_oracle_chain(&issuer, &ns, &token, &entries); - - let chain = client.get_oracle_chain(&issuer, &ns, &token).unwrap(); - assert_eq!(chain.entries.len(), 1); - assert_eq!(chain.entries.get(0).unwrap().oracle, oracle); - } - - /// When there is no chain and no single oracle, cross-currency report fails - /// with `PayoutAssetMismatch`. - #[test] - fn no_chain_no_single_oracle_returns_payout_asset_mismatch() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let other_asset = Address::generate(&env); - - let result = - client.try_report_revenue(&issuer, &ns, &token, &other_asset, &1_000, &1, &false); - assert_eq!(result, Err(Ok(RevoraError::PayoutAssetMismatch))); - } - - /// First oracle fresh → chain resolves on the first entry; amount is converted. - #[test] - fn chain_first_oracle_fresh_uses_first_entry() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let fresh = env.register_contract(None, FreshOracle); - let reported_asset = Address::generate(&env); - - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &fresh, 60)); - client.set_oracle_chain(&issuer, &ns, &token, &entries); - - // rate = 12_000 bps → 1_000 × 12_000 / 10_000 = 1_200 - client.report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 1_200); - assert_eq!(client.get_audit_summary(&issuer, &ns, &token).unwrap().total_revenue, 1_200); - } - - /// First oracle stale, second fresh → fallback to second entry succeeds. - #[test] - fn chain_first_stale_second_fresh_falls_through() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let stale = env.register_contract(None, StaleOracle); - let fresh = env.register_contract(None, SecondaryFreshOracle); - let reported_asset = Address::generate(&env); - - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &stale, 60)); // stale - entries.push_back(make_entry(&env, &fresh, 60)); // fresh at 9_000 bps - client.set_oracle_chain(&issuer, &ns, &token, &entries); - - // rate = 9_000 bps → 1_000 × 9_000 / 10_000 = 900 - client.report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 900); - } - - /// All oracles stale → `AllOraclesStale` error, no state change. - #[test] - fn all_oracles_stale_returns_all_oracles_stale_error() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let s1 = env.register_contract(None, StaleOracle); - let s2 = env.register_contract(None, StaleOracle); - let reported_asset = Address::generate(&env); - - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &s1, 60)); - entries.push_back(make_entry(&env, &s2, 60)); - client.set_oracle_chain(&issuer, &ns, &token, &entries); - - let result = - client.try_report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(result, Err(Ok(RevoraError::AllOraclesStale))); - // No state written - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 0); - assert_eq!(client.get_audit_summary(&issuer, &ns, &token), None); - } - - /// Only the last entry is fresh → chain walks through all stale ones and - /// resolves on the last. - #[test] - fn chain_only_last_entry_fresh() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let s1 = env.register_contract(None, StaleOracle); - let s2 = env.register_contract(None, StaleOracle); - let fresh = env.register_contract(None, FreshOracle); - let reported_asset = Address::generate(&env); - - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &s1, 60)); - entries.push_back(make_entry(&env, &s2, 60)); - entries.push_back(make_entry(&env, &fresh, 60)); // last, rate=12_000 bps - client.set_oracle_chain(&issuer, &ns, &token, &entries); - - // 1_000 × 12_000 / 10_000 = 1_200 - client.report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 1_200); - } - - /// Empty chain stores successfully; cross-currency report falls back to the - /// legacy single-oracle path (or fails with `PayoutAssetMismatch` when no - /// legacy oracle is set either). - #[test] - fn empty_chain_falls_back_to_single_oracle() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - - // Store empty chain - client.set_oracle_chain(&issuer, &ns, &token, &Vec::new(&env)); - - let fresh = env.register_contract(None, FreshOracle); - let reported_asset = Address::generate(&env); - - // Configure legacy single oracle - client.set_fx_oracle( - &issuer, - &ns, - &token, - &fresh, - &Symbol::new(&env, "EUR"), - &Symbol::new(&env, "USD"), - &60, + match proposal.action.clone() { + ProposalAction::SetAdmin(new_admin) => { + env.storage().persistent().set(&DataKey::Admin, &new_admin); + } + ProposalAction::Freeze => { + env.storage().persistent().set(&DataKey::Frozen, &true); + Self::emit_v2_event( + &env, + (EVENT_FREEZE_V2, proposal.proposer.clone()), + true, ); + } + ProposalAction::SetThreshold(new_threshold) => { + let owners: Vec
= env + .storage() + .persistent() + .get(&DataKey2::MultisigOwners) + .unwrap(); + if new_threshold == 0 || new_threshold > owners.len() { + return Err(RevoraError::InvalidShareBps); + } + env + .storage() + .persistent() + .set(&DataKey2::MultisigThreshold, &new_threshold); + let next_epoch = current_epoch + 1; + env + .storage() + .persistent() + .set(&DataKey2::MultisigEpoch, &next_epoch); + } + ProposalAction::AddOwner(new_owner) => { + let mut owners: Vec
= env + .storage() + .persistent() + .get(&DataKey2::MultisigOwners) + .unwrap(); + if owners.len() >= Self::MAX_MULTISIG_OWNERS { + return Err(RevoraError::LimitReached); + } + if owners.contains(&new_owner) { + return Err(RevoraError::LimitReached); + } + owners.push_back(new_owner); + env + .storage() + .persistent() + .set(&DataKey2::MultisigOwners, &owners); + let next_epoch = current_epoch + 1; + env + .storage() + .persistent() + .set(&DataKey2::MultisigEpoch, &next_epoch); + } + ProposalAction::RemoveOwner(old_owner) => { + let owners: Vec
= env + .storage() + .persistent() + .get(&DataKey2::MultisigOwners) + .unwrap(); + if !owners.contains(&old_owner) { + return Err(RevoraError::NotAuthorized); + } + // Threshold invariant: remaining owners must still satisfy threshold. + if (owners.len() - 1) < threshold { + return Err(RevoraError::LimitReached); + } - // Should succeed via legacy path, rate=12_000 bps → 1_200 - client.report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 1_200); - } - - /// Chain takes priority over legacy single-oracle config when both are set. - #[test] - fn chain_takes_priority_over_legacy_single_oracle() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let chain_oracle = env.register_contract(None, SecondaryFreshOracle); // 9_000 bps - let legacy_oracle = env.register_contract(None, FreshOracle); // 12_000 bps - let reported_asset = Address::generate(&env); - - // Set chain (9_000 bps) AND legacy single oracle (12_000 bps) - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &chain_oracle, 60)); - client.set_oracle_chain(&issuer, &ns, &token, &entries); - client.set_fx_oracle( - &issuer, - &ns, - &token, - &legacy_oracle, - &Symbol::new(&env, "EUR"), - &Symbol::new(&env, "USD"), - &60, + let mut new_owners = Vec::new(&env); + for i in 0..owners.len() { + let owner = owners.get(i).unwrap(); + if owner != old_owner { + new_owners.push_back(owner); + } + } + env + .storage() + .persistent() + .set(&DataKey2::MultisigOwners, &new_owners); + let next_epoch = current_epoch + 1; + env + .storage() + .persistent() + .set(&DataKey2::MultisigEpoch, &next_epoch); + } + ProposalAction::SetProposalDuration(new_duration) => { + if new_duration == 0 { + return Err(RevoraError::InvalidAmount); + } + env + .storage() + .persistent() + .set(&DataKey2::MultisigProposalDuration, &new_duration); + env.events().publish( + (EVENT_DURATION_SET, proposal.proposer.clone()), + new_duration, ); - - // chain wins → 1_000 × 9_000 / 10_000 = 900 - client.report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 900); + } } - /// Exceeding `MAX_ORACLE_CHAIN_LEN` returns `LimitReached`. - #[test] - fn set_oracle_chain_too_many_entries_returns_limit_reached() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - let oracle = env.register_contract(None, FreshOracle); - - let mut entries = Vec::new(&env); - for _ in 0..=10u32 { - // 11 entries > MAX_ORACLE_CHAIN_LEN (10) - entries.push_back(make_entry(&env, &oracle, 60)); - } - - let result = client.try_set_oracle_chain(&issuer, &ns, &token, &entries); - assert_eq!(result, Err(Ok(RevoraError::LimitReached))); - } - - /// Unauthorized caller cannot set the oracle chain (auth guard fires). - #[test] - fn set_oracle_chain_requires_issuer_auth() { - let env = Env::default(); - env.ledger().with_mut(|l| l.timestamp = 1_000); - let id = env.register_contract(None, RevoraRevenueShare); - let client = RevoraRevenueShareClient::new(&env, &id); - let issuer = Address::generate(&env); - let ns = Symbol::new(&env, "ns"); - let token = Address::generate(&env); - let payout = Address::generate(&env); - - // Mock auth only for register_offering, not for set_oracle_chain - env.mock_all_auths(); - client.register_offering(&issuer, &ns, &token, &5_000, &payout, &0); - - // Now use a different (unauthorized) caller - let attacker = Address::generate(&env); - let oracle = env.register_contract(None, FreshOracle); - let mut entries = Vec::new(&env); - entries.push_back(make_entry(&env, &oracle, 60)); - - // Without mocked auth this should panic (require_auth fires) - let result = std::panic::catch_unwind(|| { - let env2 = Env::default(); - let id2 = env2.register_contract(None, RevoraRevenueShare); - let c2 = RevoraRevenueShareClient::new(&env2, &id2); - let issuer2 = Address::generate(&env2); - let ns2 = Symbol::new(&env2, "ns"); - let token2 = Address::generate(&env2); - let payout2 = Address::generate(&env2); - env2.mock_all_auths(); - c2.register_offering(&issuer2, &ns2, &token2, &5_000, &payout2, &0); - // Don't mock auth here — set_oracle_chain requires issuer to auth - let oracle2 = env2.register_contract(None, FreshOracle); - let mut e2 = Vec::new(&env2); - e2.push_back(make_entry(&env2, &oracle2, 60)); - // This call must panic because attacker is not issuer - let _ = attacker; - c2.set_oracle_chain(&issuer2, &ns2, &token2, &e2); - }); - // The call with proper issuer auth and mock_all_auths succeeds - assert!(result.is_ok(), "Expected call to succeed with mocked auth"); - } - - /// `get_oracle_chain` returns `None` for an offering with no chain set. - #[test] - fn get_oracle_chain_returns_none_when_not_set() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - assert!(client.get_oracle_chain(&issuer, &ns, &token).is_none()); - } - - /// Chain with `max_age_secs = 0` never rejects on age (disabled staleness check). - #[test] - fn chain_entry_zero_max_age_never_stale() { - let env = Env::default(); - let (client, issuer, ns, token, _payout) = setup(&env); - // Use StaleOracle (returns old timestamp), but max_age_secs=0 disables check. - let oracle = env.register_contract(None, StaleOracle); - let reported_asset = Address::generate(&env); - - let mut entries = Vec::new(&env); - entries.push_back(OracleEntry { - oracle: oracle.clone(), - revenue_symbol: Symbol::new(&env, "EUR"), - payout_symbol: Symbol::new(&env, "USD"), - max_age_secs: 0, // disabled → always fresh - }); - client.set_oracle_chain(&issuer, &ns, &token, &entries); + env + .events() + .publish((EVENT_PROPOSAL_EXECUTED, executor), proposal_id); + Ok(()) + } + + /// Check whether a proposal's total voted weight meets or exceeds its configured quorum. + /// + /// Returns `true` if the sum of `voter_weight_bps` for all approvals is >= `proposal.quorum_bps`. + /// Returns `false` (does not panic) when there are no approvals (empty votes treated as zero). + /// The proposal must exist; if not found, this will panic. + pub fn check_quorum_inner(env: &Env, proposal: &Proposal) -> bool { + if proposal.approvals.is_empty() { + return false; + } + let mut total_voted_bps: u32 = 0; + for i in 0..proposal.approvals.len() { + let voter = proposal.approvals.get(i).unwrap(); + let weight: u32 = env + .storage() + .persistent() + .get(&DataKey2::VoterWeight(voter)) + .unwrap_or(0); + total_voted_bps = total_voted_bps.saturating_add(weight); + } + total_voted_bps >= proposal.quorum_bps + } + + /// Read a proposal by id (internal helper). + pub fn get_proposal_inner(env: &Env, proposal_id: u32) -> Option { + env + .storage() + .persistent() + .get(&DataKey2::MultisigProposal(proposal_id)) + } + + /// Return the list of registered multisig owners. + pub fn get_multisig_owners(env: &Env) -> Option> { + env.storage().persistent().get(&DataKey2::MultisigOwners) + } + + /// Return the current multisig approval threshold. + pub fn get_multisig_threshold(env: &Env) -> Option { + env.storage().persistent().get(&DataKey2::MultisigThreshold) + } + + // ── Testnet faucet ──────────────────────────────────────────────────────── + + /// Allocate `count` deterministic holder seed slots for an offering. + /// + /// Each seed is derived as `sha256(issuer_xdr || namespace_xdr || token_xdr || idx_xdr)` + /// and can be treated as a raw 32-byte ed25519 public key by external test suites. + /// The equal BPS split (`10_000 / count`, remainder to last slot) is documented in + /// each emitted `fct_seed` event so test suites can pin share expectations. + /// + /// ### Metrics + /// Every successful call accumulates towards the hourly `fct_mtr1` summary event: + /// - `total_dispensed` incremented by `count`. + /// - `unique_addresses` incremented once per distinct requester per window. + /// - `fct_mtr1` is emitted **at the end of the call that first crosses a window + /// boundary** (i.e. when `window_id > last_emitted_window_id`). At most one + /// `fct_mtr1` event is emitted per window — subsequent calls in the same window + /// only update counters; they do **not** re-emit the event. + /// + /// ### Security + /// Panics (via `RevoraError::TestnetOnly`) when `testnet_mode == false`. + /// Must never be callable on mainnet. + /// + /// ### Parameters + /// - `issuer` / `namespace` / `token`: offering identity. + /// - `count`: number of deterministic seed slots to generate (0 returns empty vec). + /// + /// ### Returns + /// `Vec>` of per-slot seeds in index order. + pub fn faucet_seed_holders( + env: Env, + requester: Address, + issuer: Address, + namespace: Symbol, + token: Address, + count: u32, + ) -> Result>, RevoraError> { + if !Self::is_testnet_mode(env.clone()) { + return Err(RevoraError::TestnetOnly); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // StaleOracle returns 10_000 bps; 1_000 × 10_000 / 10_000 = 1_000 - client.report_revenue(&issuer, &ns, &token, &reported_asset, &1_000, &1, &false); - assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 1_000); + if !env + .storage() + .persistent() + .has(&DataKey2::OfferingRecord(offering_id.clone())) + { + return Err(RevoraError::OfferingNotFound); } -} -#[cfg(test)] -mod issue_370_373_tests { - use super::*; - use soroban_sdk::{testutils::Address as _, Address, Env, Symbol, Vec}; - - fn client() -> (Env, Address, RevoraRevenueShareClient<'static>) { - let env = Env::default(); - env.mock_all_auths(); - let id = env.register_contract(None, RevoraRevenueShare); - let client = RevoraRevenueShareClient::new(&env, &id); - (env, id, client) - } - - fn assert_bounds(result: i128, amount: i128) { - let lo = core::cmp::min(0_i128, amount); - let hi = core::cmp::max(0_i128, amount); - assert!( - result >= lo && result <= hi, - "result {result} out of bounds [{lo}, {hi}] for amount={amount}" + let now = env.ledger().timestamp(); + let last_request_ts: Option = env + .storage() + .persistent() + .get(&DataKey2::FaucetLastRequest(requester.clone())); + if let Some(last_ts) = last_request_ts { + if now.saturating_sub(last_ts) < DEFAULT_FAUCET_COOLDOWN_SECONDS { + env.events().publish( + ( + EVENT_FAUCET_COOLDOWN_REJECT, + requester.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (last_ts, now, DEFAULT_FAUCET_COOLDOWN_SECONDS), ); - } - - #[test] - fn issue_370_get_offerings_page_limit_cursor_and_order_are_stable() { - let (env, _contract_id, client) = client(); - let issuer = Address::generate(&env); - let namespace = Symbol::new(&env, "def"); - - let mut tokens = Vec::new(&env); - for i in 0..25_u32 { - let token = Address::generate(&env); - client.register_offering( - &issuer, - &namespace, - &token, - &(1_000 + i), - &token, - &0, - &symbol_short!(""), - &0, - ); - tokens.push_back(token); - } - - assert_eq!(client.get_offering_count(&issuer, &namespace), 25); - let (page_1, cursor_1) = client.get_offerings_page(&issuer, &namespace, &0, &10); - assert_eq!(page_1.len(), 10); - assert_eq!(cursor_1, Some(10)); - for i in 0..10 { - assert_eq!(page_1.get(i).unwrap().token, tokens.get(i).unwrap()); - } - - let (page_2, cursor_2) = client.get_offerings_page(&issuer, &namespace, &10, &10); - assert_eq!(page_2.len(), 10); - assert_eq!(cursor_2, Some(20)); - for i in 0..10 { - assert_eq!(page_2.get(i).unwrap().token, tokens.get(i + 10).unwrap()); - } - - let (page_3, cursor_3) = client.get_offerings_page(&issuer, &namespace, &20, &10); - assert_eq!(page_3.len(), 5); - assert_eq!(cursor_3, None); - for i in 0..5 { - assert_eq!(page_3.get(i).unwrap().token, tokens.get(i + 20).unwrap()); - } + // ── Metrics: count the cooldown reject ──────────────────────── + let rejects: u32 = env + .storage() + .persistent() + .get(&DataKey2::FaucetMetricsCooldownRejects) + .unwrap_or(0u32); + env.storage().persistent().set( + &DataKey2::FaucetMetricsCooldownRejects, + &rejects.saturating_add(1), + ); - let (page_clamped, cursor_clamped) = - client.get_offerings_page(&issuer, &namespace, &0, &100); - assert_eq!(page_clamped.len(), 20); - assert_eq!(cursor_clamped, Some(20)); - - let (empty_at_count, cursor_at_count) = - client.get_offerings_page(&issuer, &namespace, &25, &10); - assert_eq!(empty_at_count.len(), 0); - assert_eq!(cursor_at_count, None); - - let (empty_beyond, cursor_beyond) = - client.get_offerings_page(&issuer, &namespace, &99, &10); - assert_eq!(empty_beyond.len(), 0); - assert_eq!(cursor_beyond, None); - - let (page_limit_zero, cursor_limit_zero) = - client.get_offerings_page(&issuer, &namespace, &0, &0); - assert_eq!(page_limit_zero.len(), 20); - assert_eq!(cursor_limit_zero, Some(20)); - } - - #[test] - fn issue_370_get_offerings_page_stable_across_accept_issuer_transfer() { - let (env, contract_id, client) = client(); - let old_issuer = Address::generate(&env); - let new_issuer = Address::generate(&env); - let namespace = Symbol::new(&env, "def"); - - // Security: seed issuer registry so pending transfer lookup scans the old issuer. - env.as_contract(&contract_id, || { - env.storage().persistent().set(&DataKey2::IssuerCount, &1_u32); - env.storage().persistent().set(&DataKey2::IssuerItem(0), &old_issuer); - env.storage().persistent().set(&DataKey2::IssuerRegistered(old_issuer.clone()), &true); - env.storage().persistent().set(&DataKey2::NamespaceCount(old_issuer.clone()), &1_u32); - env.storage() - .persistent() - .set(&DataKey2::NamespaceItem(old_issuer.clone(), 0), &namespace); - env.storage() - .persistent() - .set(&DataKey2::NamespaceRegistered(old_issuer.clone(), namespace.clone()), &true); - }); + return Err(RevoraError::FaucetCooldownActive); + } + } + + env + .storage() + .persistent() + .set(&DataKey2::FaucetLastRequest(requester), &now); + + if count == 0 { + return Ok(Vec::new(&env)); + } + + // ── Metrics: count unique addresses ─────────────────────────────────── + let current_window_id = now / FAUCET_METRICS_WINDOW_SECS; + let addr_seen_key = + DataKey2::FaucetMetricsAddrSeen(current_window_id, requester.clone()); + if !env.storage().persistent().has(&addr_seen_key) { + env.storage().persistent().set(&addr_seen_key, &true); + let unique: u32 = env + .storage() + .persistent() + .get(&DataKey2::FaucetMetricsUniqueAddrs) + .unwrap_or(0u32); + env.storage().persistent().set( + &DataKey2::FaucetMetricsUniqueAddrs, + &unique.saturating_add(1), + ); + } + + // ── Metrics: accumulate dispensed count ─────────────────────────────── + let dispensed: u32 = env + .storage() + .persistent() + .get(&DataKey2::FaucetMetricsTotalDispensed) + .unwrap_or(0u32); + env.storage().persistent().set( + &DataKey2::FaucetMetricsTotalDispensed, + &dispensed.saturating_add(count), + ); + + // Build a per-offering prefix: sha256(issuer || namespace || token) + let mut prefix_input = Bytes::new(&env); + prefix_input.append(&issuer.to_xdr(&env)); + prefix_input.append(&namespace.to_xdr(&env)); + prefix_input.append(&token.to_xdr(&env)); + + let bps_floor: u32 = 10_000u32 / count; + let bps_remainder: u32 = 10_000u32 % count; + + let mut seeds: Vec> = Vec::new(&env); + + for idx in 0..count { + // 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).into(); + + let share_bps: u32 = if idx == count - 1 { + bps_floor + bps_remainder + } else { + bps_floor + }; + + // Store seed for test-suite retrieval without forcing a full scan. + env + .storage() + .persistent() + .set(&DataKey2::FaucetSeedEntry(offering_id.clone(), idx), &seed); + + env.events().publish( + ( + EVENT_FAUCET_SEED, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (idx, seed.clone(), share_bps), + ); + + seeds.push_back(seed); + } + + // Persist the highest slot count so faucet_reset can clear entries without + // an unbounded storage scan. We take the max in case this is a re-call with + // a smaller count. + let prev_count: u32 = env + .storage() + .persistent() + .get::(&DataKey2::FaucetSeedCount(offering_id.clone())) + .unwrap_or(0); + if count > prev_count { + env.storage().persistent().set::( + &DataKey2::FaucetSeedCount(offering_id.clone()), + &count, + ); + } + + Ok(seeds) + } + + /// Deterministically reset the faucet state for an offering (testnet only). + /// + /// `faucet_reset(seed)` clears all per-requester cooldown timestamps + /// (`FaucetLastRequest`) and all persisted seed entries (`FaucetSeedEntry`) + /// for the given offering, then emits a single `fct_rst` event carrying the + /// caller-supplied `seed` value so test suites can assert the exact reset. + /// + /// ### Security + /// - **Strictly testnet-only.** Returns `RevoraError::TestnetOnly` when + /// `testnet_mode == false`. Must never be callable on mainnet. + /// - Requires the offering to be registered; returns `OfferingNotFound` + /// otherwise. + /// - Requires admin authorisation (`admin.require_auth()`). + /// + /// ### Why admin-gated + /// Clearing cooldowns is a privileged operation: an unprivileged caller + /// could abuse it to bypass the faucet rate-limit. Tying it to the admin + /// key preserves the anti-spam invariant while still letting CI pipelines + /// reset state between test runs. + /// + /// ### Parameters + /// - `caller` — the admin address (must match the stored admin key). + /// - `issuer` / `namespace` / `token` — offering identity. + /// - `seed` — arbitrary 32-byte value chosen by the caller; carried + /// verbatim in the `fct_rst` event so test suites can anchor against it. + /// + /// ### State mutations + /// 1. Removes `FaucetLastRequest(requester)` for every address that + /// previously called `faucet_seed_holders` for the given offering. + /// Because Soroban does not expose iteration over storage, cooldowns are + /// cleared by removing the well-known per-offering cooldown sentinel key + /// `FaucetLastRequest(offering_payer_sentinel)` and all seed entries up + /// to the highest index stored for the offering. + /// + /// Concretely: the function removes `FaucetSeedEntry(offering_id, idx)` + /// for `idx` in `0..count` (where `count` is `PeriodCount`-like counter + /// stored in `FaucetSeedCount(offering_id)`), and resets the stored seed + /// count to 0 via `FaucetSeedCount`. + /// + /// 2. Emits `fct_rst` event. + /// + /// ### Returns + /// `Ok(())` on success; `Err(RevoraError)` on any validation failure. + pub fn faucet_reset( + env: Env, + caller: Address, + issuer: Address, + namespace: Symbol, + token: Address, + seed: BytesN<32>, + ) -> Result<(), RevoraError> { + // ── Testnet gate ────────────────────────────────────────────────────── + if !Self::is_testnet_mode(env.clone()) { + return Err(RevoraError::TestnetOnly); + } + + // ── Admin authorisation ─────────────────────────────────────────────── + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + + // ── Offering must exist ─────────────────────────────────────────────── + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + if !env + .storage() + .persistent() + .has(&DataKey2::OfferingRecord(offering_id.clone())) + { + return Err(RevoraError::OfferingNotFound); + } + + // ── Clear seed entries ──────────────────────────────────────────────── + // Remove every persisted FaucetSeedEntry for this offering. + // We track the highest slot index via FaucetSeedCount(offering_id) so we + // can iterate without unbounded storage scans. + let seed_count: u32 = env + .storage() + .persistent() + .get::(&DataKey2::FaucetSeedCount(offering_id.clone())) + .unwrap_or(0); + + for idx in 0..seed_count { + env + .storage() + .persistent() + .remove(&DataKey2::FaucetSeedEntry(offering_id.clone(), idx)); + } + + // Reset the seed count to 0 so future faucet_seed_holders calls start fresh. + env.storage().persistent().set::( + &DataKey2::FaucetSeedCount(offering_id.clone()), + &0, + ); + + // ── Emit reset event ────────────────────────────────────────────────── + env.events().publish( + ( + EVENT_FAUCET_RESET, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (caller, seed, seed_count), + ); - let new_token_0 = Address::generate(&env); - let new_token_1 = Address::generate(&env); - client.register_offering( - &new_issuer, - &namespace, - &new_token_0, - &1_100, - &new_token_0, - &0, - &symbol_short!(""), - &0, - ); - client.register_offering( - &new_issuer, - &namespace, - &new_token_1, - &1_200, - &new_token_1, - &0, - &symbol_short!(""), - &0, - ); + Ok(()) + } +} // end impl RevoraRevenueShare (plain) - let mut old_tokens = Vec::new(&env); - for i in 0..25_u32 { - let token = Address::generate(&env); - client.register_offering( - &old_issuer, - &namespace, - &token, - &(2_000 + i), - &token, - &0, - &symbol_short!(""), - &0, - ); - old_tokens.push_back(token); - } +#[cfg(test)] +mod issue_455_fx_oracle_tests { + use super::*; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, Env, Symbol, + }; + + pub mod fresh { + use super::*; + #[contract] + pub struct FreshFxOracleStub; - let transfer_token = old_tokens.get(7).unwrap(); - client.propose_issuer_transfer(&old_issuer, &namespace, &transfer_token, &new_issuer); - client.accept_issuer_transfer(&new_issuer, &namespace, &transfer_token); + #[contractimpl] + impl FreshFxOracleStub { + pub fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64) { + assert_eq!(from, Symbol::new(&env, "EUR")); + assert_eq!(to, Symbol::new(&env, "USDC")); + (12_000, env.ledger().timestamp()) + } + } + } + use fresh::FreshFxOracleStub; - assert_eq!(client.get_offering_count(&old_issuer, &namespace), 25); - let (old_page, old_cursor) = client.get_offerings_page(&old_issuer, &namespace, &0, &100); - assert_eq!(old_page.len(), 20); - assert_eq!(old_cursor, Some(20)); - for i in 0..20 { - assert_eq!(old_page.get(i).unwrap().token, old_tokens.get(i).unwrap()); - } + pub mod stale { + use super::*; + #[contract] + pub struct StaleFxOracleStub; - let (old_tail, old_tail_cursor) = - client.get_offerings_page(&old_issuer, &namespace, &20, &10); - assert_eq!(old_tail.len(), 5); - assert_eq!(old_tail_cursor, None); - - assert_eq!(client.get_offering_count(&new_issuer, &namespace), 3); - let (new_page_1, new_cursor_1) = client.get_offerings_page(&new_issuer, &namespace, &0, &2); - assert_eq!(new_page_1.len(), 2); - assert_eq!(new_cursor_1, Some(2)); - assert_eq!(new_page_1.get(0).unwrap().token, new_token_0); - assert_eq!(new_page_1.get(1).unwrap().token, new_token_1); - - let (new_page_2, new_cursor_2) = client.get_offerings_page(&new_issuer, &namespace, &2, &2); - assert_eq!(new_page_2.len(), 1); - assert_eq!(new_cursor_2, None); - assert_eq!(new_page_2.get(0).unwrap().token, transfer_token); - } - - #[test] - fn issue_373_compute_share_round_half_up_negative_midpoint_and_extremes() { - let (_env, _contract_id, client) = client(); - - assert_eq!(client.compute_share(&0, &5_000, &RoundingMode::RoundHalfUp), 0); - assert_eq!(client.compute_share(&123_456, &0, &RoundingMode::RoundHalfUp), 0); - assert_eq!(client.compute_share(&15_000, &5_000, &RoundingMode::RoundHalfUp), 7_500); - assert_eq!(client.compute_share(&-15_001, &5_000, &RoundingMode::Truncation), -7_500); - assert_eq!(client.compute_share(&-15_001, &5_000, &RoundingMode::RoundHalfUp), -7_501); - - for bps in [1_u32, 5_000, 9_999, 10_000, 10_001] { - let pos = client.compute_share(&i128::MAX, &bps, &RoundingMode::RoundHalfUp); - let neg = client.compute_share(&i128::MIN, &bps, &RoundingMode::RoundHalfUp); - assert_bounds(pos, i128::MAX); - assert_bounds(neg, i128::MIN); - if bps == 10_001 { - assert_eq!(pos, 0); - assert_eq!(neg, 0); - } - } + #[contractimpl] + impl StaleFxOracleStub { + pub fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64) { + assert_eq!(from, Symbol::new(&env, "EUR")); + assert_eq!(to, Symbol::new(&env, "USDC")); + (12_000, env.ledger().timestamp().saturating_sub(120)) + } + } + } + use stale::StaleFxOracleStub; + + fn setup() -> ( + Env, + RevoraRevenueShareClient<'static>, + Address, + Symbol, + Address, + Address, + ) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|ledger| ledger.timestamp = 1_000); + + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + let issuer = Address::generate(&env); + let namespace = Symbol::new(&env, "def"); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + + client.register_offering( + &issuer, + &namespace, + &token, + &5_000, + &payout_asset, + &0, + ); + (env, client, issuer, namespace, token, payout_asset) + } + + #[test] + fn report_revenue_converts_cross_currency_amount_with_registered_oracle() { + let (env, client, issuer, namespace, token, _payout_asset) = setup(); + let oracle = env.register_contract(None, FreshFxOracleStub); + let reported_asset = Address::generate(&env); + + client.set_fx_oracle( + &issuer, + &namespace, + &token, + &oracle, + &Symbol::new(&env, "EUR"), + &Symbol::new(&env, "USDC"), + &60, + ); + + client.report_revenue( + &issuer, + &namespace, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + + assert_eq!( + client.get_revenue_by_period(&issuer, &namespace, &token, &1), + 1_200 + ); + assert_eq!( + client + .get_audit_summary(&issuer, &namespace, &token) + .unwrap() + .total_revenue, + 1_200 + ); + } + + #[test] + fn stale_oracle_quote_rejects_report_without_state_change() { + let (env, client, issuer, namespace, token, _payout_asset) = setup(); + let oracle = env.register_contract(None, StaleFxOracleStub); + let reported_asset = Address::generate(&env); + + client.set_fx_oracle( + &issuer, + &namespace, + &token, + &oracle, + &Symbol::new(&env, "EUR"), + &Symbol::new(&env, "USDC"), + &60, + ); + + let result = client.try_report_revenue( + &issuer, + &namespace, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + + assert_eq!(result, Err(Ok(RevoraError::OracleQuoteStale))); + assert_eq!( + client.get_revenue_by_period(&issuer, &namespace, &token, &1), + 0 + ); + assert_eq!(client.get_audit_summary(&issuer, &namespace, &token), None); + } +} - assert_eq!( - client.compute_share(&i128::MAX, &10_000, &RoundingMode::RoundHalfUp), - i128::MAX - ); - assert_eq!( - client.compute_share(&i128::MIN, &10_000, &RoundingMode::RoundHalfUp), - i128::MIN - ); - } +// ───────────────────────────────────────────────────────────────────────────── +// Oracle fallback-chain tests (issue #547) +// ───────────────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod oracle_chain_tests { + use super::*; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, Env, Symbol, Vec, + }; + + // ── Stub contracts ──────────────────────────────────────────────────────── + + /// Oracle that always returns a fresh quote (rate = 1.2 × 10_000 = 12_000 bps, + /// timestamp = current ledger time). + #[contract] + pub struct FreshOracle; + #[contractimpl] + impl FreshOracle { + pub fn quote(env: Env, _from: Symbol, _to: Symbol) -> (i128, u64) { + (12_000, env.ledger().timestamp()) + } + } + + /// Oracle that always returns a stale quote (timestamp 200 s in the past). + #[contract] + pub struct StaleOracle; + #[contractimpl] + impl StaleOracle { + pub fn quote(env: Env, _from: Symbol, _to: Symbol) -> (i128, u64) { + (10_000, env.ledger().timestamp().saturating_sub(200)) + } + } + + /// Oracle that returns a different fresh rate (rate = 0.9 × 10_000 = 9_000 bps). + #[contract] + pub struct SecondaryFreshOracle; + #[contractimpl] + impl SecondaryFreshOracle { + pub fn quote(env: Env, _from: Symbol, _to: Symbol) -> (i128, u64) { + (9_000, env.ledger().timestamp()) + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn setup( + env: &Env, + ) -> ( + RevoraRevenueShareClient<'static>, + Address, + Symbol, + Address, + Address, + ) { + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(env, &id); + let issuer = Address::generate(env); + let ns = Symbol::new(env, "ns"); + let token = Address::generate(env); + let payout = Address::generate(env); + client.register_offering(&issuer, &ns, &token, &5_000, &payout, &0); + (client, issuer, ns, token, payout) + } + + fn make_entry(env: &Env, oracle: &Address, max_age: u64) -> OracleEntry { + OracleEntry { + oracle: oracle.clone(), + revenue_symbol: Symbol::new(env, "EUR"), + payout_symbol: Symbol::new(env, "USD"), + max_age_secs: max_age, + } + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /// Setting and reading an oracle chain round-trips correctly. + #[test] + fn set_and_get_oracle_chain_round_trips() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let oracle = env.register_contract(None, FreshOracle); + + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &oracle, 60)); + client.set_oracle_chain(&issuer, &ns, &token, &entries); + + let chain = client.get_oracle_chain(&issuer, &ns, &token).unwrap(); + assert_eq!(chain.entries.len(), 1); + assert_eq!(chain.entries.get(0).unwrap().oracle, oracle); + } + + /// When there is no chain and no single oracle, cross-currency report fails + /// with `PayoutAssetMismatch`. + #[test] + fn no_chain_no_single_oracle_returns_payout_asset_mismatch() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let other_asset = Address::generate(&env); + + let result = client.try_report_revenue( + &issuer, + &ns, + &token, + &other_asset, + &1_000, + &1, + &false, + ); + assert_eq!(result, Err(Ok(RevoraError::PayoutAssetMismatch))); + } + + /// First oracle fresh → chain resolves on the first entry; amount is converted. + #[test] + fn chain_first_oracle_fresh_uses_first_entry() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let fresh = env.register_contract(None, FreshOracle); + let reported_asset = Address::generate(&env); + + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &fresh, 60)); + client.set_oracle_chain(&issuer, &ns, &token, &entries); + + // rate = 12_000 bps → 1_000 × 12_000 / 10_000 = 1_200 + client.report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!( + client.get_revenue_by_period(&issuer, &ns, &token, &1), + 1_200 + ); + assert_eq!( + client + .get_audit_summary(&issuer, &ns, &token) + .unwrap() + .total_revenue, + 1_200 + ); + } + + /// First oracle stale, second fresh → fallback to second entry succeeds. + #[test] + fn chain_first_stale_second_fresh_falls_through() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let stale = env.register_contract(None, StaleOracle); + let fresh = env.register_contract(None, SecondaryFreshOracle); + let reported_asset = Address::generate(&env); + + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &stale, 60)); // stale + entries.push_back(make_entry(&env, &fresh, 60)); // fresh at 9_000 bps + client.set_oracle_chain(&issuer, &ns, &token, &entries); + + // rate = 9_000 bps → 1_000 × 9_000 / 10_000 = 900 + client.report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 900); + } + + /// All oracles stale → `AllOraclesStale` error, no state change. + #[test] + fn all_oracles_stale_returns_all_oracles_stale_error() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let s1 = env.register_contract(None, StaleOracle); + let s2 = env.register_contract(None, StaleOracle); + let reported_asset = Address::generate(&env); + + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &s1, 60)); + entries.push_back(make_entry(&env, &s2, 60)); + client.set_oracle_chain(&issuer, &ns, &token, &entries); + + let result = client.try_report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!(result, Err(Ok(RevoraError::AllOraclesStale))); + // No state written + assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 0); + assert_eq!(client.get_audit_summary(&issuer, &ns, &token), None); + } + + /// Only the last entry is fresh → chain walks through all stale ones and + /// resolves on the last. + #[test] + fn chain_only_last_entry_fresh() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let s1 = env.register_contract(None, StaleOracle); + let s2 = env.register_contract(None, StaleOracle); + let fresh = env.register_contract(None, FreshOracle); + let reported_asset = Address::generate(&env); + + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &s1, 60)); + entries.push_back(make_entry(&env, &s2, 60)); + entries.push_back(make_entry(&env, &fresh, 60)); // last, rate=12_000 bps + client.set_oracle_chain(&issuer, &ns, &token, &entries); + + // 1_000 × 12_000 / 10_000 = 1_200 + client.report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!( + client.get_revenue_by_period(&issuer, &ns, &token, &1), + 1_200 + ); + } + + /// Empty chain stores successfully; cross-currency report falls back to the + /// legacy single-oracle path (or fails with `PayoutAssetMismatch` when no + /// legacy oracle is set either). + #[test] + fn empty_chain_falls_back_to_single_oracle() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + + // Store empty chain + client.set_oracle_chain(&issuer, &ns, &token, &Vec::new(&env)); + + let fresh = env.register_contract(None, FreshOracle); + let reported_asset = Address::generate(&env); + + // Configure legacy single oracle + client.set_fx_oracle( + &issuer, + &ns, + &token, + &fresh, + &Symbol::new(&env, "EUR"), + &Symbol::new(&env, "USD"), + &60, + ); + + // Should succeed via legacy path, rate=12_000 bps → 1_200 + client.report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!( + client.get_revenue_by_period(&issuer, &ns, &token, &1), + 1_200 + ); + } + + /// Chain takes priority over legacy single-oracle config when both are set. + #[test] + fn chain_takes_priority_over_legacy_single_oracle() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let chain_oracle = env.register_contract(None, SecondaryFreshOracle); // 9_000 bps + let legacy_oracle = env.register_contract(None, FreshOracle); // 12_000 bps + let reported_asset = Address::generate(&env); + + // Set chain (9_000 bps) AND legacy single oracle (12_000 bps) + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &chain_oracle, 60)); + client.set_oracle_chain(&issuer, &ns, &token, &entries); + client.set_fx_oracle( + &issuer, + &ns, + &token, + &legacy_oracle, + &Symbol::new(&env, "EUR"), + &Symbol::new(&env, "USD"), + &60, + ); + + // chain wins → 1_000 × 9_000 / 10_000 = 900 + client.report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 900); + } + + /// Exceeding `MAX_ORACLE_CHAIN_LEN` returns `LimitReached`. + #[test] + fn set_oracle_chain_too_many_entries_returns_limit_reached() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + let oracle = env.register_contract(None, FreshOracle); + + let mut entries = Vec::new(&env); + for _ in 0..=10u32 { + // 11 entries > MAX_ORACLE_CHAIN_LEN (10) + entries.push_back(make_entry(&env, &oracle, 60)); + } + + let result = client.try_set_oracle_chain(&issuer, &ns, &token, &entries); + assert_eq!(result, Err(Ok(RevoraError::LimitReached))); + } + + /// Unauthorized caller cannot set the oracle chain (auth guard fires). + #[test] + fn set_oracle_chain_requires_issuer_auth() { + let env = Env::default(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let issuer = Address::generate(&env); + let ns = Symbol::new(&env, "ns"); + let token = Address::generate(&env); + let payout = Address::generate(&env); + + // Mock auth only for register_offering, not for set_oracle_chain + env.mock_all_auths(); + client.register_offering(&issuer, &ns, &token, &5_000, &payout, &0); + + // Now use a different (unauthorized) caller + let attacker = Address::generate(&env); + let oracle = env.register_contract(None, FreshOracle); + let mut entries = Vec::new(&env); + entries.push_back(make_entry(&env, &oracle, 60)); + + // Without mocked auth this should panic (require_auth fires) + let result = std::panic::catch_unwind(|| { + let env2 = Env::default(); + let id2 = env2.register_contract(None, RevoraRevenueShare); + let c2 = RevoraRevenueShareClient::new(&env2, &id2); + let issuer2 = Address::generate(&env2); + let ns2 = Symbol::new(&env2, "ns"); + let token2 = Address::generate(&env2); + let payout2 = Address::generate(&env2); + env2.mock_all_auths(); + c2.register_offering(&issuer2, &ns2, &token2, &5_000, &payout2, &0); + // Don't mock auth here — set_oracle_chain requires issuer to auth + let oracle2 = env2.register_contract(None, FreshOracle); + let mut e2 = Vec::new(&env2); + e2.push_back(make_entry(&env2, &oracle2, 60)); + // This call must panic because attacker is not issuer + let _ = attacker; + c2.set_oracle_chain(&issuer2, &ns2, &token2, &e2); + }); + // The call with proper issuer auth and mock_all_auths succeeds + assert!(result.is_ok(), "Expected call to succeed with mocked auth"); + } + + /// `get_oracle_chain` returns `None` for an offering with no chain set. + #[test] + fn get_oracle_chain_returns_none_when_not_set() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + assert!(client.get_oracle_chain(&issuer, &ns, &token).is_none()); + } + + /// Chain with `max_age_secs = 0` never rejects on age (disabled staleness check). + #[test] + fn chain_entry_zero_max_age_never_stale() { + let env = Env::default(); + let (client, issuer, ns, token, _payout) = setup(&env); + // Use StaleOracle (returns old timestamp), but max_age_secs=0 disables check. + let oracle = env.register_contract(None, StaleOracle); + let reported_asset = Address::generate(&env); + + let mut entries = Vec::new(&env); + entries.push_back(OracleEntry { + oracle: oracle.clone(), + revenue_symbol: Symbol::new(&env, "EUR"), + payout_symbol: Symbol::new(&env, "USD"), + max_age_secs: 0, // disabled → always fresh + }); + client.set_oracle_chain(&issuer, &ns, &token, &entries); + + // StaleOracle returns 10_000 bps; 1_000 × 10_000 / 10_000 = 1_000 + client.report_revenue( + &issuer, + &ns, + &token, + &reported_asset, + &1_000, + &1, + &false, + ); + assert_eq!( + client.get_revenue_by_period(&issuer, &ns, &token, &1), + 1_000 + ); + } +} - pub fn replace_deferred(env: soroban_sdk::Env, period_id: u32, new_amount: i128) { - if env.storage().persistent().has(&DeferredDataKey::DeferredReports(period_id)) { - env.storage() - .persistent() - .set(&DeferredDataKey::DeferredReports(period_id), &new_amount); - } +#[cfg(test)] +mod issue_370_373_tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env, Symbol, Vec}; + + fn client() -> (Env, Address, RevoraRevenueShareClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + (env, id, client) + } + + fn assert_bounds(result: i128, amount: i128) { + let lo = core::cmp::min(0_i128, amount); + let hi = core::cmp::max(0_i128, amount); + assert!( + result >= lo && result <= hi, + "result {result} out of bounds [{lo}, {hi}] for amount={amount}" + ); + } + + #[test] + fn issue_370_get_offerings_page_limit_cursor_and_order_are_stable() { + let (env, _contract_id, client) = client(); + let issuer = Address::generate(&env); + let namespace = Symbol::new(&env, "def"); + + let mut tokens = Vec::new(&env); + for i in 0..25_u32 { + let token = Address::generate(&env); + client.register_offering( + &issuer, + &namespace, + &token, + &(1_000 + i), + &token, + &0, + &symbol_short!(""), + &0, + ); + tokens.push_back(token); + } + + assert_eq!(client.get_offering_count(&issuer, &namespace), 25); + + let (page_1, cursor_1) = + client.get_offerings_page(&issuer, &namespace, &0, &10); + assert_eq!(page_1.len(), 10); + assert_eq!(cursor_1, Some(10)); + for i in 0..10 { + assert_eq!(page_1.get(i).unwrap().token, tokens.get(i).unwrap()); + } + + let (page_2, cursor_2) = + client.get_offerings_page(&issuer, &namespace, &10, &10); + assert_eq!(page_2.len(), 10); + assert_eq!(cursor_2, Some(20)); + for i in 0..10 { + assert_eq!(page_2.get(i).unwrap().token, tokens.get(i + 10).unwrap()); + } + + let (page_3, cursor_3) = + client.get_offerings_page(&issuer, &namespace, &20, &10); + assert_eq!(page_3.len(), 5); + assert_eq!(cursor_3, None); + for i in 0..5 { + assert_eq!(page_3.get(i).unwrap().token, tokens.get(i + 20).unwrap()); + } + + let (page_clamped, cursor_clamped) = + client.get_offerings_page(&issuer, &namespace, &0, &100); + assert_eq!(page_clamped.len(), 20); + assert_eq!(cursor_clamped, Some(20)); + + let (empty_at_count, cursor_at_count) = + client.get_offerings_page(&issuer, &namespace, &25, &10); + assert_eq!(empty_at_count.len(), 0); + assert_eq!(cursor_at_count, None); + + let (empty_beyond, cursor_beyond) = + client.get_offerings_page(&issuer, &namespace, &99, &10); + assert_eq!(empty_beyond.len(), 0); + assert_eq!(cursor_beyond, None); + + let (page_limit_zero, cursor_limit_zero) = + client.get_offerings_page(&issuer, &namespace, &0, &0); + assert_eq!(page_limit_zero.len(), 20); + assert_eq!(cursor_limit_zero, Some(20)); + } + + #[test] + fn issue_370_get_offerings_page_stable_across_accept_issuer_transfer() { + let (env, contract_id, client) = client(); + let old_issuer = Address::generate(&env); + let new_issuer = Address::generate(&env); + let namespace = Symbol::new(&env, "def"); + + // Security: seed issuer registry so pending transfer lookup scans the old issuer. + env.as_contract(&contract_id, || { + env + .storage() + .persistent() + .set(&DataKey2::IssuerCount, &1_u32); + env + .storage() + .persistent() + .set(&DataKey2::IssuerItem(0), &old_issuer); + env + .storage() + .persistent() + .set(&DataKey2::IssuerRegistered(old_issuer.clone()), &true); + env + .storage() + .persistent() + .set(&DataKey2::NamespaceCount(old_issuer.clone()), &1_u32); + env + .storage() + .persistent() + .set(&DataKey2::NamespaceItem(old_issuer.clone(), 0), &namespace); + env.storage().persistent().set( + &DataKey2::NamespaceRegistered(old_issuer.clone(), namespace.clone()), + &true, + ); + }); + + let new_token_0 = Address::generate(&env); + let new_token_1 = Address::generate(&env); + client.register_offering( + &new_issuer, + &namespace, + &new_token_0, + &1_100, + &new_token_0, + &0, + &symbol_short!(""), + &0, + ); + client.register_offering( + &new_issuer, + &namespace, + &new_token_1, + &1_200, + &new_token_1, + &0, + &symbol_short!(""), + &0, + ); + + let mut old_tokens = Vec::new(&env); + for i in 0..25_u32 { + let token = Address::generate(&env); + client.register_offering( + &old_issuer, + &namespace, + &token, + &(2_000 + i), + &token, + &0, + &symbol_short!(""), + &0, + ); + old_tokens.push_back(token); + } + + let transfer_token = old_tokens.get(7).unwrap(); + client.propose_issuer_transfer( + &old_issuer, + &namespace, + &transfer_token, + &new_issuer, + ); + client.accept_issuer_transfer(&new_issuer, &namespace, &transfer_token); + + assert_eq!(client.get_offering_count(&old_issuer, &namespace), 25); + let (old_page, old_cursor) = + client.get_offerings_page(&old_issuer, &namespace, &0, &100); + assert_eq!(old_page.len(), 20); + assert_eq!(old_cursor, Some(20)); + for i in 0..20 { + assert_eq!(old_page.get(i).unwrap().token, old_tokens.get(i).unwrap()); + } + + let (old_tail, old_tail_cursor) = + client.get_offerings_page(&old_issuer, &namespace, &20, &10); + assert_eq!(old_tail.len(), 5); + assert_eq!(old_tail_cursor, None); + + assert_eq!(client.get_offering_count(&new_issuer, &namespace), 3); + let (new_page_1, new_cursor_1) = + client.get_offerings_page(&new_issuer, &namespace, &0, &2); + assert_eq!(new_page_1.len(), 2); + assert_eq!(new_cursor_1, Some(2)); + assert_eq!(new_page_1.get(0).unwrap().token, new_token_0); + assert_eq!(new_page_1.get(1).unwrap().token, new_token_1); + + let (new_page_2, new_cursor_2) = + client.get_offerings_page(&new_issuer, &namespace, &2, &2); + assert_eq!(new_page_2.len(), 1); + assert_eq!(new_cursor_2, None); + assert_eq!(new_page_2.get(0).unwrap().token, transfer_token); + } + + #[test] + fn issue_373_compute_share_round_half_up_negative_midpoint_and_extremes() { + let (_env, _contract_id, client) = client(); + + assert_eq!( + client.compute_share(&0, &5_000, &RoundingMode::RoundHalfUp), + 0 + ); + assert_eq!( + client.compute_share(&123_456, &0, &RoundingMode::RoundHalfUp), + 0 + ); + assert_eq!( + client.compute_share(&15_000, &5_000, &RoundingMode::RoundHalfUp), + 7_500 + ); + assert_eq!( + client.compute_share(&-15_001, &5_000, &RoundingMode::Truncation), + -7_500 + ); + assert_eq!( + client.compute_share(&-15_001, &5_000, &RoundingMode::RoundHalfUp), + -7_501 + ); + + for bps in [1_u32, 5_000, 9_999, 10_000, 10_001] { + let pos = + client.compute_share(&i128::MAX, &bps, &RoundingMode::RoundHalfUp); + let neg = + client.compute_share(&i128::MIN, &bps, &RoundingMode::RoundHalfUp); + assert_bounds(pos, i128::MAX); + assert_bounds(neg, i128::MIN); + if bps == 10_001 { + assert_eq!(pos, 0); + assert_eq!(neg, 0); + } + } + + assert_eq!( + client.compute_share(&i128::MAX, &10_000, &RoundingMode::RoundHalfUp), + i128::MAX + ); + assert_eq!( + client.compute_share(&i128::MIN, &10_000, &RoundingMode::RoundHalfUp), + i128::MIN + ); + } + + pub fn replace_deferred( + env: soroban_sdk::Env, + period_id: u32, + new_amount: i128, + ) { + if env + .storage() + .persistent() + .has(&DeferredDataKey::DeferredReports(period_id)) + { + env + .storage() + .persistent() + .set(&DeferredDataKey::DeferredReports(period_id), &new_amount); } + } - pub fn close_period(env: soroban_sdk::Env, period_id: u32) { - let deferred_key = DeferredDataKey::DeferredReports(period_id); - if let Some(amount) = env.storage().persistent().get::<_, i128>(&deferred_key) { - env.storage().persistent().remove(&deferred_key); - env.events().publish((soroban_sdk::symbol_short!("def_flush"), period_id), amount); - } + pub fn close_period(env: soroban_sdk::Env, period_id: u32) { + let deferred_key = DeferredDataKey::DeferredReports(period_id); + if let Some(amount) = + env.storage().persistent().get::<_, i128>(&deferred_key) + { + env.storage().persistent().remove(&deferred_key); + env + .events() + .publish((soroban_sdk::symbol_short!("def_flush"), period_id), amount); } + } } // ── Deferred-Distribution Priority Queue (issue #551) ───────────────────── @@ -14980,139 +17533,159 @@ mod issue_370_373_tests { #[contractimpl] impl RevoraRevenueShare { - /// Enqueue a new entry into the deferred-distribution priority queue for an offering. - /// - /// The entry is inserted into the persistent queue in `(release_ts, priority, queue_id)` - /// sorted order. `queue_id` is assigned automatically as a monotonically-increasing - /// counter scoped to the offering, ensuring deterministic ordering even when - /// `release_ts` and `priority` are identical across entries. - /// - /// Emits `deferred_priority_set` with topic - /// `(EVENT_DEFERRED_PRIORITY_SET, issuer, namespace, token)` - /// and data `(queue_id, release_ts, priority, payload_id)`. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. - /// - /// ### Parameters - /// - `issuer`: Offering issuer (must be the current issuer of the offering). - /// - `namespace`: Offering namespace. - /// - `token`: Offering token. - /// - `release_ts`: Unix timestamp at or after which the entry may be released. - /// - `priority`: Issuer-assigned score; lower = higher priority. 0 is the highest. - /// - `payload_id`: Issuer-defined identifier for the associated period or batch - /// (e.g. `period_id`). Not interpreted by the contract. - /// - /// ### Returns - /// The `queue_id` assigned to the new entry. - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist or caller is not the current issuer. - /// - `ContractFrozen` / `ContractPaused` — contract is not operational. - pub fn enqueue_deferred( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - release_ts: u64, - priority: u32, - payload_id: u64, - ) -> Result { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Verify offering exists and caller is the current issuer. - let current_issuer = - Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { - return Err(RevoraError::OfferingNotFound); - } - - // Assign the next queue_id (per-offering monotonic counter stored inline as - // Vec length before insertion, making the counter free to derive). - let queue_key = DataKey2::DeferredQueue(offering_id.clone()); - - let mut entries: Vec = - env.storage().persistent().get(&queue_key).unwrap_or_else(|| Vec::new(&env)); - - let queue_id: u32 = entries.len(); - - let new_entry = DeferredQueueEntry { release_ts, priority, queue_id, payload_id }; - - // Insert in sorted position: (release_ts ASC, priority ASC, queue_id ASC). - // Because queue_id is strictly increasing and unique, the sort is always stable - // and deterministic. A linear scan is safe here since queue sizes are bounded - // in practice (bounded by the number of deferred periods per offering). - let insert_pos = Self::sorted_insert_position(&entries, &new_entry); - - // Build a new sorted Vec by copying elements around the insertion point. - let mut sorted: Vec = Vec::new(&env); - for i in 0..insert_pos { - sorted.push_back(entries.get(i).unwrap()); - } - sorted.push_back(new_entry); - for i in insert_pos..entries.len() { - sorted.push_back(entries.get(i).unwrap()); - } - - env.storage().persistent().set(&queue_key, &sorted); + /// Enqueue a new entry into the deferred-distribution priority queue for an offering. + /// + /// The entry is inserted into the persistent queue in `(release_ts, priority, queue_id)` + /// sorted order. `queue_id` is assigned automatically as a monotonically-increasing + /// counter scoped to the offering, ensuring deterministic ordering even when + /// `release_ts` and `priority` are identical across entries. + /// + /// Emits `deferred_priority_set` with topic + /// `(EVENT_DEFERRED_PRIORITY_SET, issuer, namespace, token)` + /// and data `(queue_id, release_ts, priority, payload_id)`. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. + /// + /// ### Parameters + /// - `issuer`: Offering issuer (must be the current issuer of the offering). + /// - `namespace`: Offering namespace. + /// - `token`: Offering token. + /// - `release_ts`: Unix timestamp at or after which the entry may be released. + /// - `priority`: Issuer-assigned score; lower = higher priority. 0 is the highest. + /// - `payload_id`: Issuer-defined identifier for the associated period or batch + /// (e.g. `period_id`). Not interpreted by the contract. + /// + /// ### Returns + /// The `queue_id` assigned to the new entry. + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist or caller is not the current issuer. + /// - `ContractFrozen` / `ContractPaused` — contract is not operational. + pub fn enqueue_deferred( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + release_ts: u64, + priority: u32, + payload_id: u64, + ) -> Result { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Emit deferred_priority_set event. - env.events().publish( - (EVENT_DEFERRED_PRIORITY_SET, issuer, namespace, token), - (queue_id, release_ts, priority, payload_id), - ); + // Verify offering exists and caller is the current issuer. + let current_issuer = Self::get_current_issuer( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + // Assign the next queue_id (per-offering monotonic counter stored inline as + // Vec length before insertion, making the counter free to derive). + let queue_key = DataKey2::DeferredQueue(offering_id.clone()); + + let mut entries: Vec = env + .storage() + .persistent() + .get(&queue_key) + .unwrap_or_else(|| Vec::new(&env)); + + let queue_id: u32 = entries.len(); + + let new_entry = DeferredQueueEntry { + release_ts, + priority, + queue_id, + payload_id, + }; - Ok(queue_id) - } - - /// Return the full deferred-distribution priority queue for an offering, in release order. - /// - /// Entries are sorted by `(release_ts ASC, priority ASC, queue_id ASC)`. - /// Returns an empty `Vec` if no entries have been enqueued. - /// - /// This is a **read-only** function; no auth required. - pub fn get_deferred_queue( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> Vec { - let offering_id = OfferingId { issuer, namespace, token }; - let queue_key = DataKey2::DeferredQueue(offering_id); - env.storage().persistent().get(&queue_key).unwrap_or_else(|| Vec::new(&env)) - } - - /// Compute the insertion index for `entry` into a sorted `entries` Vec. - /// - /// The sort key is `(release_ts ASC, priority ASC, queue_id ASC)`. - /// Returns the index at which `entry` should be inserted to maintain order. - fn sorted_insert_position( - entries: &Vec, - entry: &DeferredQueueEntry, - ) -> u32 { - let mut pos: u32 = 0; - for i in 0..entries.len() { - let existing = entries.get(i).unwrap(); - // Compare (release_ts, priority, queue_id) lexicographically ascending. - if (existing.release_ts, existing.priority, existing.queue_id) - <= (entry.release_ts, entry.priority, entry.queue_id) - { - pos = i + 1; - } else { - break; - } - } - pos - } + // Insert in sorted position: (release_ts ASC, priority ASC, queue_id ASC). + // Because queue_id is strictly increasing and unique, the sort is always stable + // and deterministic. A linear scan is safe here since queue sizes are bounded + // in practice (bounded by the number of deferred periods per offering). + let insert_pos = Self::sorted_insert_position(&entries, &new_entry); + + // Build a new sorted Vec by copying elements around the insertion point. + let mut sorted: Vec = Vec::new(&env); + for i in 0..insert_pos { + sorted.push_back(entries.get(i).unwrap()); + } + sorted.push_back(new_entry); + for i in insert_pos..entries.len() { + sorted.push_back(entries.get(i).unwrap()); + } + + env.storage().persistent().set(&queue_key, &sorted); + + // Emit deferred_priority_set event. + env.events().publish( + (EVENT_DEFERRED_PRIORITY_SET, issuer, namespace, token), + (queue_id, release_ts, priority, payload_id), + ); + + Ok(queue_id) + } + + /// Return the full deferred-distribution priority queue for an offering, in release order. + /// + /// Entries are sorted by `(release_ts ASC, priority ASC, queue_id ASC)`. + /// Returns an empty `Vec` if no entries have been enqueued. + /// + /// This is a **read-only** function; no auth required. + pub fn get_deferred_queue( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Vec { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + let queue_key = DataKey2::DeferredQueue(offering_id); + env + .storage() + .persistent() + .get(&queue_key) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Compute the insertion index for `entry` into a sorted `entries` Vec. + /// + /// The sort key is `(release_ts ASC, priority ASC, queue_id ASC)`. + /// Returns the index at which `entry` should be inserted to maintain order. + fn sorted_insert_position( + entries: &Vec, + entry: &DeferredQueueEntry, + ) -> u32 { + let mut pos: u32 = 0; + for i in 0..entries.len() { + let existing = entries.get(i).unwrap(); + // Compare (release_ts, priority, queue_id) lexicographically ascending. + if (existing.release_ts, existing.priority, existing.queue_id) + <= (entry.release_ts, entry.priority, entry.queue_id) + { + pos = i + 1; + } else { + break; + } + } + pos + } } // ── Snapshot-Based Governance Voting (issue #557) ───────────────────────── @@ -15133,284 +17706,333 @@ impl RevoraRevenueShare { #[contractimpl] impl RevoraRevenueShare { - /// Create a new governance proposal for an offering, pinning voting weight - /// to the latest committed snapshot. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. - /// - /// ### Parameters - /// - `issuer`: The offering issuer. - /// - `namespace`: Offering namespace. - /// - `token`: Offering token. - /// - `description`: Human-readable proposal text (max 9 chars due to `Symbol` limit). - /// - /// ### Returns - /// The new proposal id (`u32`). - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist. - /// - `ContractFrozen` — contract is frozen. - /// - `LimitReached` — no snapshot has been committed for this offering yet. - pub fn create_gov_proposal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - description: Symbol, - ) -> Result { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - // Authenticate and resolve offering. - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Pin the snapshot_id to the latest committed snapshot at creation time. - // Fail early if no snapshot has been committed — there is nothing to pin to. - let snapshot_id: u64 = env - .storage() - .persistent() - .get(&DataKey::LastSnapshotCommitRef(offering_id.clone())) - .ok_or(RevoraError::LimitReached)?; - - // Allocate a monotonically increasing proposal id. - let count_key = DataKey2::GovProposalCount(offering_id.clone()); - let proposal_id: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let created_at = env.ledger().timestamp(); - let proposal = GovProposalEntry { - id: proposal_id, - description: description.clone(), - snapshot_id, - created_at, - yes_weight: 0, - no_weight: 0, - open: true, - }; - - env.storage() - .persistent() - .set(&DataKey2::GovProposal(offering_id.clone(), proposal_id), &proposal); - env.storage().persistent().set(&count_key, &proposal_id.saturating_add(1)); - - // Emit creation event: topics = (gov_new, offering_id fields) - // data = (proposal_id, snapshot_id, created_at) - env.events().publish( - (EVENT_GOV_PROP_CREATED, issuer, namespace, token), - (proposal_id, snapshot_id, created_at), - ); - - Ok(proposal_id) - } - - /// Cast a vote on a governance proposal. - /// - /// The voter's weight is read from the snapshot that was pinned at proposal - /// creation, so shares acquired after `create_gov_proposal` carry zero weight. - /// A `wt_pin` diagnostic event is emitted with the resolved weight. - /// - /// ### Auth - /// Requires `voter.require_auth()`. - /// - /// ### Parameters - /// - `issuer` / `namespace` / `token`: Identify the offering. - /// - `proposal_id`: Id returned by `create_gov_proposal`. - /// - `voter`: The voting address. - /// - `approve`: `true` = yes, `false` = no. - /// - /// ### Returns - /// The voter's weight in basis points (`u32`). - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist. - /// - `LimitReached` — proposal does not exist or is already closed. - /// - `ContractFrozen` — contract is frozen. - /// - `AlreadyApproved` — voter has already voted on this proposal. - pub fn cast_vote( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - proposal_id: u32, - voter: Address, - approve: bool, - ) -> Result { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - // Authenticate voter. - voter.require_auth(); - - // Offering must exist. - let _ = Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; - - // Load proposal — fail if missing or closed. - let prop_key = DataKey2::GovProposal(offering_id.clone(), proposal_id); - let mut proposal: GovProposalEntry = - env.storage().persistent().get(&prop_key).ok_or(RevoraError::LimitReached)?; - if !proposal.open { - return Err(RevoraError::LimitReached); - } - - // Idempotency / double-vote guard. - let vote_key = DataKey2::VoteRecord(offering_id.clone(), proposal_id, voter.clone()); - if env.storage().persistent().has(&vote_key) { - return Err(RevoraError::AlreadyApproved); - } - - // O(1) vote-weight lookup from the pinned snapshot. - let weight: u32 = env - .storage() - .persistent() - .get(&DataKey::SnapshotHolderShare( - offering_id.clone(), - proposal.snapshot_id, - voter.clone(), - )) - .unwrap_or(0); - - // Accumulate weight. - if approve { - proposal.yes_weight = proposal.yes_weight.saturating_add(weight); - } else { - proposal.no_weight = proposal.no_weight.saturating_add(weight); - } - - // Persist updated proposal and vote record. - env.storage().persistent().set(&prop_key, &proposal); - env.storage().persistent().set(&vote_key, &approve); - - // Emit weight_pin diagnostic event so indexers can verify the resolved weight - // came from the pinned snapshot and not a later one. - env.events().publish( - (EVENT_WEIGHT_PIN, voter.clone()), - (proposal_id, proposal.snapshot_id, weight), - ); - - // Emit legacy vote cast event (gov_vote) for backward-compatible consumers. - env.events().publish( - (EVENT_GOV_VOTE_CAST, issuer.clone(), namespace.clone(), token.clone()), - (proposal_id, voter.clone(), approve, weight), - ); + /// Create a new governance proposal for an offering, pinning voting weight + /// to the latest committed snapshot. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. + /// + /// ### Parameters + /// - `issuer`: The offering issuer. + /// - `namespace`: Offering namespace. + /// - `token`: Offering token. + /// - `description`: Human-readable proposal text (max 9 chars due to `Symbol` limit). + /// + /// ### Returns + /// The new proposal id (`u32`). + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist. + /// - `ContractFrozen` — contract is frozen. + /// - `LimitReached` — no snapshot has been committed for this offering yet. + pub fn create_gov_proposal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + description: Symbol, + ) -> Result { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + // Authenticate and resolve offering. + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - // Emit stable vote_v3 indexed event for off-chain indexer reconstruction - // of governance state (#559). Both V2 and V3 topics are emitted concurrently - // so that V2-only subscribers are not broken during the deprecation window. - // - // Data payload: (proposal_id: u32, voter: Address, choice: VoteChoice, weight_bps: u32) - // The `VoteChoice` enum encodes `approve` as `Yes(1)` / `No(0)` so indexers - // can extend to additional choices without changing the wire layout. - let choice = if approve { VoteChoice::Yes } else { VoteChoice::No }; - Self::emit_v2_and_v3( - &env, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_VOTE_V3, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id: 0, - }, - EventIndexTopicV3 { - version: 3, - event_type: EVENT_TYPE_VOTE_V3, - issuer, - namespace, - token, - period_id: 0, - _reserved: 0, - }, - (proposal_id, voter, choice, weight), - ); + // Pin the snapshot_id to the latest committed snapshot at creation time. + // Fail early if no snapshot has been committed — there is nothing to pin to. + let snapshot_id: u64 = env + .storage() + .persistent() + .get(&DataKey::LastSnapshotCommitRef(offering_id.clone())) + .ok_or(RevoraError::LimitReached)?; + + // Allocate a monotonically increasing proposal id. + let count_key = DataKey2::GovProposalCount(offering_id.clone()); + let proposal_id: u32 = + env.storage().persistent().get(&count_key).unwrap_or(0); + + let created_at = env.ledger().timestamp(); + let proposal = GovProposalEntry { + id: proposal_id, + description: description.clone(), + snapshot_id, + created_at, + yes_weight: 0, + no_weight: 0, + open: true, + }; - Ok(weight) - } - - /// Return a governance proposal by id, or `None` if it does not exist. - pub fn get_gov_proposal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - proposal_id: u32, - ) -> Option { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::GovProposal(offering_id, proposal_id)) - } - - /// Return the total number of governance proposals created for an offering. - pub fn get_gov_proposal_count( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - ) -> u32 { - let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get(&DataKey2::GovProposalCount(offering_id)).unwrap_or(0) - } - - /// Close a governance proposal so no further votes can be cast. - /// - /// ### Auth - /// Requires `issuer.require_auth()`. - /// - /// ### Errors - /// - `OfferingNotFound` — offering does not exist or caller is not the issuer. - /// - `LimitReached` — proposal does not exist or is already closed. - /// - `ContractFrozen` — contract is frozen. - pub fn close_gov_proposal( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - proposal_id: u32, - ) -> Result<(), RevoraError> { - Self::require_not_frozen(&env)?; - Self::require_not_paused(&env)?; - - let offering = - Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; - if offering.issuers.primary != issuer { - return Err(RevoraError::OfferingNotFound); - } - issuer.require_auth(); + env.storage().persistent().set( + &DataKey2::GovProposal(offering_id.clone(), proposal_id), + &proposal, + ); + env + .storage() + .persistent() + .set(&count_key, &proposal_id.saturating_add(1)); + + // Emit creation event: topics = (gov_new, offering_id fields) + // data = (proposal_id, snapshot_id, created_at) + env.events().publish( + (EVENT_GOV_PROP_CREATED, issuer, namespace, token), + (proposal_id, snapshot_id, created_at), + ); + + Ok(proposal_id) + } + + /// Cast a vote on a governance proposal. + /// + /// The voter's weight is read from the snapshot that was pinned at proposal + /// creation, so shares acquired after `create_gov_proposal` carry zero weight. + /// A `wt_pin` diagnostic event is emitted with the resolved weight. + /// + /// ### Auth + /// Requires `voter.require_auth()`. + /// + /// ### Parameters + /// - `issuer` / `namespace` / `token`: Identify the offering. + /// - `proposal_id`: Id returned by `create_gov_proposal`. + /// - `voter`: The voting address. + /// - `approve`: `true` = yes, `false` = no. + /// + /// ### Returns + /// The voter's weight in basis points (`u32`). + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist. + /// - `LimitReached` — proposal does not exist or is already closed. + /// - `ContractFrozen` — contract is frozen. + /// - `AlreadyApproved` — voter has already voted on this proposal. + pub fn cast_vote( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + proposal_id: u32, + voter: Address, + approve: bool, + ) -> Result { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + // Authenticate voter. + voter.require_auth(); + + // Offering must exist. + let _ = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let offering_id = OfferingId { - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - }; + // Load proposal — fail if missing or closed. + let prop_key = DataKey2::GovProposal(offering_id.clone(), proposal_id); + let mut proposal: GovProposalEntry = env + .storage() + .persistent() + .get(&prop_key) + .ok_or(RevoraError::LimitReached)?; + if !proposal.open { + return Err(RevoraError::LimitReached); + } + + // Idempotency / double-vote guard. + let vote_key = + DataKey2::VoteRecord(offering_id.clone(), proposal_id, voter.clone()); + if env.storage().persistent().has(&vote_key) { + return Err(RevoraError::AlreadyApproved); + } + + // O(1) vote-weight lookup from the pinned snapshot. + let weight: u32 = env + .storage() + .persistent() + .get(&DataKey::SnapshotHolderShare( + offering_id.clone(), + proposal.snapshot_id, + voter.clone(), + )) + .unwrap_or(0); + + // Accumulate weight. + if approve { + proposal.yes_weight = proposal.yes_weight.saturating_add(weight); + } else { + proposal.no_weight = proposal.no_weight.saturating_add(weight); + } + + // Persist updated proposal and vote record. + env.storage().persistent().set(&prop_key, &proposal); + env.storage().persistent().set(&vote_key, &approve); + + // Emit weight_pin diagnostic event so indexers can verify the resolved weight + // came from the pinned snapshot and not a later one. + env.events().publish( + (EVENT_WEIGHT_PIN, voter.clone()), + (proposal_id, proposal.snapshot_id, weight), + ); + + // Emit legacy vote cast event (gov_vote) for backward-compatible consumers. + env.events().publish( + ( + EVENT_GOV_VOTE_CAST, + issuer.clone(), + namespace.clone(), + token.clone(), + ), + (proposal_id, voter.clone(), approve, weight), + ); + + // Emit stable vote_v3 indexed event for off-chain indexer reconstruction + // of governance state (#559). Both V2 and V3 topics are emitted concurrently + // so that V2-only subscribers are not broken during the deprecation window. + // + // Data payload: (proposal_id: u32, voter: Address, choice: VoteChoice, weight_bps: u32) + // The `VoteChoice` enum encodes `approve` as `Yes(1)` / `No(0)` so indexers + // can extend to additional choices without changing the wire layout. + let choice = if approve { + VoteChoice::Yes + } else { + VoteChoice::No + }; + Self::emit_v2_and_v3( + &env, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_VOTE_V3, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id: 0, + }, + EventIndexTopicV3 { + version: 3, + event_type: EVENT_TYPE_VOTE_V3, + issuer, + namespace, + token, + period_id: 0, + _reserved: 0, + }, + (proposal_id, voter, choice, weight), + ); + + Ok(weight) + } + + /// Return a governance proposal by id, or `None` if it does not exist. + pub fn get_gov_proposal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + proposal_id: u32, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::GovProposal(offering_id, proposal_id)) + } + + /// Return the total number of governance proposals created for an offering. + pub fn get_gov_proposal_count( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env + .storage() + .persistent() + .get(&DataKey2::GovProposalCount(offering_id)) + .unwrap_or(0) + } + + /// Close a governance proposal so no further votes can be cast. + /// + /// ### Auth + /// Requires `issuer.require_auth()`. + /// + /// ### Errors + /// - `OfferingNotFound` — offering does not exist or caller is not the issuer. + /// - `LimitReached` — proposal does not exist or is already closed. + /// - `ContractFrozen` — contract is frozen. + pub fn close_gov_proposal( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + proposal_id: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + + let offering = Self::get_offering( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; - let prop_key = DataKey2::GovProposal(offering_id, proposal_id); - let mut proposal: GovProposalEntry = - env.storage().persistent().get(&prop_key).ok_or(RevoraError::LimitReached)?; - if !proposal.open { - return Err(RevoraError::LimitReached); - } - proposal.open = false; - env.storage().persistent().set(&prop_key, &proposal); - Ok(()) - } + let prop_key = DataKey2::GovProposal(offering_id, proposal_id); + let mut proposal: GovProposalEntry = env + .storage() + .persistent() + .get(&prop_key) + .ok_or(RevoraError::LimitReached)?; + if !proposal.open { + return Err(RevoraError::LimitReached); + } + proposal.open = false; + env.storage().persistent().set(&prop_key, &proposal); + Ok(()) + } } // --- MIGRATION UPGRADE PATH (BOUNTY #467) --- @@ -15422,16 +18044,16 @@ impl RevoraRevenueShare { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum MigrationTransform { - /// Keep the stored value unchanged (no-op / identity transform). - /// Useful when only the key naming scheme changes but the value format stays. - Identity, - /// Rename the storage key — the value is kept as-is but stored under a new - /// key symbol (the inner `Symbol` argument). - Rename(Symbol), - /// Custom transform identified by a function selector symbol. - /// The contract dispatches to a known built-in transformation matching the - /// selector. Custom selectors are defined per-upgrade in the dispatch match. - Custom(Symbol), + /// Keep the stored value unchanged (no-op / identity transform). + /// Useful when only the key naming scheme changes but the value format stays. + Identity, + /// Rename the storage key — the value is kept as-is but stored under a new + /// key symbol (the inner `Symbol` argument). + Rename(Symbol), + /// Custom transform identified by a function selector symbol. + /// The contract dispatches to a known built-in transformation matching the + /// selector. Custom selectors are defined per-upgrade in the dispatch match. + Custom(Symbol), } /// A registered migration hook binding a legacy key to its transform. @@ -15441,644 +18063,748 @@ pub enum MigrationTransform { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MigrationHook { - /// The legacy storage key symbol this hook applies to. - pub legacy_key: Symbol, - /// The transform to apply when this key is encountered during migration. - pub transform: MigrationTransform, + /// The legacy storage key symbol this hook applies to. + pub legacy_key: Symbol, + /// The transform to apply when this key is encountered during migration. + pub transform: MigrationTransform, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MigrationCursor { - pub last_key: u32, + pub last_key: u32, } #[contracttype] pub enum MigrationDataKey { - LastMigrationCompletedAt(Address), - MigrationResumeCursor(Address), - MigrationHook(Symbol), - MigrationHookIndex(u32), - MigrationHookCount, + LastMigrationCompletedAt(Address), + MigrationResumeCursor(Address), + MigrationHook(Symbol), + MigrationHookIndex(u32), + MigrationHookCount, } #[contracterror(export = false)] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum MigrationError { - MigrationAlreadyApplied = 9001, - UnsupportedMigrationPath = 9002, + MigrationAlreadyApplied = 9001, + UnsupportedMigrationPath = 9002, } #[contractimpl] impl RevoraRevenueShare { - /// Register a per-key migration hook that transforms legacy storage during - /// a storage layout upgrade. - /// - /// Hooks let upgrade authors attach a custom transform (identity, rename, or - /// a built-in custom selector) to a specific legacy key. When the storage - /// walker runs for a matching version pair, it applies each registered hook - /// to the legacy key if data exists at that key. - /// - /// # Arguments - /// * `env` - Contract environment - /// * `admin` - Admin address (must match stored admin) - /// * `legacy_key` - The legacy storage key symbol to hook into - /// * `transform` - The transform to apply when this key is encountered - /// - /// # Errors - /// * `RevoraError::NotInitialized` if the contract has no admin - /// * `RevoraError::NotAuthorized` if the caller is not the admin - /// - /// # Security - /// Hooks are deterministic and pure by construction: the transform type - /// is a stored enum variant, not an arbitrary closure. This ensures replay - /// safety and auditability. - pub fn register_migration_hook( - env: Env, - admin: Address, - legacy_key: Symbol, - transform: MigrationTransform, - ) -> Result<(), RevoraError> { - admin.require_auth(); - - let stored_admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if admin != stored_admin { - return Err(RevoraError::NotAuthorized); - } - - let hook_key = MigrationDataKey::MigrationHook(legacy_key.clone()); - let exists = env.storage().persistent().has(&hook_key); + /// Register a per-key migration hook that transforms legacy storage during + /// a storage layout upgrade. + /// + /// Hooks let upgrade authors attach a custom transform (identity, rename, or + /// a built-in custom selector) to a specific legacy key. When the storage + /// walker runs for a matching version pair, it applies each registered hook + /// to the legacy key if data exists at that key. + /// + /// # Arguments + /// * `env` - Contract environment + /// * `admin` - Admin address (must match stored admin) + /// * `legacy_key` - The legacy storage key symbol to hook into + /// * `transform` - The transform to apply when this key is encountered + /// + /// # Errors + /// * `RevoraError::NotInitialized` if the contract has no admin + /// * `RevoraError::NotAuthorized` if the caller is not the admin + /// + /// # Security + /// Hooks are deterministic and pure by construction: the transform type + /// is a stored enum variant, not an arbitrary closure. This ensures replay + /// safety and auditability. + pub fn register_migration_hook( + env: Env, + admin: Address, + legacy_key: Symbol, + transform: MigrationTransform, + ) -> Result<(), RevoraError> { + admin.require_auth(); + + let stored_admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if admin != stored_admin { + return Err(RevoraError::NotAuthorized); + } + + let hook_key = MigrationDataKey::MigrationHook(legacy_key.clone()); + let exists = env.storage().persistent().has(&hook_key); + + if !exists { + // New hook: increment the counter and store the key in the index. + let count_key = MigrationDataKey::MigrationHookCount; + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + env.storage().persistent().set(&count_key, &(count + 1)); + env + .storage() + .persistent() + .set(&MigrationDataKey::MigrationHookIndex(count), &legacy_key); + } + + // Store the transform (overwrites if already exists) + env.storage().persistent().set(&hook_key, &transform); + + env.events().publish( + (EVENT_MIG_HOOK_APPLIED, symbol_short!("register")), + (legacy_key, transform), + ); - if !exists { - // New hook: increment the counter and store the key in the index. - let count_key = MigrationDataKey::MigrationHookCount; - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - env.storage().persistent().set(&count_key, &(count + 1)); - env.storage() - .persistent() - .set(&MigrationDataKey::MigrationHookIndex(count), &legacy_key); - } + Ok(()) + } + + /// Remove a previously registered migration hook. + /// + /// # Arguments + /// * `env` - Contract environment + /// * `admin` - Admin address (must match stored admin) + /// * `legacy_key` - The legacy key to unregister + /// + /// # Errors + /// * `RevoraError::NotInitialized` if the contract has no admin + /// * `RevoraError::NotAuthorized` if the caller is not the admin + /// + /// # Idempotency + /// If no hook is registered for the given `legacy_key`, the call silently + /// succeeds (no-op). This makes the API safe to call multiple times. + pub fn clear_migration_hook( + env: Env, + admin: Address, + legacy_key: Symbol, + ) -> Result<(), RevoraError> { + admin.require_auth(); + + // Verify caller is the contract admin + let stored_admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + if admin != stored_admin { + return Err(RevoraError::NotAuthorized); + } + + let hook_key = MigrationDataKey::MigrationHook(legacy_key.clone()); + if !env.storage().persistent().has(&hook_key) { + // Idempotent: no hook to clear, silently succeed. + return Ok(()); + } + + env.storage().persistent().remove(&hook_key); + + env + .events() + .publish((EVENT_MIG_HOOK_APPLIED, symbol_short!("clear")), legacy_key); - // Store the transform (overwrites if already exists) - env.storage().persistent().set(&hook_key, &transform); - - env.events() - .publish((EVENT_MIG_HOOK_APPLIED, symbol_short!("register")), (legacy_key, transform)); - - Ok(()) - } - - /// Remove a previously registered migration hook. - /// - /// # Arguments - /// * `env` - Contract environment - /// * `admin` - Admin address (must match stored admin) - /// * `legacy_key` - The legacy key to unregister - /// - /// # Errors - /// * `RevoraError::NotInitialized` if the contract has no admin - /// * `RevoraError::NotAuthorized` if the caller is not the admin - /// - /// # Idempotency - /// If no hook is registered for the given `legacy_key`, the call silently - /// succeeds (no-op). This makes the API safe to call multiple times. - pub fn clear_migration_hook( - env: Env, - admin: Address, - legacy_key: Symbol, - ) -> Result<(), RevoraError> { - admin.require_auth(); - - // Verify caller is the contract admin - let stored_admin: Address = - env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; - if admin != stored_admin { - return Err(RevoraError::NotAuthorized); - } + Ok(()) + } + + /// Return all currently registered migration hooks as a vector. + /// + /// Useful for inspection, dry-run planning, and testing. + /// Returns an empty Vec if no hooks are registered. + pub fn get_registered_hooks(env: Env) -> Vec { + let count_key = MigrationDataKey::MigrationHookCount; + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + + let mut hooks: Vec = Vec::new(&env); + for i in 0..count { + if let Some(key) = + env.storage().persistent().get::( + &MigrationDataKey::MigrationHookIndex(i), + ) + { + if let Some(transform) = env + .storage() + .persistent() + .get::( + &MigrationDataKey::MigrationHook(key.clone()), + ) + { + hooks.push_back(MigrationHook { + legacy_key: key, + transform, + }); + } + } + } + hooks + } + + /// Internal helper: apply a single migration hook for a matching legacy key. + /// Reads the legacy value, applies the transform, writes the result, and + /// emits a `migration_hook_applied` event. + /// + /// In dry-run mode, only emits a plan event without mutating storage. + fn apply_migration_hook( + env: &Env, + issuer: &Address, + hook: &MigrationHook, + dry_run: bool, + ) { + if dry_run { + env.events().publish( + ( + soroban_sdk::Symbol::new(env, "migration_plan"), + symbol_short!("hook"), + ), + (hook.legacy_key.clone(), hook.transform.clone()), + ); + } else { + // Emit a per-hook application event for audit trail. + // The actual data transform is invoked via the transform type; + // concrete per-key read/write logic is added per-upgrade in the + // migration dispatch table below. + env.events().publish( + (EVENT_MIG_HOOK_APPLIED, hook.legacy_key.clone()), + hook.transform.clone(), + ); + } + } + + // ── Merkle proof verification ──────────────────────────────────────────── + + /// Verify a Merkle membership proof against a known root. + /// + /// This is a **read-only**, **auth-free** entrypoint that lets off-chain clients + /// confirm that a specific leaf is a member of the Merkle tree with the given + /// root hash without performing any state mutations. + /// + /// ## Parameters + /// + /// * `leaf_hash` — SHA-256 hash of the leaf being proven (use + /// `SHA-256(0x00 || holder_xdr || share_bps_xdr)` to match the on-chain leaf + /// construction from [`crate::merkle_helpers`]). + /// * `root` — The expected Merkle root (e.g. from a committed and finalized snapshot). + /// * `proof` — Ordered vector of sibling hashes, one per tree level, bottom-up. + /// + /// ## Depth bound — `MAX_PROOF_DEPTH = 32` + /// + /// The `proof` vector **must not exceed `MAX_PROOF_DEPTH` (32)** siblings. + /// A standard binary Merkle tree over 2³² leaves has depth 32, making this + /// sufficient for any realistic snapshot while preventing gas and memory + /// exhaustion from adversarially crafted deep proofs. + /// + /// If `proof.len() > MAX_PROOF_DEPTH` the function: + /// 1. Emits a `proof_reject_depth` event carrying `(proof_len, MAX_PROOF_DEPTH)` + /// so off-chain indexers can detect and alert on oversized-proof attempts. + /// 2. Returns `Err(RevoraError::ProofTooDeep)` without performing any hashing. + /// + /// ## Returns + /// + /// * `Ok(true)` — proof is valid; the leaf belongs to the tree. + /// * `Ok(false)` — proof is structurally valid but the computed path does not + /// reach the given root (leaf is not a member, or root/proof is wrong). + /// * `Err(RevoraError::ProofTooDeep)` — `proof.len() > MAX_PROOF_DEPTH`. + /// + /// ## Security notes + /// + /// * No storage reads or writes; no token transfers. + /// * No auth required — the function is purely computational. + /// * The depth check executes **before** any SHA-256 calls, so an adversary + /// cannot force expensive hashing by submitting an oversized proof. + pub fn verify_merkle_proof( + env: Env, + caller: Address, + leaf_hash: BytesN<32>, + root: BytesN<32>, + proof: Vec>, + ) -> Result { + use crate::merkle_helpers::{ + verify_merkle_proof as merkle_verify_proof, MAX_PROOF_DEPTH, + }; - let hook_key = MigrationDataKey::MigrationHook(legacy_key.clone()); - if !env.storage().persistent().has(&hook_key) { - // Idempotent: no hook to clear, silently succeed. - return Ok(()); - } + // Depth-bound check with event emission on failure. + // This mirrors the check inside `merkle_verify_proof` but also emits the + // structured event required by the contract API contract. + if proof.len() > MAX_PROOF_DEPTH { + env.events().publish( + (EVENT_PROOF_REJECT_DEPTH, caller), + (proof.len(), MAX_PROOF_DEPTH), + ); + return Err(RevoraError::ProofTooDeep); + } + + merkle_verify_proof(&env, leaf_hash, root, &proof) + .map_err(|_e| RevoraError::ProofTooDeep) + } + + /// Execute the storage walker migration from `from_version` to `to_version`. + /// + /// The walker supports a dry-run mode (`dry_run = true`) that emits plan + /// events without mutating storage. When `dry_run = false`, it runs the + /// version-specific migration dispatch and then applies all registered + /// per-key migration hooks. + /// + /// # Hooks integration + /// After the version-specific dispatch table runs, the walker iterates over + /// all registered hooks via `get_registered_hooks()` and applies each one + /// deterministically. Each hook application emits a `migration_hook_applied` + /// event for audit trail completeness. + /// + /// # Replay protection + /// The completed version is persisted to `MigrationDataKey::LastMigrationCompletedAt(issuer)` + /// to prevent replay of the same migration. + pub fn migrate_storage_walker( + env: Env, + issuer: Address, + from_version: u32, + to_version: u32, + dry_run: bool, + ) -> Result<(), MigrationError> { + // Must be gated by the issuer initiating the migration + issuer.require_auth(); + + let key = MigrationDataKey::LastMigrationCompletedAt(issuer.clone()); + let last_migration: u32 = env.storage().persistent().get(&key).unwrap_or(0); + + // Replay protection: if they are already at or past the target version, fail. + if last_migration >= to_version { + return Err(MigrationError::MigrationAlreadyApplied); + } + + let cursor_key = MigrationDataKey::MigrationResumeCursor(issuer.clone()); + let mut cursor: MigrationCursor = env + .storage() + .persistent() + .get(&cursor_key) + .unwrap_or(MigrationCursor { last_key: 0 }); + + if cursor.last_key > 0 && !dry_run { + env.events().publish( + (symbol_short!("mig_rsme"), from_version, to_version), + cursor.last_key, + ); + } + + // Add per-version migrators in a dispatch table + match (from_version, to_version) { + (1, 2) => { + // Explicit storage walker simulation for v1 -> v2. + let total_keys = 10u32; // Simulated total keys to process - env.storage().persistent().remove(&hook_key); + if dry_run { + env.events().publish( + ( + soroban_sdk::Symbol::new(&env, "migration_plan"), + from_version, + to_version, + ), + issuer.clone(), + ); + } else { + for i in 1..=total_keys { + if i <= cursor.last_key { + continue; // Skip already-processed keys on resume + } - env.events().publish((EVENT_MIG_HOOK_APPLIED, symbol_short!("clear")), legacy_key); + // Simulate key migration work here + env.events().publish( + (symbol_short!("mig_step"), from_version, to_version), + i, + ); - Ok(()) + // Persist cursor atomically with each processed key + cursor.last_key = i; + env.storage().persistent().set(&cursor_key, &cursor); + } + } + } + _ => return Err(MigrationError::UnsupportedMigrationPath), } - /// Return all currently registered migration hooks as a vector. - /// - /// Useful for inspection, dry-run planning, and testing. - /// Returns an empty Vec if no hooks are registered. - pub fn get_registered_hooks(env: Env) -> Vec { - let count_key = MigrationDataKey::MigrationHookCount; - let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + // Apply all registered per-key migration hooks + let hooks = Self::get_registered_hooks(env.clone()); + for i in 0..hooks.len() { + if let Some(hook) = hooks.get(i) { + Self::apply_migration_hook(&env, &issuer, &hook, dry_run); + } + } - let mut hooks: Vec = Vec::new(&env); - for i in 0..count { - if let Some(key) = env - .storage() - .persistent() - .get::(&MigrationDataKey::MigrationHookIndex(i)) - { - if let Some(transform) = - env.storage().persistent().get::( - &MigrationDataKey::MigrationHook(key.clone()), - ) - { - hooks.push_back(MigrationHook { legacy_key: key, transform }); - } - } - } - hooks + if !dry_run { + // Persist the completed state to block replays and clear the cursor + env.storage().persistent().set(&key, &to_version); + env.storage().persistent().remove(&cursor_key); } + Ok(()) + } +} - /// Internal helper: apply a single migration hook for a matching legacy key. - /// Reads the legacy value, applies the transform, writes the result, and - /// emits a `migration_hook_applied` event. - /// - /// In dry-run mode, only emits a plan event without mutating storage. - fn apply_migration_hook(env: &Env, issuer: &Address, hook: &MigrationHook, dry_run: bool) { - if dry_run { +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) + } + + +// Updated mint function with saturation event +pub fn mint(env: Env, to: Address, amount: i128) -> Result<(), RevoraError> { + to.require_auth(); + + let mut state: State = env.storage().instance().get(&"state").unwrap(); + + // Check supply cap + if state.supply_cap > 0 { + let new_total = state.total_issued.checked_add(amount) + .ok_or(RevoraError::InvalidAmount)?; + + // Reject if would exceed cap + if new_total > state.supply_cap { + return Err(RevoraError::SupplyCapExceeded); + } + + // Store previous total for event check + let previous_total = state.total_issued; + + // Update state + state.total_issued = new_total; + env.storage().instance().set(&"state", &state); + + // Emit saturation event if exactly at boundary and wasn't already saturated + if new_total == state.supply_cap && previous_total < state.supply_cap { env.events().publish( - (soroban_sdk::Symbol::new(env, "migration_plan"), symbol_short!("hook")), - (hook.legacy_key.clone(), hook.transform.clone()), + (EVENT_SUPPLY_CAP_SATURATED,), + (state.total_issued, state.supply_cap) ); - } else { - // Emit a per-hook application event for audit trail. - // The actual data transform is invoked via the transform type; - // concrete per-key read/write logic is added per-upgrade in the - // migration dispatch table below. - env.events() - .publish((EVENT_MIG_HOOK_APPLIED, hook.legacy_key.clone()), hook.transform.clone()); } + } else { + // Supply cap disabled (0 = unlimited) + state.total_issued = state.total_issued.checked_add(amount) + .ok_or(RevoraError::InvalidAmount)?; + env.storage().instance().set(&"state", &state); } + + Ok(()) +} - // ── Merkle proof verification ──────────────────────────────────────────── - - /// Verify a Merkle membership proof against a known root. - /// - /// This is a **read-only**, **auth-free** entrypoint that lets off-chain clients - /// confirm that a specific leaf is a member of the Merkle tree with the given - /// root hash without performing any state mutations. - /// - /// ## Parameters - /// - /// * `leaf_hash` — SHA-256 hash of the leaf being proven (use - /// `SHA-256(0x00 || holder_xdr || share_bps_xdr)` to match the on-chain leaf - /// construction from [`crate::merkle_helpers`]). - /// * `root` — The expected Merkle root (e.g. from a committed and finalized snapshot). - /// * `proof` — Ordered vector of sibling hashes, one per tree level, bottom-up. - /// - /// ## Depth bound — `MAX_PROOF_DEPTH = 32` - /// - /// The `proof` vector **must not exceed `MAX_PROOF_DEPTH` (32)** siblings. - /// A standard binary Merkle tree over 2³² leaves has depth 32, making this - /// sufficient for any realistic snapshot while preventing gas and memory - /// exhaustion from adversarially crafted deep proofs. - /// - /// If `proof.len() > MAX_PROOF_DEPTH` the function: - /// 1. Emits a `proof_reject_depth` event carrying `(proof_len, MAX_PROOF_DEPTH)` - /// so off-chain indexers can detect and alert on oversized-proof attempts. - /// 2. Returns `Err(RevoraError::ProofTooDeep)` without performing any hashing. - /// - /// ## Returns - /// - /// * `Ok(true)` — proof is valid; the leaf belongs to the tree. - /// * `Ok(false)` — proof is structurally valid but the computed path does not - /// reach the given root (leaf is not a member, or root/proof is wrong). - /// * `Err(RevoraError::ProofTooDeep)` — `proof.len() > MAX_PROOF_DEPTH`. - /// - /// ## Security notes - /// - /// * No storage reads or writes; no token transfers. - /// * No auth required — the function is purely computational. - /// * The depth check executes **before** any SHA-256 calls, so an adversary - /// cannot force expensive hashing by submitting an oversized proof. - pub fn verify_merkle_proof( - env: Env, - caller: Address, - leaf_hash: BytesN<32>, - root: BytesN<32>, - proof: Vec>, - ) -> Result { - use crate::merkle_helpers::{verify_merkle_proof as merkle_verify_proof, MAX_PROOF_DEPTH}; - - // Depth-bound check with event emission on failure. - // This mirrors the check inside `merkle_verify_proof` but also emits the - // structured event required by the contract API contract. - if proof.len() > MAX_PROOF_DEPTH { - env.events() - .publish((EVENT_PROOF_REJECT_DEPTH, caller), (proof.len(), MAX_PROOF_DEPTH)); - return Err(RevoraError::ProofTooDeep); - } - - merkle_verify_proof(&env, leaf_hash, root, &proof).map_err(|_e| RevoraError::ProofTooDeep) - } - - /// Execute the storage walker migration from `from_version` to `to_version`. - /// - /// The walker supports a dry-run mode (`dry_run = true`) that emits plan - /// events without mutating storage. When `dry_run = false`, it runs the - /// version-specific migration dispatch and then applies all registered - /// per-key migration hooks. - /// - /// # Hooks integration - /// After the version-specific dispatch table runs, the walker iterates over - /// all registered hooks via `get_registered_hooks()` and applies each one - /// deterministically. Each hook application emits a `migration_hook_applied` - /// event for audit trail completeness. - /// - /// # Replay protection - /// The completed version is persisted to `MigrationDataKey::LastMigrationCompletedAt(issuer)` - /// to prevent replay of the same migration. - pub fn migrate_storage_walker( - env: Env, - issuer: Address, - from_version: u32, - to_version: u32, - dry_run: bool, - ) -> Result<(), MigrationError> { - // Must be gated by the issuer initiating the migration - issuer.require_auth(); - - let key = MigrationDataKey::LastMigrationCompletedAt(issuer.clone()); - let last_migration: u32 = env.storage().persistent().get(&key).unwrap_or(0); - - // Replay protection: if they are already at or past the target version, fail. - if last_migration >= to_version { - return Err(MigrationError::MigrationAlreadyApplied); - } - - let cursor_key = MigrationDataKey::MigrationResumeCursor(issuer.clone()); - let mut cursor: MigrationCursor = - env.storage().persistent().get(&cursor_key).unwrap_or(MigrationCursor { last_key: 0 }); - - if cursor.last_key > 0 && !dry_run { - env.events() - .publish((symbol_short!("mig_rsme"), from_version, to_version), cursor.last_key); - } - - // Add per-version migrators in a dispatch table - match (from_version, to_version) { - (1, 2) => { - // Explicit storage walker simulation for v1 -> v2. - let total_keys = 10u32; // Simulated total keys to process - - if dry_run { - env.events().publish( - ( - soroban_sdk::Symbol::new(&env, "migration_plan"), - from_version, - to_version, - ), - issuer.clone(), - ); - } else { - for i in 1..=total_keys { - if i <= cursor.last_key { - continue; // Skip already-processed keys on resume - } - - // Simulate key migration work here - env.events() - .publish((symbol_short!("mig_step"), from_version, to_version), i); - - // Persist cursor atomically with each processed key - cursor.last_key = i; - env.storage().persistent().set(&cursor_key, &cursor); - } - } - } - _ => return Err(MigrationError::UnsupportedMigrationPath), - } - - // Apply all registered per-key migration hooks - let hooks = Self::get_registered_hooks(env.clone()); - for i in 0..hooks.len() { - if let Some(hook) = hooks.get(i) { - Self::apply_migration_hook(&env, &issuer, &hook, dry_run); - } - } +// Optional: Add a function to get the current saturation state +pub fn is_supply_cap_saturated(env: Env, issuer: Address, namespace: Symbol, token: Address) -> bool { + let state: State = env.storage().instance().get(&"state").unwrap(); + state.supply_cap > 0 && state.total_issued >= state.supply_cap +} - if !dry_run { - // Persist the completed state to block replays and clear the cursor - env.storage().persistent().set(&key, &to_version); - env.storage().persistent().remove(&cursor_key); - } - Ok(()) +// Optional: Add a function to get the remaining supply before cap +pub fn get_remaining_supply(env: Env, issuer: Address, namespace: Symbol, token: Address) -> i128 { + let state: State = env.storage().instance().get(&"state").unwrap(); + if state.supply_cap == 0 { + i128::MAX // Unlimited + } else { + state.supply_cap.saturating_sub(state.total_issued) } } -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) - } +// Updated State struct to include metadata +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct State { + pub total_issued: i128, + pub supply_cap: i128, + pub metadata: TokenMetadata, +} } #[cfg(test)] diff --git a/src/test_compute_share_invariants.rs b/src/test_compute_share_invariants.rs index dc57d99e..b2cf4f9d 100644 --- a/src/test_compute_share_invariants.rs +++ b/src/test_compute_share_invariants.rs @@ -1312,8 +1312,385 @@ fn issue_610_zero_vs_max_error_code_verification() { &3, ); assert!(r_b.is_err(), "cap=i128::MAX at capacity: deposit must fail"); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SUPPLY CAP SATURATION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod supply_cap_saturation_tests { + use super::*; + use soroban_sdk::{Env, String, Address, testutils::Address as _}; + use crate::{TokenContract, TokenContractClient, State, TokenMetadata}; + + fn create_test_metadata(env: &Env) -> TokenMetadata { + TokenMetadata { + name: String::from_str(env, "Test Token"), + symbol: String::from_str(env, "TST"), + decimals: 32, + } + } + + fn setup_contract(env: &Env, supply_cap: i128) -> (TokenContractClient, Address) { + let contract_id = env.register_contract(None, TokenContract); + let client = TokenContractClient::new(env, &contract_id); + let admin = Address::generate(env); + + let metadata = create_test_metadata(env); + client.initialize(&metadata, &supply_cap); + + (client, admin) + } + + #[test] + fn test_supply_cap_saturation_event_exact_boundary() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1000); + + // Mint 500 tokens (below cap) + client.mint(&admin, &500); + + // Mint another 500 tokens (exactly reaches cap) + client.mint(&admin, &500); + + // Verify event was emitted + let events = env.events().all(); + assert_eq!(events.len(), 1); + + let event = &events[0]; + assert_eq!(event.0.type_, soroban_sdk::EventType::Contract); + assert_eq!(event.1.to_string(), "cap_sat"); + + // Verify the event data (new total supply) + let event_data: i128 = event.2.clone().try_into().unwrap(); + assert_eq!(event_data, 1000); + + // Verify state + let state: State = client.get_state(); + assert_eq!(state.total_issued, 1000); + assert_eq!(state.supply_cap, 1000); + } + + #[test] + fn test_supply_cap_saturation_below_boundary() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1000); + + // Mint 999 tokens (below cap) + client.mint(&admin, &999); + + // Verify no event was emitted + let events = env.events().all(); + assert_eq!(events.len(), 0); + + // Verify state + let state: State = client.get_state(); + assert_eq!(state.total_issued, 999); + assert_eq!(state.supply_cap, 1000); + } + + #[test] + fn test_supply_cap_saturation_multiple_mints_exact() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1000); + + // Mint in small increments + for i in 0..10 { + client.mint(&admin, &100); + let state: State = client.get_state(); + + // Event should only fire on the last mint (10th iteration) + let events = env.events().all(); + if i == 9 { + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, 1000); + } else { + assert_eq!(events.len(), 0); + } + } + } + + #[test] + fn test_supply_cap_saturation_exceeds_cap_panics() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1000); + + // Mint 1000 tokens (exact cap) + client.mint(&admin, &1000); + + // Try to mint 1 more token (should panic) + let result = std::panic::catch_unwind(|| { + client.mint(&admin, &1); + }); + assert!(result.is_err()); + + // Verify state unchanged + let state: State = client.get_state(); + assert_eq!(state.total_issued, 1000); + assert_eq!(state.supply_cap, 1000); + + // Only one event should have been emitted + let events = env.events().all(); + assert_eq!(events.len(), 1); + } + + #[test] + fn test_supply_cap_disabled_zero_never_saturated() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 0); + + // Mint arbitrary large amount + client.mint(&admin, &1000000); + client.mint(&admin, &2000000); + client.mint(&admin, &3000000); + + // Verify no events emitted + let events = env.events().all(); + assert_eq!(events.len(), 0); + + // Verify state + let state: State = client.get_state(); + assert_eq!(state.total_issued, 6000000); + assert_eq!(state.supply_cap, 0); + } + + #[test] + fn test_supply_cap_saturation_regression_scenarios() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 100); + + // Scenario 1: Mint exactly to cap + client.mint(&admin, &100); + let events = env.events().all(); + assert_eq!(events.len(), 1); + + // Scenario 2: Reset contract + let (client2, admin2) = setup_contract(&env, 100); + + // Scenario 3: Mint below cap + client2.mint(&admin2, &50); + let events2 = env.events().all(); + assert_eq!(events2.len(), 0); + + // Scenario 4: Mint to exactly cap from below + client2.mint(&admin2, &50); + let events3 = env.events().all(); + assert_eq!(events3.len(), 1); + } + + #[test] + fn test_supply_cap_saturation_edge_cases() { + // Test with supply_cap = 1 + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1); + + // Mint 1 token + client.mint(&admin, &1); + + let events = env.events().all(); + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, 1); + + // Reset and test with supply_cap = 0 + let (client2, admin2) = setup_contract(&env, 0); + client2.mint(&admin2, &100); + let events2 = env.events().all(); + assert_eq!(events2.len(), 0); + } + + #[test] + fn test_supply_cap_saturation_large_numbers() { + let env = Env::default(); + let supply_cap = 10_000_000_000_000_000_000i128; + let (client, admin) = setup_contract(&env, supply_cap); + + // Mint exactly to cap + client.mint(&admin, &supply_cap); + + let events = env.events().all(); + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, supply_cap); + } + + #[test] + fn test_supply_cap_saturation_no_duplicate_events() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 100); + + // Mint to cap + client.mint(&admin, &100); + + // Try to mint more (should fail) + let _ = std::panic::catch_unwind(|| { + client.mint(&admin, &0); + }); + + // Verify only one event + let events = env.events().all(); + assert_eq!(events.len(), 1); + } + + #[test] + fn test_supply_cap_saturation_multiple_contracts() { + let env = Env::default(); + + // Create two independent contracts + let (client1, admin1) = setup_contract(&env, 100); + let (client2, admin2) = setup_contract(&env, 200); + + // Mint exactly to cap for contract 1 + client1.mint(&admin1, &100); + + // Mint below cap for contract 2 + client2.mint(&admin2, &150); + + // Check events for contract 1 + let events1 = env.events().all(); + assert_eq!(events1.len(), 1); + let event_data: i128 = events1[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, 100); + + // Mint exactly to cap for contract 2 + client2.mint(&admin2, &50); + + // Check events for contract 2 + let events2 = env.events().all(); + assert_eq!(events2.len(), 2); + let event_data2: i128 = events2[1].2.clone().try_into().unwrap(); + assert_eq!(event_data2, 200); + } + + #[test] + fn test_supply_cap_saturation_boundary_behavior() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1000); + + // Test at exactly cap - 1 + client.mint(&admin, &999); + let state: State = client.get_state(); + assert_eq!(state.total_issued, 999); + + // No event yet + let events = env.events().all(); + assert_eq!(events.len(), 0); + + // Mint 1 token to exactly reach cap + client.mint(&admin, &1); + + // Event should be emitted + let events = env.events().all(); + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, 1000); + + let state: State = client.get_state(); + assert_eq!(state.total_issued, 1000); + } - // If the error is accessible, verify it's SupplyCapExceeded (error code 23) - // In Soroban tests, errors are typically wrapped; this verifies the failure occurs + #[test] + fn test_supply_cap_saturation_overflow_protection() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, i128::MAX); + + // Mint to near max + client.mint(&admin, &(i128::MAX - 1000)); + + // No event yet + let events = env.events().all(); + assert_eq!(events.len(), 0); + + // Mint to exactly max + client.mint(&admin, &1000); + + // Event should be emitted + let events = env.events().all(); + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, i128::MAX); + } + + #[test] + fn test_supply_cap_saturation_with_fractional_amounts() { + let env = Env::default(); + let (client, admin) = setup_contract(&env, 1000); + + // Mint fractional amounts that sum to cap + client.mint(&admin, &333); + client.mint(&admin, &333); + client.mint(&admin, &334); + + // Should emit event on last mint + let events = env.events().all(); + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, 1000); + + let state: State = client.get_state(); + assert_eq!(state.total_issued, 1000); + } + + #[test] + fn test_supply_cap_saturation_after_decimal_normalization() { + let env = Env::default(); + let supply_cap = 1000; + let (client, admin) = setup_contract(&env, supply_cap); + + // Mint with different decimal precision + client.mint(&admin, &100); // 10% + client.mint(&admin, &200); // 20% + client.mint(&admin, &700); // 70% - reaches cap + + // Should emit event on the mint that reaches cap + let events = env.events().all(); + assert_eq!(events.len(), 1); + let event_data: i128 = events[0].2.clone().try_into().unwrap(); + assert_eq!(event_data, 1000); + + let state: State = client.get_state(); + assert_eq!(state.total_issued, 1000); + } +} + +// Additional property-based test for supply cap saturation invariants +#[test] +fn property_supply_cap_saturation_invariants() { + let env = Env::default(); + + // Test invariant: sum of individual mints equals final total + let test_cases = vec![ + (100, vec![50, 50]), + (1000, vec![100, 200, 300, 400]), + (10000, vec![2500, 2500, 2500, 2500]), + (999, vec![333, 333, 333]), + (1, vec![1]), + ]; + + for (cap, amounts) in test_cases { + let (client, admin) = setup_contract(&env, cap); + let mut total = 0; + let mut events_count = 0; + + for amount in amounts { + client.mint(&admin, &amount); + total += amount; + + let state: State = client.get_state(); + assert_eq!(state.total_issued, total); + + let events = env.events().all(); + if total == cap { + events_count += 1; + } + } + + // Verify final state + let state: State = client.get_state(); + assert_eq!(state.total_issued, cap); + assert!(state.total_issued <= state.supply_cap); } }