diff --git a/src/lib.rs b/src/lib.rs
index fcacc6a9..b47ce68b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -169,6 +169,8 @@ mod test_claim_transfer_fail;
#[cfg(test)]
mod test_duplicates;
#[cfg(test)]
+mod test_override_audit_trail;
+#[cfg(test)]
mod test_min_revenue_threshold_boundary;
#[cfg(test)]
mod test_prove_distribution;
diff --git a/src/test_override_audit_trail.rs b/src/test_override_audit_trail.rs
new file mode 100644
index 00000000..da0085fe
--- /dev/null
+++ b/src/test_override_audit_trail.rs
@@ -0,0 +1,400 @@
+//! Override-Revenue Audit Trail Reconstructibility Tests
+//!
+//! # Purpose
+//! Verifies that the sequence of `rev_ovrd` events emitted by `report_revenue`
+//! (with `override_existing=true`) is sufficient to deterministically reconstruct
+//! the final persisted amount for any period — without reading on-chain storage.
+//!
+//! # Reconstruction Algorithm (for off-chain indexers)
+//! 1. Collect the initial `rev_init` event for a period → `current = init_amount`.
+//! 2. For each subsequent `rev_ovrd` event for that period (in emission order):
+//! `current = new_amount` (the event carries both `new_amount` and `old_amount`).
+//! 3. After replaying all events, `current` equals `get_revenue_by_period(period_id)`.
+//!
+//! # Security Assumptions
+//! - Events are emitted in the same transaction that mutates storage; they cannot
+//! diverge from the persisted state within a single successful call.
+//! - `AuditSummary.total_revenue` is updated via `saturating_add(new - old)` on
+//! each override; the same delta can be reconstructed from `rev_ovrd` events.
+//! - `report_count` is never incremented on override; only `rev_init` events
+//! contribute to the count.
+//!
+//! # Event Payload Layout
+//! `rev_ovrd` data tuple: `(new_amount: i128, period_id: u64, old_amount: i128, blacklist: Vec
)`
+//! `rev_ovra` data tuple: `(payout_asset: Address, new_amount: i128, period_id: u64, old_amount: i128, blacklist: Vec)`
+
+#![cfg(test)]
+
+extern crate alloc;
+
+use super::*;
+use alloc::vec::Vec as RustVec;
+use soroban_sdk::{
+ symbol_short,
+ testutils::{Address as _, Events as _},
+ Address, Env, IntoVal, Symbol, Val, Vec as SdkVec,
+};
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+fn setup() -> (Env, 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.initialize(&issuer, &None::, &None::);
+ client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0);
+ (env, contract_id, issuer, token, payout_asset)
+}
+
+/// Collect `(new_amount, period_id, old_amount)` tuples from all `rev_ovrd`
+/// events emitted at or after `start_idx` in the environment event log.
+fn collect_override_events(env: &Env, start_idx: u32) -> RustVec<(i128, u64, i128)> {
+ let rev_ovrd_sym: Symbol = symbol_short!("rev_ovrd");
+ let mut out = RustVec::new();
+ let all = env.events().all();
+ for i in start_idx..all.len() {
+ let (_, topics, data) = all.get(i).unwrap();
+ let topics_vec: SdkVec = topics.into_val(env);
+ let topic_sym: Symbol = topics_vec.get(0).unwrap().into_val(env);
+ if topic_sym == rev_ovrd_sym {
+ let data_vec: SdkVec = data.into_val(env);
+ let new_amount: i128 = data_vec.get(0).unwrap().into_val(env);
+ let period_id: u64 = data_vec.get(1).unwrap().into_val(env);
+ let old_amount: i128 = data_vec.get(2).unwrap().into_val(env);
+ out.push((new_amount, period_id, old_amount));
+ }
+ }
+ out
+}
+
+/// Collect the initial amount from the first `rev_init` event for `period_id`
+/// emitted at or after `start_idx`.
+fn collect_init_amount(env: &Env, start_idx: u32, period_id: u64) -> Option {
+ let rev_init_sym: Symbol = symbol_short!("rev_init");
+ let all = env.events().all();
+ for i in start_idx..all.len() {
+ let (_, topics, data) = all.get(i).unwrap();
+ let topics_vec: SdkVec = topics.into_val(env);
+ let topic_sym: Symbol = topics_vec.get(0).unwrap().into_val(env);
+ if topic_sym == rev_init_sym {
+ let data_vec: SdkVec = data.into_val(env);
+ let amount: i128 = data_vec.get(0).unwrap().into_val(env);
+ let pid: u64 = data_vec.get(1).unwrap().into_val(env);
+ if pid == period_id {
+ return Some(amount);
+ }
+ }
+ }
+ None
+}
+
+/// Replay the override event sequence for `period_id` and return the
+/// reconstructed final amount. Panics if no `rev_init` is found.
+fn reconstruct_from_events(env: &Env, start_idx: u32, period_id: u64) -> i128 {
+ let init = collect_init_amount(env, start_idx, period_id)
+ .expect("rev_init event must exist for the period");
+ let overrides = collect_override_events(env, start_idx);
+ // Apply overrides in emission order; each carries the authoritative new_amount.
+ let mut current = init;
+ for (new_amount, pid, _old_amount) in &overrides {
+ if *pid == period_id {
+ current = *new_amount;
+ }
+ }
+ current
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+/// Five sequential overrides on a single period; the event sequence must
+/// reconstruct the final persisted amount exactly.
+///
+/// Override sequence: 100 → 200 → 50 → 300 → 150 → 250
+/// Expected final: 250
+#[test]
+fn override_audit_trail_five_overrides_reconstructible() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+ let period_id: u64 = 1;
+
+ let start_idx = env.events().all().len();
+
+ // Initial report
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &100, &period_id, &false);
+
+ // Five overrides with varied deltas
+ let override_amounts: [i128; 5] = [200, 50, 300, 150, 250];
+ for &amt in &override_amounts {
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &amt, &period_id, &true);
+ }
+
+ let persisted = client.get_revenue_by_period(&issuer, &ns, &token, &period_id);
+ let reconstructed = reconstruct_from_events(&env, start_idx, period_id);
+
+ assert_eq!(
+ reconstructed, persisted,
+ "event-reconstructed amount must equal persisted storage value"
+ );
+ assert_eq!(persisted, 250, "final persisted amount must be the last override value");
+
+ // Exactly 5 rev_ovrd events must have been emitted
+ let overrides = collect_override_events(&env, start_idx);
+ assert_eq!(overrides.len(), 5, "must emit exactly one rev_ovrd per override call");
+
+ // Each override event must carry the correct (new, old) pair
+ let expected_pairs: [(i128, i128); 5] =
+ [(200, 100), (50, 200), (300, 50), (150, 300), (250, 150)];
+ for (i, &(new_amount, _pid, old_amount)) in overrides.iter().enumerate() {
+ assert_eq!(
+ (new_amount, old_amount),
+ expected_pairs[i],
+ "override event {i} must carry correct (new_amount, old_amount)"
+ );
+ }
+}
+
+/// AuditSummary.total_revenue must equal the sum reconstructed by replaying
+/// rev_init + rev_ovrd deltas across multiple periods.
+///
+/// Periods: 1 (init=100, override→200), 2 (init=60, override→10), 3 (init=40, no override)
+/// Expected total: 200 + 10 + 40 = 250
+#[test]
+fn override_audit_trail_total_revenue_reconstructible_from_events() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+
+ let start_idx = env.events().all().len();
+
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &100, &1, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &60, &2, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &40, &3, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &200, &1, &true);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &10, &2, &true);
+
+ // Reconstruct total from events: start with all rev_init amounts, then apply deltas
+ let init_amounts: [(u64, i128); 3] = [(1, 100), (2, 60), (3, 40)];
+ let mut reconstructed_total: i128 = init_amounts.iter().map(|(_, a)| a).sum();
+ for (new_amount, _pid, old_amount) in collect_override_events(&env, start_idx) {
+ reconstructed_total = reconstructed_total.saturating_add(new_amount - old_amount);
+ }
+
+ let summary = client.get_audit_summary(&issuer, &ns, &token).unwrap();
+ assert_eq!(
+ reconstructed_total, summary.total_revenue,
+ "event-reconstructed total_revenue must match AuditSummary"
+ );
+ assert_eq!(summary.total_revenue, 250);
+ assert_eq!(summary.report_count, 3, "report_count must not change on override");
+}
+
+/// Overriding with a decreasing amount must still be reconstructible and
+/// must correctly reduce total_revenue.
+///
+/// init=500, override→100: delta = -400, expected total = 100
+#[test]
+fn override_audit_trail_decreasing_amount_reconstructible() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+ let period_id: u64 = 1;
+
+ let start_idx = env.events().all().len();
+
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &500, &period_id, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &100, &period_id, &true);
+
+ let persisted = client.get_revenue_by_period(&issuer, &ns, &token, &period_id);
+ let reconstructed = reconstruct_from_events(&env, start_idx, period_id);
+
+ assert_eq!(reconstructed, persisted);
+ assert_eq!(persisted, 100);
+
+ let summary = client.get_audit_summary(&issuer, &ns, &token).unwrap();
+ assert_eq!(summary.total_revenue, 100);
+
+ // Verify the single override event carries the correct delta
+ let overrides = collect_override_events(&env, start_idx);
+ assert_eq!(overrides.len(), 1);
+ let (new_amount, pid, old_amount) = overrides[0];
+ assert_eq!(new_amount, 100);
+ assert_eq!(old_amount, 500);
+ assert_eq!(pid, period_id);
+}
+
+/// Overriding with the same amount (no-op delta) must still emit rev_ovrd
+/// and the reconstructed amount must equal the persisted value.
+#[test]
+fn override_audit_trail_same_amount_emits_event_and_is_reconstructible() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+ let period_id: u64 = 1;
+
+ let start_idx = env.events().all().len();
+
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &300, &period_id, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &300, &period_id, &true);
+
+ let persisted = client.get_revenue_by_period(&issuer, &ns, &token, &period_id);
+ let reconstructed = reconstruct_from_events(&env, start_idx, period_id);
+
+ assert_eq!(reconstructed, persisted);
+ assert_eq!(persisted, 300);
+
+ // rev_ovrd must still be emitted even when new == old
+ let overrides = collect_override_events(&env, start_idx);
+ assert_eq!(overrides.len(), 1, "rev_ovrd must be emitted even for same-amount override");
+ let (new_amount, _pid, old_amount) = overrides[0];
+ assert_eq!(new_amount, 300);
+ assert_eq!(old_amount, 300);
+
+ // AuditSummary must be unchanged (delta = 0)
+ let summary = client.get_audit_summary(&issuer, &ns, &token).unwrap();
+ assert_eq!(summary.total_revenue, 300);
+ assert_eq!(summary.report_count, 1);
+}
+
+/// Saturating override: init near i128::MAX, then override to i128::MAX.
+/// The reconstructed amount must equal the persisted value and not overflow.
+#[test]
+fn override_audit_trail_saturating_override_reconstructible() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+ let period_id: u64 = 1;
+
+ let start_idx = env.events().all().len();
+
+ let near_max: i128 = i128::MAX - 1;
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &near_max, &period_id, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &i128::MAX, &period_id, &true);
+
+ let persisted = client.get_revenue_by_period(&issuer, &ns, &token, &period_id);
+ let reconstructed = reconstruct_from_events(&env, start_idx, period_id);
+
+ assert_eq!(reconstructed, persisted);
+ assert_eq!(persisted, i128::MAX);
+
+ let overrides = collect_override_events(&env, start_idx);
+ assert_eq!(overrides.len(), 1);
+ let (new_amount, _pid, old_amount) = overrides[0];
+ assert_eq!(new_amount, i128::MAX);
+ assert_eq!(old_amount, near_max);
+}
+
+/// rev_ovrd events for different periods must not interfere with each other's
+/// reconstruction. Two periods each overridden independently must both
+/// reconstruct correctly.
+#[test]
+fn override_audit_trail_independent_periods_do_not_interfere() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+
+ let start_idx = env.events().all().len();
+
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &100, &1, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &200, &2, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &999, &1, &true);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &777, &2, &true);
+
+ for period_id in [1u64, 2u64] {
+ let persisted = client.get_revenue_by_period(&issuer, &ns, &token, &period_id);
+ let reconstructed = reconstruct_from_events(&env, start_idx, period_id);
+ assert_eq!(
+ reconstructed, persisted,
+ "period {period_id}: reconstructed must equal persisted"
+ );
+ }
+
+ assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &1), 999);
+ assert_eq!(client.get_revenue_by_period(&issuer, &ns, &token, &2), 777);
+}
+
+/// rev_ovra (asset-tagged override) event must carry the same (new_amount,
+/// old_amount, period_id) as rev_ovrd, plus the payout_asset address.
+#[test]
+fn override_audit_trail_rev_ovra_payload_matches_rev_ovrd() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+ let period_id: u64 = 1;
+
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &100, &period_id, &false);
+
+ let before = env.events().all().len();
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &400, &period_id, &true);
+
+ let rev_ovrd_sym: Symbol = symbol_short!("rev_ovrd");
+ let rev_ovra_sym: Symbol = symbol_short!("rev_ovra");
+ let all = env.events().all();
+
+ let mut ovrd_payload: Option<(i128, u64, i128)> = None;
+ let mut ovra_payload: Option<(Address, i128, u64, i128)> = None;
+
+ for i in before..all.len() {
+ let (_, topics, data) = all.get(i).unwrap();
+ let topics_vec: SdkVec = topics.into_val(&env);
+ let sym: Symbol = topics_vec.get(0).unwrap().into_val(&env);
+ let data_vec: SdkVec = data.into_val(&env);
+
+ if sym == rev_ovrd_sym {
+ let new_amount: i128 = data_vec.get(0).unwrap().into_val(&env);
+ let pid: u64 = data_vec.get(1).unwrap().into_val(&env);
+ let old_amount: i128 = data_vec.get(2).unwrap().into_val(&env);
+ ovrd_payload = Some((new_amount, pid, old_amount));
+ } else if sym == rev_ovra_sym {
+ let asset: Address = data_vec.get(0).unwrap().into_val(&env);
+ let new_amount: i128 = data_vec.get(1).unwrap().into_val(&env);
+ let pid: u64 = data_vec.get(2).unwrap().into_val(&env);
+ let old_amount: i128 = data_vec.get(3).unwrap().into_val(&env);
+ ovra_payload = Some((asset, new_amount, pid, old_amount));
+ }
+ }
+
+ let (ovrd_new, ovrd_pid, ovrd_old) = ovrd_payload.expect("rev_ovrd must be emitted");
+ let (asset, ovra_new, ovra_pid, ovra_old) = ovra_payload.expect("rev_ovra must be emitted");
+
+ assert_eq!(ovrd_new, ovra_new, "new_amount must match between rev_ovrd and rev_ovra");
+ assert_eq!(ovrd_old, ovra_old, "old_amount must match between rev_ovrd and rev_ovra");
+ assert_eq!(ovrd_pid, ovra_pid, "period_id must match between rev_ovrd and rev_ovra");
+ assert_eq!(asset, payout_asset, "rev_ovra must carry the correct payout_asset");
+ assert_eq!(ovrd_new, 400);
+ assert_eq!(ovrd_old, 100);
+ assert_eq!(ovrd_pid, period_id);
+}
+
+/// report_count must never increase on override; only rev_init events
+/// contribute to the count. Verified across N overrides.
+#[test]
+fn override_audit_trail_report_count_unchanged_across_overrides() {
+ let (env, contract_id, issuer, token, payout_asset) = setup();
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let ns = symbol_short!("def");
+
+ // Two initial periods
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &100, &1, &false);
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &200, &2, &false);
+
+ let count_before = client.get_audit_summary(&issuer, &ns, &token).unwrap().report_count;
+ assert_eq!(count_before, 2);
+
+ // Five overrides across both periods
+ for amt in [50i128, 75, 90, 110, 130] {
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &amt, &1, &true);
+ }
+ client.report_revenue(&issuer, &ns, &token, &payout_asset, &999, &2, &true);
+
+ let summary = client.get_audit_summary(&issuer, &ns, &token).unwrap();
+ assert_eq!(
+ summary.report_count, count_before,
+ "report_count must not change after overrides"
+ );
+ assert_eq!(summary.report_count, 2);
+}
diff --git a/src/vesting.rs b/src/vesting.rs
index 9ee402e4..a9bfdfc5 100644
--- a/src/vesting.rs
+++ b/src/vesting.rs
@@ -218,7 +218,7 @@ pub fn migrate_offering_schedules(
return Ok(Vec::new(env));
}
- let mut beneficiaries: Vec = Vec::new(env);
+ let mut beneficiaries: soroban_sdk::Vec = Vec::new(env);
for i in 0..count {
if let Some(beneficiary) = env
.storage()
@@ -240,23 +240,18 @@ pub fn migrate_offering_schedules(
// First pass: validate that no schedule is pre-cliff.
for beneficiary in beneficiaries.iter() {
- let schedule_opt: Option =
- env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone()));
- if let Some(schedule) = schedule_opt {
- if schedule.issuer == offering_id.issuer
- && schedule.token == offering_id.token
- && now < schedule.cliff_ts
- {
- return Err(VestingError::SchedulePreCliff);
+ 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);
+ }
}
}
}
// Second pass: migrate matching schedules and rebuild the beneficiary index.
for beneficiary in beneficiaries.iter() {
- let schedule_opt: Option =
- env.storage().persistent().get(&VestingKey::Schedule(beneficiary.clone()));
- if let Some(mut schedule) = schedule_opt {
+ if let Some(mut schedule) = env.storage().persistent().get::(&VestingKey::Schedule(beneficiary.clone())) {
if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token {
schedule.issuer = new_issuer.clone();
env.storage()