From fb4572a7054749713bf4e1e46e40a24c936dde35 Mon Sep 17 00:00:00 2001 From: shogun444 Date: Mon, 1 Jun 2026 22:54:15 +0530 Subject: [PATCH 1/2] feat: verify snapshot content_hash on finalize --- README.md | 2 + src/lib.rs | 190 ++++++++++++++++++++++++++++-- src/test_snapshot_finalization.rs | 102 ++++++++++++++++ 3 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 src/test_snapshot_finalization.rs diff --git a/README.md b/README.md index d831d635..ded436a6 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ Accepted ranges and rejection semantics: | `period_id` | `report_revenue` | `> 0` and `last + 1` (for new reports) | `InvalidPeriodId` | | `min_amount` | `set_min_revenue_threshold` | ≥ 0 | `InvalidAmount` | +- `snapshot content_hash`: SHA-256 digest over the on-chain snapshot rows in ascending slot order. Each row is encoded in XDR as the tuple `(slot_index: u32, holder: Address, share_bps: u32)`. + Use `try_*` client methods to receive these errors as `Result`. Consolidated invalid-amount regression coverage lives in `src/invalid_amount_matrix_tests.rs`; the checklist is in `docs/negative-amount-validation-matrix.md`. This branch's public fee-related amount helper is `calculate_fee_for_asset`; it is a pure quote helper and is documented separately from the `InvalidAmount` rejection matrix. diff --git a/src/lib.rs b/src/lib.rs index a4eceed9..e715ffdd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ )] use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address, - BytesN, Env, IntoVal, Map, Symbol, Vec, + Bytes, BytesN, Env, IntoVal, Map, Symbol, Vec, }; // Issue #109 — Revenue report correction and audit-summary reconciliation are @@ -91,6 +91,10 @@ pub enum RevoraError { 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, /// Payout asset mismatch. PayoutAssetMismatch = 14, /// A transfer is already pending for this offering. @@ -173,6 +177,8 @@ mod test_min_revenue_threshold_boundary; mod test_multisig_gas; #[cfg(test)] mod test_pause_tiers; +#[cfg(test)] +mod test_snapshot_finalization; /// Two-tier pause state stored at `DataKey::Paused`. /// @@ -337,6 +343,8 @@ const EVENT_CONCENTRATION_WARNING: Symbol = symbol_short!("conc_wrn"); const EVENT_CONCENTRATION_REPORTED: Symbol = symbol_short!("conc_rep"); const EVENT_SNAP_COMMIT: Symbol = symbol_short!("snap_cmt"); const EVENT_SNAP_SHARES_APPLIED: Symbol = symbol_short!("snap_shr"); +const EVENT_SNAP_FINALIZED: Symbol = symbol_short!("snap_fin"); +const EVENT_SNAP_FINALIZATION_CONFIG: Symbol = symbol_short!("snap_fnc"); const EVENT_FREEZE_OFFERING: Symbol = symbol_short!("frz_off"); const EVENT_UNFREEZE_OFFERING: Symbol = symbol_short!("ufrz_off"); const EVENT_PROPOSAL_CREATED: Symbol = symbol_short!("prop_new"); @@ -644,7 +652,7 @@ pub enum DataKey { /// Whether snapshot distribution is enabled for an offering. SnapshotConfig(OfferingId), - /// Latest recorded snapshot reference for an offering. + /// Latest recorded snapshot reference for snapshot deposits on an offering. LastSnapshotRef(OfferingId), /// Committed snapshot entry keyed by (offering_id, snapshot_ref). SnapshotEntry(OfferingId, u64), @@ -676,6 +684,12 @@ pub enum DataKey { OfferingFeeBps(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), + /// Whether the snapshot has been finalized successfully. + SnapshotFinalized(OfferingId, u64), } /// Secondary storage keys for auxiliary/extended contract state. @@ -4394,6 +4408,13 @@ impl RevoraRevenueShare { 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 @@ -4473,8 +4494,21 @@ impl RevoraRevenueShare { token: Address, ) -> u64 { let offering_id = OfferingId { issuer, namespace, token }; - let key = DataKey::LastSnapshotRef(offering_id); - env.storage().persistent().get(&key).unwrap_or(0) + 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) ────────────────────────────── @@ -4564,7 +4598,7 @@ impl RevoraRevenueShare { } // Enforce strict monotonicity: snapshot_ref must exceed the last committed ref. - let last_ref_key = DataKey::LastSnapshotRef(offering_id.clone()); + 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); @@ -4583,7 +4617,9 @@ impl RevoraRevenueShare { env.storage() .persistent() .set(&DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref), &entry); - env.storage().persistent().set(&last_ref_key, &snapshot_ref); + env.storage() + .persistent() + .set(&last_ref_key, &snapshot_ref); env.events().publish( (EVENT_SNAP_COMMIT, issuer, namespace, token), @@ -4676,10 +4712,15 @@ impl RevoraRevenueShare { // 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); for i in 0..batch_len { let (holder, share_bps) = holders.get(i).unwrap(); - let slot = start_index.saturating_add(i); + let slot = start_index.saturating_add(i as u32); // Write indexed slot for deterministic enumeration. env.storage().persistent().set( @@ -4687,6 +4728,10 @@ impl RevoraRevenueShare { &(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() @@ -4694,7 +4739,7 @@ impl RevoraRevenueShare { .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) .unwrap_or(0); - let new_total = current_total.saturating_sub(old_share).saturating_add(*share_bps); + let new_total = current_total.saturating_sub(old_share).saturating_add(share_bps); if new_total > 10_000 { return Err(RevoraError::InvalidShareBps); } @@ -4705,15 +4750,19 @@ impl RevoraRevenueShare { .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); current_total = new_total; - added_bps = added_bps.saturating_add(*share_bps); + added_bps = added_bps.saturating_add(share_bps); } // Update snapshot metadata. - let new_holder_count = entry.holder_count.saturating_add(batch_len); + if slot_count > entry.holder_count { + entry.holder_count = slot_count; + } let new_total_bps = entry.total_bps.saturating_add(added_bps); - entry.holder_count = new_holder_count; 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); @@ -4738,8 +4787,7 @@ impl RevoraRevenueShare { let offering_id = OfferingId { issuer, namespace, token }; env.storage() .persistent() - .get::(&DataKey::SnapshotEntry(offering_id, snapshot_ref)) - .map(|e| e.holder_count) + .get(&DataKey::SnapshotHolderCount(offering_id, snapshot_ref)) .unwrap_or(0) } @@ -4758,6 +4806,122 @@ impl RevoraRevenueShare { env.storage().persistent().get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index)) } + /// Enable or disable snapshot finalization enforcement. + pub fn set_snapshot_finalization( + env: Env, + admin: Address, + enabled: bool, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + let current_admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + current_admin.require_auth(); + env.storage() + .persistent() + .set(&DataKey::SnapshotFinalizationRequired, &enabled); + env.events() + .publish((EVENT_SNAP_FINALIZATION_CONFIG,), enabled); + Ok(()) + } + + /// Return true when snapshot finalization is enforced by contract configuration. + pub fn snapshot_finalization_required(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::SnapshotFinalizationRequired) + .unwrap_or(false) + } + + fn is_snapshot_finalized(env: &Env, offering_id: &OfferingId, snapshot_ref: u64) -> bool { + env.storage() + .persistent() + .get(&DataKey::SnapshotFinalized( + offering_id.clone(), + snapshot_ref, + )) + .unwrap_or(false) + } + + /// Finalize a snapshot by recomputing the digest over applied holder slots. + /// + /// Returns `SnapshotHashMismatch` if the recomputed hash differs from the + /// committed `content_hash`. + pub fn finalize_snapshot( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + snapshot_ref: 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() }; + if !env + .storage() + .persistent() + .get::(&DataKey::SnapshotConfig(offering_id.clone())) + .unwrap_or(false) + { + return Err(RevoraError::SnapshotNotEnabled); + } + + let entry_key = DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref); + let entry: SnapshotEntry = env.storage().persistent().get(&entry_key).ok_or(RevoraError::OutdatedSnapshot)?; + + if Self::is_snapshot_finalized(&env, &offering_id, snapshot_ref) { + return Ok(()); + } + + let slot_count: u32 = env + .storage() + .persistent() + .get(&DataKey::SnapshotHolderCount(offering_id.clone(), snapshot_ref)) + .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(), + snapshot_ref, + 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); + } + + env.storage() + .persistent() + .set(&DataKey::SnapshotFinalized(offering_id.clone(), snapshot_ref), &true); + env.events().publish( + (EVENT_SNAP_FINALIZED, issuer, namespace, token), + snapshot_ref, + ); + Ok(()) + } + // ── Delegating wrappers for functions in the plain impl block ───────────── // These expose functions from the plain impl block through the contract ABI. diff --git a/src/test_snapshot_finalization.rs b/src/test_snapshot_finalization.rs new file mode 100644 index 00000000..e0599a6a --- /dev/null +++ b/src/test_snapshot_finalization.rs @@ -0,0 +1,102 @@ +#![cfg(test)] + +use crate::{DataKey, RevoraError, RevoraRevenueShare, RevoraRevenueShareClient}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, BytesN as _}, + xdr::ToXdr, + Address, Bytes, BytesN, Env, +}; + +fn setup_snapshot_test() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + + client.register_offering(&issuer, &symbol_short!("def"), &token, &5_000, &payout_asset, &0); + (env, client, issuer, token, payout_asset, contract_id) +} + +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 finalize_snapshot_succeeds_when_hash_matches() { + let (env, client, issuer, token, _payout_asset, _contract_id) = setup_snapshot_test(); + + client.set_snapshot_config(&issuer, &symbol_short!("def"), &token, &true); + + let holder1 = Address::generate(&env); + let holder2 = Address::generate(&env); + let holders = soroban_sdk::vec![&env, (holder1.clone(), 5_000u32), (holder2.clone(), 5_000u32)]; + let content_hash = compute_snapshot_content_hash( + &env, + &[(holder1.clone(), 5_000), (holder2.clone(), 5_000)], + ); + + client.commit_snapshot(&issuer, &symbol_short!("def"), &token, &1, &content_hash); + client.apply_snapshot_shares(&issuer, &symbol_short!("def"), &token, &1, &0, &holders); + client.finalize_snapshot(&issuer, &symbol_short!("def"), &token, &1); +} + +#[test] +fn finalize_snapshot_fails_when_hash_mismatch() { + let (env, client, issuer, token, _payout_asset, _contract_id) = setup_snapshot_test(); + + client.set_snapshot_config(&issuer, &symbol_short!("def"), &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, &symbol_short!("def"), &token, &1, &content_hash); + client.apply_snapshot_shares(&issuer, &symbol_short!("def"), &token, &1, &0, &holders); + + let result = client.try_finalize_snapshot(&issuer, &symbol_short!("def"), &token, &1); + assert!(result.is_err()); + assert!(matches!(result.err(), Some(Ok(RevoraError::SnapshotHashMismatch)))); +} + +#[test] +fn deposit_revenue_with_snapshot_fails_when_finalization_required_and_unfinalized() { + let (env, client, issuer, token, payout_asset, _contract_id) = setup_snapshot_test(); + + client.set_snapshot_config(&issuer, &symbol_short!("def"), &token, &true); + + let admin = Address::generate(&env); + env.as_contract(&_contract_id, || { + env.storage().persistent().set(&DataKey::Admin, &admin); + }); + client.set_snapshot_finalization(&admin, &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, &symbol_short!("def"), &token, &1, &content_hash); + client.apply_snapshot_shares(&issuer, &symbol_short!("def"), &token, &1, &0, &holders); + + let result = client.try_deposit_revenue_with_snapshot( + &issuer, + &symbol_short!("def"), + &token, + &payout_asset, + &10_000, + &1, + &1, + ); + + assert!(result.is_err()); + assert!(matches!(result.err(), Some(Ok(RevoraError::SnapshotNotFinalized)))); +} From b51a3f1e1d1a43093a3491922428269526b7179c Mon Sep 17 00:00:00 2001 From: shogun444 Date: Mon, 1 Jun 2026 23:55:19 +0530 Subject: [PATCH 2/2] FIxed CI --- src/lib.rs | 80 ++++++++++---------- src/test_claim_transfer_fail.rs | 96 ++++++++---------------- src/test_multisig_gas.rs | 120 ++++++++++++++++++++---------- src/test_pause_tiers.rs | 6 +- src/test_snapshot_finalization.rs | 9 +-- src/vesting.rs | 38 ++++++---- 6 files changed, 182 insertions(+), 167 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e715ffdd..862a9983 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] #![deny(unsafe_code)] #![allow(dead_code)] #![allow(unused_variables)] @@ -37,7 +37,8 @@ clippy::manual_let_else, clippy::empty_line_after_doc_comments, clippy::doc_lazy_continuation, - clippy::unnecessary_lazy_evaluations + clippy::unnecessary_lazy_evaluations, + clippy::enum_variant_names )] use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address, @@ -1207,7 +1208,11 @@ impl RevoraRevenueShare { (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)); + Self::emit_v2_event( + env, + (EVENT_SHARE_SET_V2, issuer, namespace, token), + (holder, share_bps), + ); Ok(()) } @@ -4345,6 +4350,7 @@ impl RevoraRevenueShare { period_id: u64, ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; issuer.require_auth(); // Input validation (#35): reject zero/invalid period_id and non-positive amounts. @@ -4386,6 +4392,7 @@ impl RevoraRevenueShare { snapshot_reference: u64, ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; issuer.require_auth(); // 0. Validate snapshot reference using Negative Amount Validation Matrix (#163) @@ -4617,9 +4624,7 @@ impl RevoraRevenueShare { env.storage() .persistent() .set(&DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref), &entry); - env.storage() - .persistent() - .set(&last_ref_key, &snapshot_ref); + env.storage().persistent().set(&last_ref_key, &snapshot_ref); env.events().publish( (EVENT_SNAP_COMMIT, issuer, namespace, token), @@ -4720,7 +4725,7 @@ impl RevoraRevenueShare { for i in 0..batch_len { let (holder, share_bps) = holders.get(i).unwrap(); - let slot = start_index.saturating_add(i as u32); + let slot = start_index.saturating_add(i); // Write indexed slot for deterministic enumeration. env.storage().persistent().set( @@ -4765,7 +4770,9 @@ impl RevoraRevenueShare { .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.storage() + .persistent() + .set(&DataKey::HolderShareTotal(offering_id.clone()), ¤t_total); env.events().publish( (EVENT_SNAP_SHARES_APPLIED, issuer, namespace, token), @@ -4814,35 +4821,23 @@ impl RevoraRevenueShare { ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; Self::require_not_paused(&env)?; - let current_admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(RevoraError::NotInitialized)?; + let current_admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; current_admin.require_auth(); - env.storage() - .persistent() - .set(&DataKey::SnapshotFinalizationRequired, &enabled); - env.events() - .publish((EVENT_SNAP_FINALIZATION_CONFIG,), enabled); + env.storage().persistent().set(&DataKey::SnapshotFinalizationRequired, &enabled); + env.events().publish((EVENT_SNAP_FINALIZATION_CONFIG,), enabled); Ok(()) } /// Return true when snapshot finalization is enforced by contract configuration. pub fn snapshot_finalization_required(env: Env) -> bool { - env.storage() - .persistent() - .get(&DataKey::SnapshotFinalizationRequired) - .unwrap_or(false) + env.storage().persistent().get(&DataKey::SnapshotFinalizationRequired).unwrap_or(false) } fn is_snapshot_finalized(env: &Env, offering_id: &OfferingId, snapshot_ref: u64) -> bool { env.storage() .persistent() - .get(&DataKey::SnapshotFinalized( - offering_id.clone(), - snapshot_ref, - )) + .get(&DataKey::SnapshotFinalized(offering_id.clone(), snapshot_ref)) .unwrap_or(false) } @@ -4861,13 +4856,18 @@ impl RevoraRevenueShare { 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)?; + 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 offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; if !env .storage() .persistent() @@ -4878,7 +4878,8 @@ impl RevoraRevenueShare { } let entry_key = DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref); - let entry: SnapshotEntry = env.storage().persistent().get(&entry_key).ok_or(RevoraError::OutdatedSnapshot)?; + let entry: SnapshotEntry = + env.storage().persistent().get(&entry_key).ok_or(RevoraError::OutdatedSnapshot)?; if Self::is_snapshot_finalized(&env, &offering_id, snapshot_ref) { return Ok(()); @@ -4895,11 +4896,7 @@ impl RevoraRevenueShare { let (holder, share_bps): (Address, u32) = env .storage() .persistent() - .get(&DataKey::SnapshotHolder( - offering_id.clone(), - snapshot_ref, - index, - )) + .get(&DataKey::SnapshotHolder(offering_id.clone(), snapshot_ref, index)) .ok_or(RevoraError::SnapshotHashMismatch)?; digest_input.append(&index.to_xdr(&env)); @@ -4915,10 +4912,7 @@ impl RevoraRevenueShare { env.storage() .persistent() .set(&DataKey::SnapshotFinalized(offering_id.clone(), snapshot_ref), &true); - env.events().publish( - (EVENT_SNAP_FINALIZED, issuer, namespace, token), - snapshot_ref, - ); + env.events().publish((EVENT_SNAP_FINALIZED, issuer, namespace, token), snapshot_ref); Ok(()) } @@ -4937,7 +4931,11 @@ impl RevoraRevenueShare { 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(), + }; Self::get_current_issuer( &env, issuer.clone(), @@ -6662,4 +6660,4 @@ mod issue_370_373_tests { i128::MIN ); } -} \ No newline at end of file +} diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index fdc4a334..3340085c 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -173,6 +173,24 @@ fn deploy_failing_token(env: &Env) -> (Address, FailingTransferTokenClient<'stat (id, client) } +fn pending_periods( + env: &Env, + revora_id: &Address, + issuer: &Address, + offering_token: &Address, + holder: &Address, +) -> soroban_sdk::Vec { + env.as_contract(revora_id, || { + RevoraRevenueShare::get_pending_periods( + env.clone(), + issuer.clone(), + symbol_short!("def"), + offering_token.clone(), + holder.clone(), + ) + }) +} + /// Full setup for claim-failure tests. /// /// - Registers an offering with `FailingTransferToken` as payment token. @@ -250,27 +268,15 @@ fn claim_transfer_fail_returns_transfer_failed() { /// `LastClaimedIdx` is NOT advanced when claim transfer fails. #[test] fn claim_transfer_fail_does_not_advance_last_claimed_idx() { - let (env, _revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token, holder) = + let (env, revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token, holder) = setup_claim_fail(); - let pending_before = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + let pending_before = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!(pending_before.len(), 1, "should have 1 pending period before failed claim"); let _ = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); - let pending_after = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + let pending_after = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!( pending_after.len(), pending_before.len(), @@ -316,7 +322,7 @@ fn claim_transfer_fail_contract_balance_unchanged() { /// After a failed claim, the holder can retry and succeed once the token issue is resolved. #[test] fn claim_transfer_fail_then_retry_succeeds() { - let (env, _revora_id, revora, _fail_token_id, fail_token, issuer, offering_token, holder) = + let (env, revora_id, revora, _fail_token_id, fail_token, issuer, offering_token, holder) = setup_claim_fail(); // First attempt fails @@ -333,13 +339,7 @@ fn claim_transfer_fail_then_retry_succeeds() { assert_eq!(r2.unwrap().unwrap(), 100_000, "holder should receive full payout on retry"); // Pending periods now empty - let pending = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + let pending = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!(pending.len(), 0, "all periods should be claimed after successful retry"); } @@ -374,13 +374,7 @@ fn claim_transfer_fail_multi_period_no_partial_state() { // Re-arm fail mode for claim direction fail_token.set_fail_from(&revora_id); - let pending_before = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + let pending_before = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!(pending_before.len(), 3); // Attempt to claim all 3 — transfer fails @@ -388,13 +382,7 @@ fn claim_transfer_fail_multi_period_no_partial_state() { assert!(matches!(result.err(), Some(Ok(RevoraError::TransferFailed)))); // All 3 periods still pending — no partial state - let pending_after = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + let pending_after = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!( pending_after.len(), 3, @@ -434,30 +422,18 @@ fn claim_transfer_fail_does_not_affect_other_holder_state() { assert!(matches!(r1.err(), Some(Ok(RevoraError::TransferFailed)))); // holder2 pending state is independent and unchanged - let pending_h2 = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder2.clone(), - ); + let pending_h2 = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder2); assert_eq!(pending_h2.len(), 2, "holder2 should still have 2 pending periods"); // holder1 pending state also unchanged - let pending_h1 = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + let pending_h1 = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!(pending_h1.len(), 2, "holder1 should still have 2 pending periods"); } /// A failed claim on one offering does not affect a sibling offering's state. #[test] fn claim_transfer_fail_does_not_affect_sibling_offering() { - let (env, _revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token_a, holder) = + let (env, revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token_a, holder) = setup_claim_fail(); // Register a second offering with a normal Stellar asset token @@ -494,22 +470,10 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { assert_eq!(r_b.unwrap().unwrap(), 100_000); // Offering A: period 1 still pending - let pending_a = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token_a.clone(), - holder.clone(), - ); + let pending_a = pending_periods(&env, &revora_id, &issuer, &offering_token_a, &holder); assert_eq!(pending_a.len(), 1, "offering A period must remain pending"); // Offering B: no pending periods - let pending_b = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token_b.clone(), - holder.clone(), - ); + let pending_b = pending_periods(&env, &revora_id, &issuer, &offering_token_b, &holder); assert_eq!(pending_b.len(), 0, "offering B must be fully claimed"); } diff --git a/src/test_multisig_gas.rs b/src/test_multisig_gas.rs index 2d3ca249..494683d5 100644 --- a/src/test_multisig_gas.rs +++ b/src/test_multisig_gas.rs @@ -88,13 +88,15 @@ fn setup_max_multisig() -> (Env, Address, RevoraRevenueShareClient<'static>, Add // Majority threshold; duration = 1 day. let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; // 11 - RevoraRevenueShare::init_multisig( - env.clone(), - admin.clone(), - owners.clone(), - threshold, - 86_400u64, - ) + env.as_contract(&id, || { + RevoraRevenueShare::init_multisig( + env.clone(), + admin.clone(), + owners.clone(), + threshold, + 86_400u64, + ) + }) .unwrap(); (env, id, client, admin, owners) @@ -104,18 +106,26 @@ fn setup_max_multisig() -> (Env, Address, RevoraRevenueShareClient<'static>, Add /// Returns the proposal id ready for `execute_action`. fn propose_and_approve( env: &Env, + contract_id: &Address, owners: &Vec
, threshold: u32, action: ProposalAction, ) -> u32 { // owners[0] proposes (counts as first approval automatically). let proposer = owners.get(0).unwrap(); - let proposal_id = RevoraRevenueShare::propose_action(env.clone(), proposer, action).unwrap(); + let proposal_id = env + .as_contract(contract_id, || { + RevoraRevenueShare::propose_action(env.clone(), proposer, action) + }) + .unwrap(); // Collect remaining approvals up to threshold. for i in 1..threshold { let approver = owners.get(i).unwrap(); - RevoraRevenueShare::approve_action(env.clone(), approver, proposal_id).unwrap(); + env.as_contract(contract_id, || { + RevoraRevenueShare::approve_action(env.clone(), approver, proposal_id) + }) + .unwrap(); } proposal_id @@ -137,15 +147,16 @@ fn execute_remove_owner_at_max_owners_within_budget() { let (env, id, _client, _admin, owners) = setup_max_multisig(); let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; // 11 - // Remove the last owner (index 19) — it is not the proposer. + // Remove the last owner (index 19) — it is not the proposer. let target = owners.get(RevoraRevenueShare::MAX_MULTISIG_OWNERS - 1).unwrap(); let action = ProposalAction::RemoveOwner(target); - let proposal_id = propose_and_approve(&env, &owners, threshold, action); + let proposal_id = propose_and_approve(&env, &id, &owners, threshold, action); let executor = owners.get(0).unwrap(); // Must complete without panic or resource exhaustion. - RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id).unwrap(); + env.as_contract(&id, || RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id)) + .unwrap(); // Functional correctness: owner count decreased by 1. assert_eq!( @@ -172,22 +183,25 @@ fn execute_add_owner_at_cap_minus_one_within_budget() { owners.push_back(Address::generate(&env)); } let threshold = count / 2 + 1; // 10 - RevoraRevenueShare::init_multisig( - env.clone(), - admin.clone(), - owners.clone(), - threshold, - 86_400u64, - ) + env.as_contract(&id, || { + RevoraRevenueShare::init_multisig( + env.clone(), + admin.clone(), + owners.clone(), + threshold, + 86_400u64, + ) + }) .unwrap(); let new_owner = Address::generate(&env); let action = ProposalAction::AddOwner(new_owner.clone()); - let proposal_id = propose_and_approve(&env, &owners, threshold, action); + let proposal_id = propose_and_approve(&env, &id, &owners, threshold, action); let executor = owners.get(0).unwrap(); // Must complete without panic or resource exhaustion. - RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id).unwrap(); + env.as_contract(&id, || RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id)) + .unwrap(); // Functional correctness: owner count is now MAX. let final_owners = read_owners(&env, &id); @@ -210,10 +224,12 @@ fn execute_add_owner_at_max_returns_limit_reached() { let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; let new_owner = Address::generate(&env); let action = ProposalAction::AddOwner(new_owner); - let proposal_id = propose_and_approve(&env, &owners, threshold, action); + let proposal_id = propose_and_approve(&env, &id, &owners, threshold, action); let executor = owners.get(0).unwrap(); - let result = RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id); + let result = env.as_contract(&id, || { + RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id) + }); assert_eq!( result, @@ -253,17 +269,36 @@ fn execute_remove_owner_below_threshold_returns_limit_reached() { owners.push_back(owner1.clone()); owners.push_back(owner2.clone()); owners.push_back(owner3.clone()); - RevoraRevenueShare::init_multisig(env.clone(), admin.clone(), owners.clone(), 3u32, 86_400u64) - .unwrap(); + env.as_contract(&id, || { + RevoraRevenueShare::init_multisig( + env.clone(), + admin.clone(), + owners.clone(), + 3u32, + 86_400u64, + ) + }) + .unwrap(); // All 3 must approve to meet threshold = 3. let action = ProposalAction::RemoveOwner(owner3.clone()); - let proposal_id = - RevoraRevenueShare::propose_action(env.clone(), owner1.clone(), action).unwrap(); - RevoraRevenueShare::approve_action(env.clone(), owner2.clone(), proposal_id).unwrap(); - RevoraRevenueShare::approve_action(env.clone(), owner3.clone(), proposal_id).unwrap(); + let proposal_id = env + .as_contract(&id, || { + RevoraRevenueShare::propose_action(env.clone(), owner1.clone(), action) + }) + .unwrap(); + env.as_contract(&id, || { + RevoraRevenueShare::approve_action(env.clone(), owner2.clone(), proposal_id) + }) + .unwrap(); + env.as_contract(&id, || { + RevoraRevenueShare::approve_action(env.clone(), owner3.clone(), proposal_id) + }) + .unwrap(); - let result = RevoraRevenueShare::execute_action(env.clone(), owner1.clone(), proposal_id); + let result = env.as_contract(&id, || { + RevoraRevenueShare::execute_action(env.clone(), owner1.clone(), proposal_id) + }); assert_eq!( result, @@ -294,11 +329,13 @@ fn execute_action_non_owner_returns_not_authorized() { let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; let target = owners.get(RevoraRevenueShare::MAX_MULTISIG_OWNERS - 1).unwrap(); let action = ProposalAction::RemoveOwner(target); - let proposal_id = propose_and_approve(&env, &owners, threshold, action); + let proposal_id = propose_and_approve(&env, &id, &owners, threshold, action); // outsider is not in the owners list let outsider = Address::generate(&env); - let result = RevoraRevenueShare::execute_action(env.clone(), outsider, proposal_id); + let result = env.as_contract(&id, || { + RevoraRevenueShare::execute_action(env.clone(), outsider, proposal_id) + }); assert_eq!( result, @@ -320,7 +357,7 @@ fn execute_action_expired_proposal_returns_proposal_expired() { let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; let target = owners.get(RevoraRevenueShare::MAX_MULTISIG_OWNERS - 1).unwrap(); let action = ProposalAction::RemoveOwner(target); - let proposal_id = propose_and_approve(&env, &owners, threshold, action); + let proposal_id = propose_and_approve(&env, &id, &owners, threshold, action); // Advance ledger time past the 1-day duration. env.ledger().with_mut(|li| { @@ -328,7 +365,9 @@ fn execute_action_expired_proposal_returns_proposal_expired() { }); let executor = owners.get(0).unwrap(); - let result = RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id); + let result = env.as_contract(&id, || { + RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id) + }); assert_eq!( result, @@ -344,20 +383,25 @@ fn execute_action_expired_proposal_returns_proposal_expired() { /// `execute_action` on an already-executed proposal returns `LimitReached`. #[test] fn execute_action_already_executed_returns_limit_reached() { - let (env, _id, _client, _admin, owners) = setup_max_multisig(); + let (env, id, _client, _admin, owners) = setup_max_multisig(); let threshold = RevoraRevenueShare::MAX_MULTISIG_OWNERS / 2 + 1; let target = owners.get(RevoraRevenueShare::MAX_MULTISIG_OWNERS - 1).unwrap(); let action = ProposalAction::RemoveOwner(target); - let proposal_id = propose_and_approve(&env, &owners, threshold, action); + let proposal_id = propose_and_approve(&env, &id, &owners, threshold, action); let executor = owners.get(0).unwrap(); // First execution succeeds. - RevoraRevenueShare::execute_action(env.clone(), executor.clone(), proposal_id).unwrap(); + env.as_contract(&id, || { + RevoraRevenueShare::execute_action(env.clone(), executor.clone(), proposal_id) + }) + .unwrap(); // Second execution must fail. - let result = RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id); + let result = env.as_contract(&id, || { + RevoraRevenueShare::execute_action(env.clone(), executor, proposal_id) + }); assert_eq!( result, Err(RevoraError::LimitReached), diff --git a/src/test_pause_tiers.rs b/src/test_pause_tiers.rs index 53835702..8efa814b 100644 --- a/src/test_pause_tiers.rs +++ b/src/test_pause_tiers.rs @@ -30,13 +30,13 @@ use crate::{PauseState, RevoraError, RevoraRevenueShare, RevoraRevenueShareClien // ── helpers ────────────────────────────────────────────────────────────────── -fn make_client(env: &Env) -> RevoraRevenueShareClient { +fn make_client(env: &Env) -> RevoraRevenueShareClient<'_> { let id = env.register_contract(None, RevoraRevenueShare); RevoraRevenueShareClient::new(env, &id) } /// Initialize with both admin and safety roles; mock all auths for the test. -fn setup(env: &Env) -> (RevoraRevenueShareClient, Address, Address) { +fn setup(env: &Env) -> (RevoraRevenueShareClient<'_>, Address, Address) { env.mock_all_auths(); let client = make_client(env); let admin = Address::generate(env); @@ -51,7 +51,7 @@ fn setup(env: &Env) -> (RevoraRevenueShareClient, Address, Address) { /// Returns `(client, admin, safety, issuer, offering_token, payment_token, holder)`. fn setup_with_offering( env: &Env, -) -> (RevoraRevenueShareClient, Address, Address, Address, Address, Address, Address) { +) -> (RevoraRevenueShareClient<'_>, Address, Address, Address, Address, Address, Address) { env.mock_all_auths(); let client = make_client(env); let admin = Address::generate(env); diff --git a/src/test_snapshot_finalization.rs b/src/test_snapshot_finalization.rs index e0599a6a..ba8b3888 100644 --- a/src/test_snapshot_finalization.rs +++ b/src/test_snapshot_finalization.rs @@ -8,7 +8,8 @@ use soroban_sdk::{ Address, Bytes, BytesN, Env, }; -fn setup_snapshot_test() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address, Address) { +fn setup_snapshot_test( +) -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register_contract(None, RevoraRevenueShare); @@ -40,10 +41,8 @@ fn finalize_snapshot_succeeds_when_hash_matches() { let holder1 = Address::generate(&env); let holder2 = Address::generate(&env); let holders = soroban_sdk::vec![&env, (holder1.clone(), 5_000u32), (holder2.clone(), 5_000u32)]; - let content_hash = compute_snapshot_content_hash( - &env, - &[(holder1.clone(), 5_000), (holder2.clone(), 5_000)], - ); + let content_hash = + compute_snapshot_content_hash(&env, &[(holder1.clone(), 5_000), (holder2.clone(), 5_000)]); client.commit_snapshot(&issuer, &symbol_short!("def"), &token, &1, &content_hash); client.apply_snapshot_shares(&issuer, &symbol_short!("def"), &token, &1, &0, &holders); diff --git a/src/vesting.rs b/src/vesting.rs index 68c18cab..87e0e200 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -107,9 +107,9 @@ impl VestingContract { } let schedule = VestingSchedule { - issuer, + issuer: issuer.clone(), beneficiary: beneficiary.clone(), - token, + token: token.clone(), total_amount, cliff_ts, start_ts, @@ -218,40 +218,50 @@ pub fn migrate_offering_schedules( return Ok(Vec::new(env)); } - let mut beneficiaries = Vec::new(env); + let mut beneficiaries: Vec
= Vec::new(env); for i in 0..count { - if let Some(beneficiary) = - env.storage().persistent().get(&VestingKey::OfferingScheduleItem(offering_id.clone(), i)) + if let Some(beneficiary) = env + .storage() + .persistent() + .get(&VestingKey::OfferingScheduleItem(offering_id.clone(), i)) { beneficiaries.push_back(beneficiary); } } - let new_offering_id = VestingOfferingId { issuer: new_issuer.clone(), token: offering_id.token.clone() }; + let new_offering_id = + VestingOfferingId { issuer: new_issuer.clone(), token: offering_id.token.clone() }; let mut new_count: u32 = env .storage() .persistent() .get(&VestingKey::OfferingScheduleCount(new_offering_id.clone())) .unwrap_or(0); - let mut migrated = Vec::new(&env); + let mut migrated = Vec::new(env); // First pass: validate that no schedule is pre-cliff. for beneficiary in beneficiaries.iter() { - if let Some(schedule) = env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone())) { - if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token { - if now < schedule.cliff_ts { - return Err(VestingError::SchedulePreCliff); - } + let schedule: Option = + env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone())); + if let Some(schedule) = schedule { + if schedule.issuer == offering_id.issuer + && schedule.token == offering_id.token + && now < schedule.cliff_ts + { + return Err(VestingError::SchedulePreCliff); } } } // Second pass: migrate matching schedules and rebuild the beneficiary index. for beneficiary in beneficiaries.iter() { - if let Some(mut schedule) = env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone())) { + let schedule: Option = + env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone())); + if let Some(mut schedule) = schedule { if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token { schedule.issuer = new_issuer.clone(); - env.storage().persistent().set(&VestingKey::Schedule(beneficiary.clone()), &schedule); + env.storage() + .persistent() + .set(&VestingKey::Schedule(beneficiary.clone()), &schedule); env.storage().persistent().set( &VestingKey::OfferingScheduleItem(new_offering_id.clone(), new_count), &beneficiary.clone(),