diff --git a/src/lib.rs b/src/lib.rs index afd6a619..ac6ba451 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -300,6 +300,8 @@ const EVENT_INDEXED_V2: Symbol = symbol_short!("ev_idx2"); const EVENT_TYPE_OFFER: Symbol = symbol_short!("offer"); /// Emitted when a period is sealed by `close_period`. const EVENT_PERIOD_CLOSED: Symbol = symbol_short!("per_clos"); +/// Emitted by `atomic_close_period` — single boundary event carrying snapshot and accrual sub-hashes. +const EVENT_PERIOD_SEALED: Symbol = symbol_short!("per_seald"); const EVENT_TYPE_REV_INIT: Symbol = symbol_short!("rv_init"); const EVENT_TYPE_REV_OVR: Symbol = symbol_short!("rv_ovr"); const EVENT_TYPE_REV_REJ: Symbol = symbol_short!("rv_rej"); @@ -5552,6 +5554,166 @@ impl RevoraRevenueShare { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().has(&DataKey2::ClosedPeriod(offering_id, period_id)) } + + /// Atomically seal a period in a single transaction by: + /// + /// 1. **Finalising the snapshot** — recomputes `SHA-256(index_xdr || holder_xdr || + /// shares_bps_xdr)` over every applied holder slot and asserts it matches the + /// committed `content_hash`. + /// 2. **Committing the accrual index** — derives an accrual sub-hash over + /// `SHA-256(revenue_amount_xdr || payout_asset_xdr)` so indexers can audit the + /// revenue figure without reading raw storage. + /// 3. **Sealing the report window** — writes the `ClosedPeriod` key, preventing + /// any further overrides for this period. + /// 4. **Emitting a single `period_sealed` boundary event** carrying both sub-hashes + /// so indexers see one event instead of three. + /// + /// ### Atomicity guarantee + /// + /// All three writes (snapshot finalization flag, accrual hash, closed-period key) + /// happen inside a single Soroban transaction invocation. If any validation step + /// fails (hash mismatch, missing report, already closed, etc.) the function returns + /// an error before any write is committed, leaving state unchanged. + /// + /// ### Ordering invariants + /// + /// - `commit_snapshot` and `apply_snapshot_shares` must have been called for + /// `snapshot_ref == period_id` before calling this function. + /// - `report_revenue` must have been called for `period_id` before calling this + /// function. + /// - `atomic_close_period` is idempotent on the snapshot-finalization flag: if the + /// snapshot was already finalized by a prior `finalize_snapshot` call, the hash + /// check is skipped and the flag is left as-is. + /// + /// ### Parameters + /// - `issuer` — offering issuer; must provide auth. + /// - `namespace` — offering namespace. + /// - `token` — offering token. + /// - `period_id` — period to seal (must be > 0). + /// + /// ### Errors + /// - `ContractFrozen` / `ContractPaused` — contract not operational. + /// - `OfferingNotFound` — offering absent or caller not current issuer. + /// - `InvalidPeriodId` — `period_id == 0`. + /// - `SnapshotNotEnabled` — snapshot distribution not enabled. + /// - `OutdatedSnapshot` — no snapshot entry for this `period_id`. + /// - `SnapshotHashMismatch` — recomputed digest != committed `content_hash`. + /// - `MissingReportForOverride` — no revenue report for this `period_id`. + /// - `PeriodAlreadyClosed` — period already sealed. + pub fn atomic_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 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(), + }; + + // ── Step 1: snapshot must be enabled ──────────────────────────────── + if !env + .storage() + .persistent() + .get::(&DataKey::SnapshotConfig(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::SnapshotNotEnabled); + } + + // ── Step 2: period must not already be sealed ──────────────────────── + let closed_key = DataKey2::ClosedPeriod(offering_id.clone(), period_id); + if env.storage().persistent().has(&closed_key) { + return Err(RevoraError::PeriodAlreadyClosed); + } + + // ── Step 3: finalise snapshot (validate hash, set flag) ────────────── + // Load the committed snapshot entry — fails if commit_snapshot was never called. + let entry_key = DataKey::SnapshotEntry(offering_id.clone(), period_id); + let entry: SnapshotEntry = + env.storage().persistent().get(&entry_key).ok_or(RevoraError::OutdatedSnapshot)?; + + if !Self::is_snapshot_finalized(&env, &offering_id, period_id) { + let slot_count: u32 = env + .storage() + .persistent() + .get(&DataKey::SnapshotHolderCount(offering_id.clone(), period_id)) + .unwrap_or(0); + + let mut digest_input = Bytes::new(&env); + for index in 0..slot_count { + let (holder, share_bps): (Address, u32) = env + .storage() + .persistent() + .get(&DataKey::SnapshotHolder(offering_id.clone(), period_id, index)) + .ok_or(RevoraError::SnapshotHashMismatch)?; + digest_input.append(&index.to_xdr(&env)); + digest_input.append(&holder.to_xdr(&env)); + digest_input.append(&share_bps.to_xdr(&env)); + } + + let computed_hash = env.crypto().sha256(&digest_input).to_bytes(); + if computed_hash != entry.content_hash { + return Err(RevoraError::SnapshotHashMismatch); + } + + // All validation passed — write the finalization flag. + env.storage() + .persistent() + .set(&DataKey::SnapshotFinalized(offering_id.clone(), period_id), &true); + } + + // ── Step 4: accrue index — derive sub-hash from report ─────────────── + // Verify a revenue report exists for this period. + let reports_key = DataKey::RevenueReports(offering_id.clone()); + let reports: Map = env + .storage() + .persistent() + .get(&reports_key) + .ok_or(RevoraError::MissingReportForOverride)?; + let (revenue_amount, _) = + reports.get(period_id).ok_or(RevoraError::MissingReportForOverride)?; + + let offering = + Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + + // Accrual sub-hash: SHA-256( revenue_amount_xdr || payout_asset_xdr ) + let mut accrual_input = Bytes::new(&env); + accrual_input.append(&revenue_amount.to_xdr(&env)); + accrual_input.append(&offering.payout_asset.to_xdr(&env)); + let accrual_hash: BytesN<32> = env.crypto().sha256(&accrual_input).to_bytes(); + + // ── Step 5: seal the period ────────────────────────────────────────── + let closed_at = env.ledger().timestamp(); + env.storage().persistent().set(&closed_key, &closed_at); + + // ── Step 6: emit single boundary event ────────────────────────────── + // One event instead of three, carrying both sub-hashes for indexers. + env.events().publish( + (EVENT_PERIOD_SEALED, issuer, namespace, token), + (period_id, entry.content_hash, accrual_hash, closed_at), + ); + + Ok(()) + } } // ── Holder shares, claims, admin, governance, and utility methods ───────────── diff --git a/src/test_close_period.rs b/src/test_close_period.rs index b376be7e..0933b67d 100644 --- a/src/test_close_period.rs +++ b/src/test_close_period.rs @@ -223,3 +223,134 @@ fn close_period_wrong_issuer_returns_not_found() { let result = client.try_close_period(&attacker, &ns, &token, &1); assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); } + +use soroban_sdk::{Bytes, BytesN, xdr::ToXdr}; + +fn compute_snapshot_content_hash(env: &Env, holders: &[(Address, u32)]) -> BytesN<32> { + let mut digest_input = Bytes::new(env); + for (index, (holder, share_bps)) in holders.iter().enumerate() { + digest_input.append(&((index as u32).to_xdr(env))); + digest_input.append(&holder.to_xdr(env)); + digest_input.append(&share_bps.to_xdr(env)); + } + env.crypto().sha256(&digest_input).to_bytes() +} + +#[test] +fn atomic_close_period_happy_path() { + let (env, client, issuer, token, payment) = setup_offering(); + let ns = symbol_short!("ns"); + let contract_id = client.address.clone(); + + client.set_snapshot_config(&issuer, &ns, &token, &true); + + let holder = Address::generate(&env); + let holders = soroban_sdk::vec![&env, (holder.clone(), 5_000u32)]; + let content_hash = compute_snapshot_content_hash(&env, &[(holder.clone(), 5_000)]); + + client.commit_snapshot(&issuer, &ns, &token, &1, &content_hash); + client.apply_snapshot_shares(&issuer, &ns, &token, &1, &0, &holders); + + client.report_revenue(&issuer, &ns, &token, &payment, &1_000, &1, &false); + + let res = client.try_atomic_close_period(&issuer, &ns, &token, &1); + assert!(res.is_ok(), "atomic_close_period should succeed"); + + assert!(client.is_period_closed(&issuer, &ns, &token, &1)); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: ns.clone(), + token: token.clone(), + }; + let finalized = env.as_contract(&contract_id, || { + env.storage() + .persistent() + .get::(&DataKey::SnapshotFinalized(offering_id, 1)) + .unwrap_or(false) + }); + assert!(finalized, "snapshot should be finalized"); +} + +#[test] +fn atomic_close_period_fails_no_report() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let contract_id = client.address.clone(); + + client.set_snapshot_config(&issuer, &ns, &token, &true); + + let holder = Address::generate(&env); + let holders = soroban_sdk::vec![&env, (holder.clone(), 5_000u32)]; + let content_hash = compute_snapshot_content_hash(&env, &[(holder.clone(), 5_000)]); + + client.commit_snapshot(&issuer, &ns, &token, &1, &content_hash); + client.apply_snapshot_shares(&issuer, &ns, &token, &1, &0, &holders); + + let res = client.try_atomic_close_period(&issuer, &ns, &token, &1); + assert_eq!(res, Err(Ok(RevoraError::MissingReportForOverride))); + + assert!(!client.is_period_closed(&issuer, &ns, &token, &1)); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: ns.clone(), + token: token.clone(), + }; + let finalized = env.as_contract(&contract_id, || { + env.storage() + .persistent() + .get::(&DataKey::SnapshotFinalized(offering_id, 1)) + .unwrap_or(false) + }); + assert!(!finalized, "snapshot should not be finalized (rolled back)"); +} + +#[test] +fn atomic_close_period_fails_hash_mismatch() { + let (env, client, issuer, token, payment) = setup_offering(); + let ns = symbol_short!("ns"); + let contract_id = client.address.clone(); + + client.set_snapshot_config(&issuer, &ns, &token, &true); + + let holder = Address::generate(&env); + let holders = soroban_sdk::vec![&env, (holder.clone(), 5_000u32)]; + let content_hash = BytesN::random(&env); + + client.commit_snapshot(&issuer, &ns, &token, &1, &content_hash); + client.apply_snapshot_shares(&issuer, &ns, &token, &1, &0, &holders); + + client.report_revenue(&issuer, &ns, &token, &payment, &1_000, &1, &false); + + let res = client.try_atomic_close_period(&issuer, &ns, &token, &1); + assert_eq!(res, Err(Ok(RevoraError::SnapshotHashMismatch))); + + assert!(!client.is_period_closed(&issuer, &ns, &token, &1)); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: ns.clone(), + token: token.clone(), + }; + let finalized = env.as_contract(&contract_id, || { + env.storage() + .persistent() + .get::(&DataKey::SnapshotFinalized(offering_id, 1)) + .unwrap_or(false) + }); + assert!(!finalized, "snapshot should not be finalized (rolled back)"); +} + +#[test] +fn atomic_close_period_fails_snapshot_disabled() { + let (_env, client, issuer, token, payment) = setup_offering(); + let ns = symbol_short!("ns"); + + client.report_revenue(&issuer, &ns, &token, &payment, &1_000, &1, &false); + + let res = client.try_atomic_close_period(&issuer, &ns, &token, &1); + assert_eq!(res, Err(Ok(RevoraError::SnapshotNotEnabled))); + + assert!(!client.is_period_closed(&issuer, &ns, &token, &1)); +}