diff --git a/README.md b/README.md index bca5811c..b302e204 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,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 7d9f0942..a4accf56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,6 +92,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. @@ -176,7 +180,28 @@ mod test_override_audit_trail; #[cfg(test)] mod test_min_revenue_threshold_boundary; #[cfg(test)] -mod test_prove_distribution; +mod test_multisig_gas; +#[cfg(test)] +mod test_pause_tiers; +#[cfg(test)] +mod test_snapshot_finalization; + +/// Two-tier pause state stored at `DataKey::Paused`. +/// +/// - `NotPaused` – normal operation; all entrypoints are open. +/// - `SoftPaused` – blocks reports and deposits but **allows** `claim`, so +/// holders can still withdraw their funds during incident response. +/// - `HardPaused` – blocks every state-mutating operation including `claim`. +/// +/// Wire values are stable: do not renumber. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum PauseState { + NotPaused = 0, + SoftPaused = 1, + HardPaused = 2, +} // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); @@ -327,6 +352,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"); @@ -668,7 +695,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), @@ -702,7 +729,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. @@ -4548,6 +4580,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. @@ -4589,6 +4622,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) @@ -4611,6 +4645,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 @@ -4690,8 +4731,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) ────────────────────────────── @@ -4781,7 +4835,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); @@ -4893,6 +4947,11 @@ 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(); @@ -4904,6 +4963,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() @@ -4926,11 +4989,15 @@ impl RevoraRevenueShare { } // 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() @@ -4957,8 +5024,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) } @@ -4977,6 +5043,109 @@ 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_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index 92d9f5ec..0087d0f1 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,12 +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) = + setup_claim_fail(); + 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 = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!( pending_after.len(), pending_before.len(), @@ -301,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 @@ -316,6 +337,8 @@ fn claim_transfer_fail_then_retry_succeeds() { let r2 = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); assert!(r2.is_ok(), "retry after fixing token should succeed, got {r2:?}"); + // Pending periods now empty + let pending = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!(pending.len(), 0, "all periods should be claimed after successful retry"); } @@ -350,6 +373,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 = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!(pending_before.len(), 3); // Attempt to claim all 3 — transfer fails @@ -357,7 +381,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 = pending_periods(&env, &revora_id, &issuer, &offering_token, &holder); assert_eq!( pending_after.len(), 3, @@ -397,15 +421,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 = 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 = 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 @@ -439,5 +466,11 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { let r_b = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token_b, &50); assert!(r_b.is_ok(), "sibling offering claim must succeed, got {r_b:?}"); + // Offering A: period 1 still pending + 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 = 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 e72a2367..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 @@ -141,11 +151,12 @@ fn execute_remove_owner_at_max_owners_within_budget() { 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_snapshot_finalization.rs b/src/test_snapshot_finalization.rs new file mode 100644 index 00000000..ba8b3888 --- /dev/null +++ b/src/test_snapshot_finalization.rs @@ -0,0 +1,101 @@ +#![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)))); +} diff --git a/src/vesting.rs b/src/vesting.rs index 3e3f236c..c7d53716 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -236,22 +236,27 @@ pub fn migrate_offering_schedules( .persistent() .get(&VestingKey::OfferingScheduleCount(new_offering_id.clone())) .unwrap_or(0); - let mut migrated: Vec
= 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::<_, VestingSchedule>(&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::<_, VestingSchedule>(&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()