From 97a09dd543135a880520ff65b6cc8ef678593ecb Mon Sep 17 00:00:00 2001 From: victortanimu05-stack Date: Thu, 30 Jul 2026 06:23:39 +0000 Subject: [PATCH] feat: add dividend auto-reinvestment path - Add AutoReinvestConfig struct (enabled + nav_per_share_e7) - Add AutoReinvestInvalidNav=62 and AutoReinvestNotEnabled=63 error codes - Add EVENT_DIVIDEND_REINVEST event constant - Add DataKey2::AutoReinvest(OfferingId, Address) storage key - Add set_auto_reinvest / get_auto_reinvest entrypoints (holder-authed) - Fork claim() in plain impl block: when enabled, convert dividend to share_delta = floor(payout / nav_per_share_e7) via set_holder_share_internal; supply cap / vesting rejections fall back gracefully to cash transfer - Add test_accrual_ledger module with 9 tests: happy path, disabled, nav=0 guard, cap-exhausted fallback, no-config, tiny dividend, toggle off --- src/lib.rs | 777 +++++++++++++++++++++++++------------ src/test_accrual_ledger.rs | 197 +++++++++- 2 files changed, 728 insertions(+), 246 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a6c13b3ff..036b1dc12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -211,6 +211,10 @@ pub enum RevoraError { MaxDisputesReached = 60, /// The caller holds zero shares in the offering and cannot open a dispute. DisputeZeroShare = 61, + /// NAV per share must be strictly positive for auto-reinvestment. + AutoReinvestInvalidNav = 62, + /// Auto-reinvestment is not enabled for this holder. + AutoReinvestNotEnabled = 63, } pub mod vesting; @@ -224,14 +228,16 @@ mod test_claim_transfer_fail; mod test_compute_share_invariants; #[cfg(test)] mod test_duplicates; -#[cfg(test)] -mod test_time_windows; mod test_event_indexed_v2; #[cfg(test)] mod test_min_revenue_threshold_boundary; +#[cfg(test)] +mod test_time_windows; // #[cfg(test)] // mod test_claim_transfer_fail; #[cfg(test)] +mod test_accrual_ledger; +#[cfg(test)] mod test_close_period; #[cfg(test)] mod test_disclosure; @@ -318,7 +324,6 @@ pub struct Proposal { pub quorum_bps: u32, } - const EVENT_SNAP_CONFIG: Symbol = symbol_short!("snap_cfg"); const EVENT_INIT: Symbol = symbol_short!("init"); @@ -439,6 +444,8 @@ const MIN_ISSUER_TRANSFER_EXPIRY_SECS: u64 = 60 * 60; const MAX_ISSUER_TRANSFER_EXPIRY_SECS: u64 = 30 * 24 * 60 * 60; const EVENT_CLAIM: Symbol = symbol_short!("claim"); const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set"); +/// Emitted when a holder's dividend is auto-reinvested as additional shares. +const EVENT_DIVIDEND_REINVEST: Symbol = symbol_short!("div_rein"); // v1 versioned event symbols (legacy) /// Represents a revenue-share offering registered on-chain. @@ -655,7 +662,6 @@ pub struct TransferRestrictions { pub max_holders: u32, } - /// Read-only comparison between stored audit state and recomputed report state. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -732,6 +738,35 @@ pub struct HolderAccrualState { pub accrued_owed: i128, } +/// Per-holder auto-reinvestment configuration for an offering. +/// +/// When `enabled` is `true`, the `claim` function converts the owed dividend +/// into additional basis-point shares instead of transferring tokens. +/// +/// ### NAV precision +/// `nav_per_share_e7` is the net-asset-value per one basis-point share expressed +/// in the offering's payment-token units at 7-decimal (canonical Stellar) precision. +/// For example, if 1 bps-share is worth 0.05 USDC (5_000_000 in 7-decimal units), +/// set `nav_per_share_e7 = 5_000_000`. +/// +/// ### Share-delta formula +/// ``` +/// share_delta_bps = total_payout_e7 / nav_per_share_e7 +/// ``` +/// Fractional bps are truncated (floor division) to remain conservative. +/// The resulting `share_delta_bps` must be ≥ 1 for the reinvestment to be +/// applied; otherwise the call succeeds but acts as a normal zero-transfer claim +/// (the tiny fractional amount is left unclaimed and the period index still +/// advances). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AutoReinvestConfig { + /// Whether auto-reinvestment is active for this holder. + pub enabled: bool, + /// NAV per basis-point share in 7-decimal payment-token units (must be > 0). + pub nav_per_share_e7: i128, +} + /// Versioned structured topic payload for indexers. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -819,7 +854,6 @@ pub struct PendingRedemption { pub timestamp: u64, } - #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum WindowDataKey { @@ -841,7 +875,6 @@ pub enum MetaDataKey { RevenueApproved(OfferingId, u64), } - /// Defines how fractional shares are handled during distribution calculations. #[contracttype] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -852,7 +885,6 @@ pub enum RoundingMode { RoundHalfUp = 1, } - /// Immutable record of a committed snapshot for an offering. /// /// A snapshot captures the canonical state of holder shares at a specific point in time, @@ -882,7 +914,6 @@ pub struct SnapshotEntry { pub total_bps: u32, } - /// Primary storage keys for core contract state. /// Split from the full key set to stay within the Soroban XDR union variant limit (≤50). /// @@ -1069,7 +1100,6 @@ pub enum DataKey2 { GlobalFreezeReason, // ── Missing variants added for compilation ── - /// Current accrual index counter for dividend-accrual ledger. AccrualIndex(OfferingId), /// Per-offering platform fee model. @@ -1112,6 +1142,10 @@ pub enum DataKey2 { GovProposal(OfferingId, u32), /// Vote record for (offering_id, proposal_id, voter) -> bool (true=yes, false=no). VoteRecord(OfferingId, u32, Address), + + // ── Dividend auto-reinvestment (feat/dividend-reinvest) ── + /// Per-holder auto-reinvestment configuration: enabled flag + NAV per share. + AutoReinvest(OfferingId, Address), } /// Maximum number of offerings returned in a single page. @@ -1429,10 +1463,8 @@ impl RevoraRevenueShare { /// # Errors /// - [`RevoraError::MigrationDowngradeNotAllowed`] if `CONTRACT_VERSION < persisted version`. fn assert_contract_version_compatible(env: &Env) -> Result<(), RevoraError> { - if let Some(min_supported) = env - .storage() - .persistent() - .get::(&DataKey::DeployedVersion) + if let Some(min_supported) = + env.storage().persistent().get::(&DataKey::DeployedVersion) { if CONTRACT_VERSION < min_supported { env.events().publish( @@ -1507,11 +1539,21 @@ impl RevoraRevenueShare { /// Check if a holder is emergency frozen for an offering. fn is_frozen(env: &Env, offering_id: &OfferingId, holder: &Address) -> bool { - env.storage().persistent().get::(&DataKey2::EmergencyFreeze(offering_id.clone(), holder.clone())).is_some() + env.storage() + .persistent() + .get::(&DataKey2::EmergencyFreeze( + offering_id.clone(), + holder.clone(), + )) + .is_some() } /// Require that a holder is not emergency frozen. - fn require_not_frozen(env: &Env, offering_id: &OfferingId, holder: &Address) -> Result<(), RevoraError> { + fn require_not_frozen( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> Result<(), RevoraError> { if Self::is_frozen(env, offering_id, holder) { return Err(RevoraError::HolderFrozen); } @@ -1519,8 +1561,13 @@ impl RevoraRevenueShare { } /// Require that caller is either admin or issuer of the offering. - fn require_admin_or_issuer(env: &Env, caller: &Address, offering_id: &OfferingId) -> Result<(), RevoraError> { - let admin: Address = env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; + fn require_admin_or_issuer( + env: &Env, + caller: &Address, + offering_id: &OfferingId, + ) -> Result<(), RevoraError> { + let admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; if caller == &admin || caller == &offering_id.issuer { return Ok(()); } @@ -1730,13 +1777,16 @@ impl RevoraRevenueShare { let max_shares: i128 = env.storage().persistent().get(&max_shares_key).unwrap_or(0); if max_shares > 0 { let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); - let current_total_shares: i128 = env.storage().persistent().get(&total_shares_key).unwrap_or(0); + let current_total_shares: i128 = + env.storage().persistent().get(&total_shares_key).unwrap_or(0); let old_share: u32 = env .storage() .persistent() .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) .unwrap_or(0); - let new_total_shares = current_total_shares.saturating_sub(old_share as i128).saturating_add(share_bps as i128); + let new_total_shares = current_total_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); if new_total_shares > max_shares { return Err(RevoraError::MaxTotalSupplySharesExceeded); } @@ -1763,15 +1813,19 @@ impl RevoraRevenueShare { } } - let new_total = current_total.s_sub(old_share).unwrap_or(0).s_add(share_bps).unwrap_or(u32::MAX); + let new_total = + current_total.s_sub(old_share).unwrap_or(0).s_add(share_bps).unwrap_or(u32::MAX); if new_total > 10_000 { return Err(RevoraError::InvalidShareBps); } // Update total shares issued let total_shares_key = DataKey2::TotalSharesIssued(offering_id.clone()); - let current_total_shares: i128 = env.storage().persistent().get(&total_shares_key).unwrap_or(0); - let new_total_shares = current_total_shares.saturating_sub(old_share as i128).saturating_add(share_bps as i128); + let current_total_shares: i128 = + env.storage().persistent().get(&total_shares_key).unwrap_or(0); + let new_total_shares = current_total_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); env.storage().persistent().set(&total_shares_key, &new_total_shares); // Persist updated holder share and running total. @@ -1824,14 +1878,9 @@ impl RevoraRevenueShare { offering_id: &OfferingId, holder: &Address, ) -> Vec { - if let Some(schedule) = env - .storage() - .persistent() - .get::<_, Vec>(&DataKey2::HolderShareSchedule( - offering_id.clone(), - holder.clone(), - )) - { + if let Some(schedule) = env.storage().persistent().get::<_, Vec>( + &DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), + ) { return schedule; } @@ -1870,11 +1919,11 @@ impl RevoraRevenueShare { updated.push_back(checkpoint); } - updated.push_back(HolderShareCheckpoint { start_index: period_count, share_bps: new_share }); - env.storage().persistent().set( - &DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), - &updated, - ); + updated + .push_back(HolderShareCheckpoint { start_index: period_count, share_bps: new_share }); + env.storage() + .persistent() + .set(&DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), &updated); } fn get_holder_accrual_state( @@ -1956,8 +2005,11 @@ impl RevoraRevenueShare { } if current_share > 0 { - let acc_end = - Self::get_acc_per_share_at_index(env, offering_id, current_index.saturating_add(1)); + let acc_end = Self::get_acc_per_share_at_index( + env, + offering_id, + current_index.saturating_add(1), + ); let acc_start = Self::get_acc_per_share_at_index(env, offering_id, current_index); let delta = acc_end.saturating_sub(acc_start); total = total.saturating_add( @@ -2007,7 +2059,8 @@ impl RevoraRevenueShare { fn cache_holder_accrual_through_matured(env: &Env, offering_id: &OfferingId, holder: &Address) { let mut state = Self::get_holder_accrual_state(env, offering_id, holder); - let matured_end = Self::find_matured_claim_end_idx(env, offering_id, state.last_settled_idx); + let matured_end = + Self::find_matured_claim_end_idx(env, offering_id, state.last_settled_idx); if matured_end <= state.last_settled_idx { return; } @@ -2021,12 +2074,12 @@ impl RevoraRevenueShare { ); state.accrued_owed = state.accrued_owed.saturating_add(delta); state.last_settled_idx = matured_end; - state.last_acc_per_share_e18 = Self::get_acc_per_share_at_index(env, offering_id, matured_end); + state.last_acc_per_share_e18 = + Self::get_acc_per_share_at_index(env, offering_id, matured_end); - env.storage().persistent().set( - &DataKey2::HolderAccrualState(offering_id.clone(), holder.clone()), - &state, - ); + env.storage() + .persistent() + .set(&DataKey2::HolderAccrualState(offering_id.clone(), holder.clone()), &state); } fn normalize_jurisdictions(env: &Env, jurisdictions: Vec) -> Vec { @@ -2233,10 +2286,9 @@ impl RevoraRevenueShare { let current_acc: i128 = env.storage().persistent().get(&global_acc_key).unwrap_or(0); let next_acc = current_acc.saturating_add(acc_delta_e18); env.storage().persistent().set(&global_acc_key, &next_acc); - env.storage().persistent().set( - &DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), - &next_acc, - ); + env.storage() + .persistent() + .set(&DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), &next_acc); // Update cumulative deposited revenue and emit cap-reached event if applicable (#96) let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); @@ -2253,17 +2305,24 @@ impl RevoraRevenueShare { } // Update the e18 accrual index - let decimals = Self::get_payment_token_decimals(env.clone(), issuer.clone(), namespace.clone(), token.clone()); + let decimals = Self::get_payment_token_decimals( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ); let normalized_amount = Self::normalize_amount(amount, decimals); let total_share_bps_key = DataKey::HolderShareTotal(offering_id.clone()); - let total_share_bps: u32 = env.storage().persistent().get(&total_share_bps_key).unwrap_or(0); - + let total_share_bps: u32 = + env.storage().persistent().get(&total_share_bps_key).unwrap_or(0); + if total_share_bps > 0 { let accrual_delta = (normalized_amount.checked_mul(E18)) .and_then(|x| x.checked_div(total_share_bps as i128)) .unwrap_or(0); let current_accrual_key = DataKey::AccrualIndexE18(offering_id.clone()); - let current_accrual: i128 = env.storage().persistent().get(¤t_accrual_key).unwrap_or(0); + let current_accrual: i128 = + env.storage().persistent().get(¤t_accrual_key).unwrap_or(0); let new_accrual = current_accrual.checked_add(accrual_delta).unwrap_or(current_accrual); env.storage().persistent().set(¤t_accrual_key, &new_accrual); } @@ -2298,9 +2357,10 @@ impl RevoraRevenueShare { Self::require_not_paused(&env)?; issuer.require_auth(); - if let Err((err, _)) = - AmountValidationMatrix::validate(max_total_supply_shares, AmountValidationCategory::MaxTotalSupplyShares) - { + if let Err((err, _)) = AmountValidationMatrix::validate( + max_total_supply_shares, + AmountValidationCategory::MaxTotalSupplyShares, + ) { return Err(err); } @@ -2314,12 +2374,22 @@ impl RevoraRevenueShare { Ok(()) } - pub fn get_max_total_supply_shares(env: Env, issuer: Address, namespace: Symbol, token: Address) -> i128 { + pub fn get_max_total_supply_shares( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> i128 { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().get(&DataKey2::MaxTotalSupplyShares(offering_id)).unwrap_or(0) } - pub fn get_total_shares_issued(env: Env, issuer: Address, namespace: Symbol, token: Address) -> i128 { + pub fn get_total_shares_issued( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> i128 { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id)).unwrap_or(0) } @@ -3496,11 +3566,7 @@ impl RevoraRevenueShare { let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); let offering = Offering { - issuers: Issuers { - primary: primary_issuer.clone(), - co: co_issuers.clone(), - quorum, - }, + issuers: Issuers { primary: primary_issuer.clone(), co: co_issuers.clone(), quorum }, namespace: namespace.clone(), token: token.clone(), revenue_share_bps, @@ -3720,15 +3786,8 @@ impl RevoraRevenueShare { return Err(RevoraError::OfferingNotFound); } - let config = FxOracleConfig { - oracle, - revenue_symbol, - payout_symbol, - max_oracle_age_secs, - }; - env.storage() - .persistent() - .set(&DataKey2::FxOracleConfig(offering_id), &config); + let config = FxOracleConfig { oracle, revenue_symbol, payout_symbol, max_oracle_age_secs }; + env.storage().persistent().set(&DataKey2::FxOracleConfig(offering_id), &config); Ok(()) } @@ -3783,9 +3842,8 @@ impl RevoraRevenueShare { &sig, &config.oracle, )?; - let decoded: (i128, u64) = env - .from_xdr(&q) - .map_err(|_| RevoraError::MetadataInvalidFormat)?; + let decoded: (i128, u64) = + env.from_xdr(&q).map_err(|_| RevoraError::MetadataInvalidFormat)?; decoded } else { FxOracleClient::new(env, &config.oracle) @@ -4220,7 +4278,8 @@ impl RevoraRevenueShare { .persistent() .get(&summary_key) .unwrap_or(AuditSummary { total_revenue: 0, report_count: 0 }); - summary.total_revenue = summary.total_revenue.s_add(amount).unwrap_or(i128::MAX); + summary.total_revenue = + summary.total_revenue.s_add(amount).unwrap_or(i128::MAX); summary.report_count = summary.report_count.s_add(1).unwrap_or(u64::MAX); env.storage().persistent().set(&summary_key, &summary); @@ -4606,7 +4665,7 @@ impl RevoraRevenueShare { // Validate attestation timestamp: attested_at must not be in the future if attestation.attested_at > env.ledger().timestamp() { return Err(RevoraError::InvalidAmount); // Wait, let's check error codes - // Wait, let's use a proper error? Wait let's check RevoraError + // Wait, let's use a proper error? Wait let's check RevoraError } let offering_id = OfferingId { @@ -4639,7 +4698,8 @@ impl RevoraRevenueShare { } } - env.events().publish((EVENT_BL_ADD, issuer, namespace, token), (caller, investor, attestation)); + env.events() + .publish((EVENT_BL_ADD, issuer, namespace, token), (caller, investor, attestation)); Ok(()) } @@ -5567,10 +5627,7 @@ impl RevoraRevenueShare { }; let key = DataKey2::TransferRestrictions(offering_id, category.clone()); - let restrictions = TransferRestrictions { - category, - max_holders, - }; + let restrictions = TransferRestrictions { category, max_holders }; env.storage().persistent().set(&key, &restrictions); Ok(()) } @@ -5618,16 +5675,20 @@ impl RevoraRevenueShare { if let Some(existing) = existing_cat { if existing != category { if to_share > 0 { - let old_count_key = DataKey2::CategoryHolderCount(offering_id.clone(), existing); - let old_count: u32 = env.storage().persistent().get(&old_count_key).unwrap_or(0); + let old_count_key = + DataKey2::CategoryHolderCount(offering_id.clone(), existing); + let old_count: u32 = + env.storage().persistent().get(&old_count_key).unwrap_or(0); env.storage().persistent().set(&old_count_key, &old_count.saturating_sub(1)); - - let new_count_key = DataKey2::CategoryHolderCount(offering_id.clone(), category.clone()); - let new_count: u32 = env.storage().persistent().get(&new_count_key).unwrap_or(0); - if let Some(restrictions) = env - .storage() - .persistent() - .get::<_, TransferRestrictions>(&DataKey2::TransferRestrictions(offering_id.clone(), category.clone())) + + let new_count_key = + DataKey2::CategoryHolderCount(offering_id.clone(), category.clone()); + let new_count: u32 = + env.storage().persistent().get(&new_count_key).unwrap_or(0); + if let Some(restrictions) = + env.storage().persistent().get::<_, TransferRestrictions>( + &DataKey2::TransferRestrictions(offering_id.clone(), category.clone()), + ) { if new_count >= restrictions.max_holders { env.storage().persistent().set(&old_count_key, &old_count); @@ -5642,7 +5703,14 @@ impl RevoraRevenueShare { env.storage().persistent().set(&cat_key, &category); } - Self::set_holder_share_internal(&env, issuer.clone(), namespace.clone(), token.clone(), from.clone(), from_share - amount_bps)?; + Self::set_holder_share_internal( + &env, + issuer.clone(), + namespace.clone(), + token.clone(), + from.clone(), + from_share - amount_bps, + )?; Self::set_holder_share_internal(&env, issuer, namespace, token, to, to_share + amount_bps)?; Ok(()) @@ -6537,7 +6605,10 @@ impl RevoraRevenueShare { let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone()); let max_shares: i128 = env.storage().persistent().get(&max_shares_key).unwrap_or(0); let mut temp_total_shares: i128 = if max_shares > 0 { - env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id.clone())).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey2::TotalSharesIssued(offering_id.clone())) + .unwrap_or(0) } else { 0 }; @@ -6608,10 +6679,16 @@ impl RevoraRevenueShare { // Update total shares issued if max_shares > 0 { - env.storage().persistent().set(&DataKey2::TotalSharesIssued(offering_id.clone()), &temp_total_shares); + env.storage() + .persistent() + .set(&DataKey2::TotalSharesIssued(offering_id.clone()), &temp_total_shares); } else { // If no cap, still track total shares - let mut total_shares: i128 = env.storage().persistent().get(&DataKey2::TotalSharesIssued(offering_id.clone())).unwrap_or(0); + let mut total_shares: i128 = env + .storage() + .persistent() + .get(&DataKey2::TotalSharesIssued(offering_id.clone())) + .unwrap_or(0); for i in 0..batch_len { let (holder, share_bps) = holders.get(i).unwrap(); let old_share: u32 = env @@ -6619,9 +6696,13 @@ impl RevoraRevenueShare { .persistent() .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) .unwrap_or(0); - total_shares = total_shares.saturating_sub(old_share as i128).saturating_add(share_bps as i128); + total_shares = total_shares + .saturating_sub(old_share as i128) + .saturating_add(share_bps as i128); } - env.storage().persistent().set(&DataKey2::TotalSharesIssued(offering_id.clone()), &total_shares); + env.storage() + .persistent() + .set(&DataKey2::TotalSharesIssued(offering_id.clone()), &total_shares); } // Update snapshot metadata. @@ -6679,7 +6760,6 @@ impl RevoraRevenueShare { env.storage().persistent().get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index)) } - /// Set a holder's revenue share in basis points for an offering. pub fn set_holder_share( env: Env, @@ -6896,11 +6976,11 @@ impl RevoraRevenueShare { Self::require_not_frozen(&env)?; Self::require_not_paused(&env)?; issuer.require_auth(); - + if ratio_bps == 0 { return Err(RevoraError::InvalidConversionRatio); } - + let offering_id = OfferingId { issuer, namespace, token }; let key = DataKey2::ClassConversionRatio(offering_id, from_class, to_class); env.storage().persistent().set(&key, &ratio_bps); @@ -6924,29 +7004,43 @@ impl RevoraRevenueShare { let offering_id = OfferingId { issuer, namespace, token }; - if let Some(schedule) = env.storage().persistent().get::<_, crate::vesting::VestingSchedule>(&crate::vesting::VestingKey::Schedule(holder.clone())) { - let vested = crate::vesting::VestingContract::get_vested_amount(env.clone(), holder.clone()).unwrap_or(0); + if let Some(schedule) = + env.storage().persistent().get::<_, crate::vesting::VestingSchedule>( + &crate::vesting::VestingKey::Schedule(holder.clone()), + ) + { + let vested = + crate::vesting::VestingContract::get_vested_amount(env.clone(), holder.clone()) + .unwrap_or(0); if schedule.total_amount > vested { return Err(RevoraError::UnvestedConversionBlocked); } } - let ratio_key = DataKey2::ClassConversionRatio(offering_id.clone(), from_class.clone(), to_class.clone()); - let ratio_bps: u32 = env.storage().persistent().get(&ratio_key).ok_or(RevoraError::ConversionNotApproved)?; + let ratio_key = DataKey2::ClassConversionRatio( + offering_id.clone(), + from_class.clone(), + to_class.clone(), + ); + let ratio_bps: u32 = + env.storage().persistent().get(&ratio_key).ok_or(RevoraError::ConversionNotApproved)?; if ratio_bps == 0 { return Err(RevoraError::InvalidConversionRatio); } - let from_key = DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), from_class.clone()); - let to_key = DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), to_class.clone()); + let from_key = + DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), from_class.clone()); + let to_key = + DataKey2::HolderShareClass(offering_id.clone(), holder.clone(), to_class.clone()); let from_balance: u32 = env.storage().persistent().get(&from_key).unwrap_or(0); if from_balance < amount_bps { return Err(RevoraError::InsufficientClassBalance); } - let converted_amount_bps = ((amount_bps as u64).saturating_mul(ratio_bps as u64) / 10000) as u32; + let converted_amount_bps = + ((amount_bps as u64).saturating_mul(ratio_bps as u64) / 10000) as u32; let to_balance: u32 = env.storage().persistent().get(&to_key).unwrap_or(0); @@ -6957,20 +7051,28 @@ impl RevoraRevenueShare { env.storage().persistent().set(&to_key, &new_to); let classes_key = DataKey2::OfferingClasses(offering_id.clone()); - if let Some(mut cls_vec) = env.storage().persistent().get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key) { + if let Some(mut cls_vec) = + env.storage().persistent().get::<_, Vec<(ShareClass, ClassConfig)>>(&classes_key) + { let mut from_idx = None; let mut to_idx = None; for (i, (sc, _)) in cls_vec.iter().enumerate() { - if *sc == from_class { from_idx = Some(i as u32); } - if *sc == to_class { to_idx = Some(i as u32); } + if *sc == from_class { + from_idx = Some(i as u32); + } + if *sc == to_class { + to_idx = Some(i as u32); + } } if let (Some(f_idx), Some(t_idx)) = (from_idx, to_idx) { let (_, mut f_cfg) = cls_vec.get(f_idx).unwrap(); let (_, mut t_cfg) = cls_vec.get(t_idx).unwrap(); - - f_cfg.bps = f_cfg.bps.checked_sub(amount_bps).ok_or(RevoraError::InvalidShareBps)?; - t_cfg.bps = t_cfg.bps.checked_add(amount_bps).ok_or(RevoraError::InvalidShareBps)?; - + + f_cfg.bps = + f_cfg.bps.checked_sub(amount_bps).ok_or(RevoraError::InvalidShareBps)?; + t_cfg.bps = + t_cfg.bps.checked_add(amount_bps).ok_or(RevoraError::InvalidShareBps)?; + cls_vec.set(f_idx, (from_class.clone(), f_cfg)); cls_vec.set(t_idx, (to_class.clone(), t_cfg)); env.storage().persistent().set(&classes_key, &cls_vec); @@ -6979,7 +7081,7 @@ impl RevoraRevenueShare { env.events().publish( (soroban_sdk::symbol_short!("class_conv"), offering_id, holder), - (from_class, from_balance, new_from, to_class, to_balance, new_to) + (from_class, from_balance, new_from, to_class, to_balance, new_to), ); Ok(()) @@ -7010,17 +7112,11 @@ impl RevoraRevenueShare { namespace: namespace.clone(), token: token.clone(), }; - env.storage().persistent().set( - &DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), - &jurisdiction, - ); + env.storage() + .persistent() + .set(&DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), &jurisdiction); env.events().publish( - ( - Self::jurisdiction_set_event(&env), - issuer, - namespace, - token, - ), + (Self::jurisdiction_set_event(&env), issuer, namespace, token), (EVENT_JUR_SCOPE_HOLDER, holder, jurisdiction), ); Ok(()) @@ -7065,16 +7161,9 @@ impl RevoraRevenueShare { token: token.clone(), }; let normalized = Self::normalize_jurisdictions(&env, jurisdictions); - env.storage() - .persistent() - .set(&DataKey2::AllowedJurisdictions(offering_id), &normalized); + env.storage().persistent().set(&DataKey2::AllowedJurisdictions(offering_id), &normalized); env.events().publish( - ( - Self::jurisdiction_set_event(&env), - issuer, - namespace, - token, - ), + (Self::jurisdiction_set_event(&env), issuer, namespace, token), (EVENT_JUR_SCOPE_ALLOW, normalized), ); Ok(()) @@ -7172,41 +7261,87 @@ impl RevoraRevenueShare { } /// Configure the reporting access window for an offering. If unset, always open. - pub fn set_report_window(env: Env, issuer: Address, namespace: Symbol, token: Address, start_timestamp: u64, end_timestamp: u64) -> Result<(), RevoraError> { + pub fn set_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; - let current_issuer = Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()).ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { return Err(RevoraError::OfferingNotFound); } + let current_issuer = + Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } issuer.require_auth(); let window = AccessWindow { start_timestamp, end_timestamp }; Self::validate_window(&window)?; - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; env.storage().persistent().set(&WindowDataKey::Report(offering_id), &window); - env.events().publish((EVENT_REPORT_WINDOW_SET, issuer, namespace, token), (start_timestamp, end_timestamp)); + env.events().publish( + (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); Ok(()) } /// Configure the claiming access window for an offering. If unset, always open. - pub fn set_claim_window(env: Env, issuer: Address, namespace: Symbol, token: Address, start_timestamp: u64, end_timestamp: u64) -> Result<(), RevoraError> { + pub fn set_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; - let current_issuer = Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()).ok_or(RevoraError::OfferingNotFound)?; - if current_issuer != issuer { return Err(RevoraError::OfferingNotFound); } + let current_issuer = + Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } issuer.require_auth(); let window = AccessWindow { start_timestamp, end_timestamp }; Self::validate_window(&window)?; - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; env.storage().persistent().set(&WindowDataKey::Claim(offering_id), &window); - env.events().publish((EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), (start_timestamp, end_timestamp)); + env.events().publish( + (EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); Ok(()) } /// Read configured reporting window (if any) for an offering. - pub fn get_report_window(env: Env, issuer: Address, namespace: Symbol, token: Address) -> Option { + pub fn get_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().get(&WindowDataKey::Report(offering_id)) } /// Read configured claiming window (if any) for an offering. - pub fn get_claim_window(env: Env, issuer: Address, namespace: Symbol, token: Address) -> Option { + pub fn get_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().get(&WindowDataKey::Claim(offering_id)) } @@ -7432,9 +7567,16 @@ impl RevoraRevenueShare { issuer.require_auth(); let window = AccessWindow { start_timestamp, end_timestamp }; Self::validate_window(&window)?; - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; env.storage().persistent().set(&WindowDataKey::Report(offering_id), &window); - env.events().publish((EVENT_REPORT_WINDOW_SET, issuer, namespace, token), (start_timestamp, end_timestamp)); + env.events().publish( + (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); Ok(()) } @@ -7675,7 +7817,69 @@ impl RevoraRevenueShare { return Err(RevoraError::ClaimDelayNotElapsed); } - // Transfer only if there is a positive payout + // ── Auto-reinvestment fork ───────────────────────────────────────────── + // When the holder has opted in, convert the owed dividend into additional + // basis-point shares at the configured NAV instead of transferring tokens. + let reinvest_cfg: Option = env + .storage() + .persistent() + .get(&DataKey2::AutoReinvest(offering_id.clone(), holder.clone())); + + if let Some(cfg) = reinvest_cfg { + if cfg.enabled && total_payout > 0 && cfg.nav_per_share_e7 > 0 { + // share_delta_bps = floor(total_payout / nav_per_share_e7) + let share_delta = total_payout.checked_div(cfg.nav_per_share_e7).unwrap_or(0); + + if share_delta > 0 && share_delta <= u32::MAX as i128 { + let share_delta_bps = share_delta as u32; + let current_share_bps: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + // Cap new share at 100 % (10 000 bps). + let new_share_bps = + current_share_bps.saturating_add(share_delta_bps).min(10_000); + let effective_delta = new_share_bps.saturating_sub(current_share_bps); + + if effective_delta > 0 { + // set_holder_share_internal enforces supply-cap and vesting rules. + // On failure (cap exhausted / vesting blocked) we fall through to + // the normal token transfer so the holder still receives cash. + if Self::set_holder_share_internal( + &env, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + holder.clone(), + new_share_bps, + None, + ) + .is_ok() + { + env.events().publish( + ( + EVENT_DIVIDEND_REINVEST, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + ( + holder.clone(), + total_payout, + effective_delta, + cfg.nav_per_share_e7, + ), + ); + // Suppress token transfer — dividend reinvested as shares. + total_payout = 0; + } + } + } + } + } + + // Transfer only if there is a positive payout (zeroed out when reinvested). if total_payout > 0 { let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id) .ok_or(RevoraError::PaymentTokenMismatch)?; @@ -7688,10 +7892,10 @@ impl RevoraRevenueShare { } } - // Advance claim index only for periods actually claimed (respecting delay) + // Advance claim index only for periods actually claimed (respecting delay). env.storage().persistent().set(&idx_key, &last_claimed_idx); - // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) + // Versioned v2 event: [2, holder, total_payout, periods] -- always emitted. Self::emit_v2_event( &env, ( @@ -7773,7 +7977,12 @@ impl RevoraRevenueShare { // If dual-signature mode is enabled for this offering, the single-sig // `close_period` path is not available — callers must use `close_period_dual_sig`. - if env.storage().persistent().get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())).unwrap_or(false) { + if env + .storage() + .persistent() + .get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())) + .unwrap_or(false) + { return Err(RevoraError::DualSigNotConfigured); } @@ -7842,10 +8051,7 @@ impl RevoraRevenueShare { env.storage().persistent().set(&DataKey2::DualSigEnabled(offering_id), &enabled); - env.events().publish( - (symbol_short!("dual_cfg"), issuer, namespace, token), - (enabled,), - ); + env.events().publish((symbol_short!("dual_cfg"), issuer, namespace, token), (enabled,)); Ok(()) } @@ -7895,8 +8101,9 @@ impl RevoraRevenueShare { }; // Verify offering exists and retrieve the full Offering (including issuers). - let offering = Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) - .ok_or(RevoraError::OfferingNotFound)?; + let offering = + Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; // Both signers must be valid issuers (primary or co-issuer). let is_valid = |addr: &Address| -> bool { @@ -7910,7 +8117,12 @@ impl RevoraRevenueShare { } // Dual-signature mode must be enabled for this offering. - if !env.storage().persistent().get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())).unwrap_or(false) { + if !env + .storage() + .persistent() + .get::<_, bool>(&DataKey2::DualSigEnabled(offering_id.clone())) + .unwrap_or(false) + { return Err(RevoraError::DualSigNotConfigured); } @@ -8695,13 +8907,7 @@ impl RevoraRevenueShare { return (0, None); } - Self::compute_claimable_preview( - &env, - &offering_id, - &holder, - start_idx, - Some(count), - ) + Self::compute_claimable_preview(&env, &offering_id, &holder, start_idx, Some(count)) } // ── Time-delayed claim configuration (#27) ────────────────── @@ -8756,6 +8962,80 @@ impl RevoraRevenueShare { let count_key = DataKey::PeriodCount(offering_id); env.storage().persistent().get(&count_key).unwrap_or(0) } + + // ── Dividend auto-reinvestment ──────────────────────────────────────────── + + /// Configure dividend auto-reinvestment for a holder on an offering. + /// + /// When `enabled` is `true`, the next `claim` call will convert the holder's + /// owed dividend into additional basis-point shares (at the configured NAV) + /// instead of transferring payment tokens. + /// + /// ### Auth + /// Requires `holder.require_auth()`. Only the holder can opt in or out. + /// + /// ### Parameters + /// - `holder` — The holder configuring auto-reinvestment. + /// - `issuer` / `namespace` / `token` — The offering to configure. + /// - `enabled` — `true` to enable, `false` to disable. + /// - `nav_per_share_e7` — NAV per basis-point share in 7-decimal payment-token + /// units. Must be `> 0` when `enabled` is `true`; ignored when `false`. + /// + /// ### Errors + /// - `ContractFrozen` — contract is halted. + /// - `OfferingNotFound` — the offering does not exist. + /// - `AutoReinvestInvalidNav` — `nav_per_share_e7 <= 0` while `enabled = true`. + pub fn set_auto_reinvest( + env: Env, + holder: Address, + issuer: Address, + namespace: Symbol, + token: Address, + enabled: bool, + nav_per_share_e7: i128, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + holder.require_auth(); + + // Offering must exist. + Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + + if enabled && nav_per_share_e7 <= 0 { + return Err(RevoraError::AutoReinvestInvalidNav); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + let cfg = AutoReinvestConfig { enabled, nav_per_share_e7 }; + env.storage().persistent().set(&DataKey2::AutoReinvest(offering_id, holder.clone()), &cfg); + + env.events().publish( + (EVENT_DIVIDEND_REINVEST, issuer, namespace, token), + (holder, enabled, nav_per_share_e7), + ); + + Ok(()) + } + + /// Read a holder's auto-reinvestment configuration for an offering. + /// + /// Returns `None` when the holder has never configured auto-reinvestment + /// (equivalent to disabled with NAV 0). + pub fn get_auto_reinvest( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> Option { + let offering_id = OfferingId { issuer, namespace, token }; + env.storage().persistent().get(&DataKey2::AutoReinvest(offering_id, holder)) + } } // ── Test-only helpers (not part of the contract ABI) ───────────────────────── @@ -8796,10 +9076,9 @@ impl RevoraRevenueShare { let current_acc: i128 = env.storage().persistent().get(&global_acc_key).unwrap_or(0); let next_acc = current_acc.saturating_add(acc_delta_e18); env.storage().persistent().set(&global_acc_key, &next_acc); - env.storage().persistent().set( - &DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), - &next_acc, - ); + env.storage() + .persistent() + .set(&DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), &next_acc); // Update cumulative deposited revenue let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); @@ -9063,18 +9342,12 @@ impl RevoraRevenueShare { if env.storage().persistent().has(&DataKey2::MultisigThreshold) { return Err(RevoraError::LimitReached); } - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(RevoraError::LimitReached)?; + let admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::LimitReached)?; admin.require_auth(); env.storage().persistent().set(&DataKey::Frozen, &true); - env.storage() - .persistent() - .set(&DataKey2::GlobalFreezeReason, &reason); - env.events() - .publish((symbol_short!("frz_set"),), (admin, reason)); + env.storage().persistent().set(&DataKey2::GlobalFreezeReason, &reason); + env.events().publish((symbol_short!("frz_set"),), (admin, reason)); Self::emit_v2_event(&env, (EVENT_FREEZE_V2,), true); Ok(()) } @@ -9095,12 +9368,9 @@ impl RevoraRevenueShare { /// /// Returns `None` when the contract has never been frozen via `set_freeze`. pub fn get_freeze_reason(env: Env) -> Option { - env.storage() - .persistent() - .get(&DataKey2::GlobalFreezeReason) + env.storage().persistent().get(&DataKey2::GlobalFreezeReason) } - /// Freeze a single offering while keeping other offerings operational. /// /// Authorization boundary: @@ -9232,10 +9502,7 @@ impl RevoraRevenueShare { let key = DataKey2::EmergencyFreeze(offering_id, holder.clone()); env.storage().persistent().set(&key, &reason); - env.events().publish( - (EVENT_FRZ_SET, issuer, namespace, token), - (caller, holder, reason), - ); + env.events().publish((EVENT_FRZ_SET, issuer, namespace, token), (caller, holder, reason)); Ok(()) } @@ -9273,16 +9540,14 @@ impl RevoraRevenueShare { } let key = DataKey2::EmergencyFreeze(offering_id, holder.clone()); - let stored_reason: FreezeReason = env.storage().persistent().get(&key).ok_or(RevoraError::HolderFrozen)?; + let stored_reason: FreezeReason = + env.storage().persistent().get(&key).ok_or(RevoraError::HolderFrozen)?; if stored_reason != reason { return Err(RevoraError::FreezeReasonMismatch); } env.storage().persistent().remove(&key); - env.events().publish( - (EVENT_FRZ_CLR, issuer, namespace, token), - (caller, holder, reason), - ); + env.events().publish((EVENT_FRZ_CLR, issuer, namespace, token), (caller, holder, reason)); Ok(()) } @@ -9295,7 +9560,10 @@ impl RevoraRevenueShare { holder: Address, ) -> bool { let offering_id = OfferingId { issuer, namespace, token }; - env.storage().persistent().get::(&DataKey2::EmergencyFreeze(offering_id, holder)).is_some() + env.storage() + .persistent() + .get::(&DataKey2::EmergencyFreeze(offering_id, holder)) + .is_some() } // ── Multisig admin logic ─────────────────────────────────── @@ -9418,7 +9686,8 @@ impl RevoraRevenueShare { let mut initial_approvals = Vec::new(&env); initial_approvals.push_back(proposer.clone()); - let quorum_bps: u32 = env.storage().persistent().get(&DataKey2::MultisigQuorumBps).unwrap_or(5100); + let quorum_bps: u32 = + env.storage().persistent().get(&DataKey2::MultisigQuorumBps).unwrap_or(5100); let proposal = Proposal { id, @@ -9590,11 +9859,8 @@ impl RevoraRevenueShare { let mut total_voted_bps: u32 = 0; for i in 0..proposal.approvals.len() { let voter = proposal.approvals.get(i).unwrap(); - let weight: u32 = env - .storage() - .persistent() - .get(&DataKey2::VoterWeight(voter)) - .unwrap_or(0); + let weight: u32 = + env.storage().persistent().get(&DataKey2::VoterWeight(voter)).unwrap_or(0); total_voted_bps = total_voted_bps.saturating_add(weight); } total_voted_bps >= proposal.quorum_bps @@ -9657,10 +9923,8 @@ impl RevoraRevenueShare { } let now = env.ledger().timestamp(); - let last_request_ts: Option = env - .storage() - .persistent() - .get(&DataKey2::FaucetLastRequest(requester.clone())); + let last_request_ts: Option = + env.storage().persistent().get(&DataKey2::FaucetLastRequest(requester.clone())); if let Some(last_ts) = last_request_ts { if now.saturating_sub(last_ts) < DEFAULT_FAUCET_COOLDOWN_SECONDS { env.events().publish( @@ -9677,9 +9941,7 @@ impl RevoraRevenueShare { } } - env.storage() - .persistent() - .set(&DataKey2::FaucetLastRequest(requester), &now); + env.storage().persistent().set(&DataKey2::FaucetLastRequest(requester), &now); if count == 0 { return Ok(Vec::new(&env)); @@ -9702,11 +9964,8 @@ impl RevoraRevenueShare { slot_input.append(&idx.to_xdr(&env)); let seed: BytesN<32> = env.crypto().sha256(&slot_input); - let share_bps: u32 = if idx == count - 1 { - bps_floor + bps_remainder - } else { - bps_floor - }; + let share_bps: u32 = + if idx == count - 1 { bps_floor + bps_remainder } else { bps_floor }; // Store seed for test-suite retrieval without forcing a full scan. env.storage() @@ -9728,7 +9987,11 @@ impl RevoraRevenueShare { #[cfg(test)] mod issue_455_fx_oracle_tests { use super::*; - use soroban_sdk::{contract, contractimpl, testutils::{Address as _, Ledger}, Address, Env, Symbol}; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, Env, Symbol, + }; pub mod fresh { use super::*; @@ -9794,15 +10057,7 @@ mod issue_455_fx_oracle_tests { &60, ); - client.report_revenue( - &issuer, - &namespace, - &token, - &reported_asset, - &1_000, - &1, - &false, - ); + client.report_revenue(&issuer, &namespace, &token, &reported_asset, &1_000, &1, &false); assert_eq!(client.get_revenue_by_period(&issuer, &namespace, &token, &1), 1_200); assert_eq!( @@ -9874,7 +10129,16 @@ mod issue_370_373_tests { let mut tokens = Vec::new(&env); for i in 0..25_u32 { let token = Address::generate(&env); - client.register_offering(&issuer, &namespace, &token, &(1_000 + i), &token, &0, &symbol_short!(""), &0); + client.register_offering( + &issuer, + &namespace, + &token, + &(1_000 + i), + &token, + &0, + &symbol_short!(""), + &0, + ); tokens.push_back(token); } @@ -9945,13 +10209,40 @@ mod issue_370_373_tests { let new_token_0 = Address::generate(&env); let new_token_1 = Address::generate(&env); - client.register_offering(&new_issuer, &namespace, &new_token_0, &1_100, &new_token_0, &0, &symbol_short!(""), &0); - client.register_offering(&new_issuer, &namespace, &new_token_1, &1_200, &new_token_1, &0, &symbol_short!(""), &0); + client.register_offering( + &new_issuer, + &namespace, + &new_token_0, + &1_100, + &new_token_0, + &0, + &symbol_short!(""), + &0, + ); + client.register_offering( + &new_issuer, + &namespace, + &new_token_1, + &1_200, + &new_token_1, + &0, + &symbol_short!(""), + &0, + ); let mut old_tokens = Vec::new(&env); for i in 0..25_u32 { let token = Address::generate(&env); - client.register_offering(&old_issuer, &namespace, &token, &(2_000 + i), &token, &0, &symbol_short!(""), &0); + client.register_offering( + &old_issuer, + &namespace, + &token, + &(2_000 + i), + &token, + &0, + &symbol_short!(""), + &0, + ); old_tokens.push_back(token); } @@ -10033,8 +10324,6 @@ mod issue_370_373_tests { } } - - // ── Snapshot-Based Governance Voting (issue #557) ───────────────────────── // // Voting weight is pinned to the snapshot taken at the moment the proposal was @@ -10107,8 +10396,7 @@ impl RevoraRevenueShare { // Allocate a monotonically increasing proposal id. let count_key = DataKey2::GovProposalCount(offering_id.clone()); - let proposal_id: u32 = - env.storage().persistent().get(&count_key).unwrap_or(0); + let proposal_id: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); let created_at = env.ledger().timestamp(); let proposal = GovProposalEntry { @@ -10245,9 +10533,7 @@ impl RevoraRevenueShare { proposal_id: u32, ) -> Option { let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::GovProposal(offering_id, proposal_id)) + env.storage().persistent().get(&DataKey2::GovProposal(offering_id, proposal_id)) } /// Return the total number of governance proposals created for an offering. @@ -10258,10 +10544,7 @@ impl RevoraRevenueShare { token: Address, ) -> u32 { let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey2::GovProposalCount(offering_id)) - .unwrap_or(0) + env.storage().persistent().get(&DataKey2::GovProposalCount(offering_id)).unwrap_or(0) } /// Close a governance proposal so no further votes can be cast. @@ -10352,10 +10635,12 @@ impl RevoraRevenueShare { } let cursor_key = MigrationDataKey::MigrationResumeCursor(issuer.clone()); - let mut cursor: MigrationCursor = env.storage().persistent().get(&cursor_key).unwrap_or(MigrationCursor { last_key: 0 }); + let mut cursor: MigrationCursor = + env.storage().persistent().get(&cursor_key).unwrap_or(MigrationCursor { last_key: 0 }); if cursor.last_key > 0 && !dry_run { - env.events().publish((symbol_short!("mig_resume"), from_version, to_version), cursor.last_key); + env.events() + .publish((symbol_short!("mig_resume"), from_version, to_version), cursor.last_key); } // Add per-version migrators in a dispatch table @@ -10363,10 +10648,14 @@ impl RevoraRevenueShare { (1, 2) => { // Explicit storage walker simulation for v1 -> v2. let total_keys = 10u32; // Simulated total keys to process - + if dry_run { env.events().publish( - (soroban_sdk::Symbol::new(&env, "migration_plan"), from_version, to_version), + ( + soroban_sdk::Symbol::new(&env, "migration_plan"), + from_version, + to_version, + ), issuer.clone(), ); } else { @@ -10376,10 +10665,8 @@ impl RevoraRevenueShare { } // Simulate key migration work here - env.events().publish( - (symbol_short!("mig_step"), from_version, to_version), - i, - ); + env.events() + .publish((symbol_short!("mig_step"), from_version, to_version), i); // Persist cursor atomically with each processed key cursor.last_key = i; @@ -10399,9 +10686,9 @@ impl RevoraRevenueShare { } } -#[cfg(test)] -mod test_storage_layout_version; #[cfg(test)] mod test_close_period; #[cfg(test)] mod test_snapshot_voting_weight; +#[cfg(test)] +mod test_storage_layout_version; diff --git a/src/test_accrual_ledger.rs b/src/test_accrual_ledger.rs index b36900eff..9ca6550b5 100644 --- a/src/test_accrual_ledger.rs +++ b/src/test_accrual_ledger.rs @@ -1,12 +1,14 @@ #![cfg(test)] -use crate::{RevoraRevenueShare, RevoraRevenueShareClient}; +use crate::{AutoReinvestConfig, RevoraRevenueShare, RevoraRevenueShareClient}; use soroban_sdk::{ symbol_short, testutils::{Address as _, Ledger}, Address, Env, }; +// ── Setup helper ───────────────────────────────────────────────────────────── + fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); @@ -25,6 +27,8 @@ fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address (env, client, issuer, token, payout_asset) } +// ── Existing accrual-ledger tests (preserved) ──────────────────────────────── + #[test] fn claim_uses_historical_share_for_unclaimed_periods() { let (_env, client, issuer, token, payout_asset) = setup_offering(); @@ -86,3 +90,194 @@ fn delay_barrier_preserves_pre_change_accrual() { env.ledger().with_mut(|li| li.timestamp = 1_150); assert_eq!(client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0), 25_000); } + +// ── Auto-reinvestment tests ─────────────────────────────────────────────────── + +/// Happy path: holder enables auto-reinvest, claim converts dividend to shares. +/// +/// Setup: +/// - holder share: 5 000 bps (50 %) +/// - deposit: 100 000 units → holder owed 50 000 +/// - NAV per share: 5 000 units per bps +/// - expected share_delta: floor(50_000 / 5_000) = 10 bps +/// - expected new holder share: 5 000 + 10 = 5 010 bps +/// - claim return value: 0 (no tokens transferred; all reinvested) +#[test] +fn auto_reinvest_happy_path_converts_dividend_to_shares() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + // Enable auto-reinvest: NAV = 5 000 units per bps-share. + client.set_auto_reinvest(&holder, &issuer, &symbol_short!("def"), &token, &true, &5_000_i128); + + // Claim should reinvest the 50 000-unit dividend as 10 new bps shares. + let returned = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(returned, 0, "all payout should be reinvested, not transferred"); + + let new_share = client.get_holder_share(&issuer, &symbol_short!("def"), &token, &holder); + assert_eq!(new_share, 5_010, "holder should have 10 new bps"); +} + +/// get_auto_reinvest returns the stored config after set_auto_reinvest. +#[test] +fn get_auto_reinvest_returns_stored_config() { + let (env, client, issuer, token, _payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + // Nothing stored yet. + let before = client.get_auto_reinvest(&issuer, &symbol_short!("def"), &token, &holder); + assert!(before.is_none()); + + client.set_auto_reinvest(&holder, &issuer, &symbol_short!("def"), &token, &true, &10_000_i128); + + let after = client.get_auto_reinvest(&issuer, &symbol_short!("def"), &token, &holder); + let cfg = after.expect("config should be stored"); + assert!(cfg.enabled); + assert_eq!(cfg.nav_per_share_e7, 10_000); +} + +/// Disabled path: auto-reinvest off → claim transfers tokens normally. +#[test] +fn auto_reinvest_disabled_transfers_tokens_normally() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + // Explicitly disabled. + client.set_auto_reinvest(&holder, &issuer, &symbol_short!("def"), &token, &false, &5_000_i128); + + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(payout, 50_000, "tokens should be transferred when reinvest is off"); + + // Share unchanged. + let share = client.get_holder_share(&issuer, &symbol_short!("def"), &token, &holder); + assert_eq!(share, 5_000); +} + +/// NAV = 0 guard: set_auto_reinvest with enabled=true and nav=0 must fail. +#[test] +fn set_auto_reinvest_rejects_zero_nav_when_enabled() { + let (env, client, issuer, token, _payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + let result = client.try_set_auto_reinvest( + &holder, + &issuer, + &symbol_short!("def"), + &token, + &true, + &0_i128, + ); + assert!(result.is_err(), "nav=0 with enabled=true should be rejected"); +} + +/// NAV = 0 allowed when disabled (NAV field is ignored). +#[test] +fn set_auto_reinvest_allows_zero_nav_when_disabled() { + let (env, client, issuer, token, _payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + let result = client.try_set_auto_reinvest( + &holder, + &issuer, + &symbol_short!("def"), + &token, + &false, + &0_i128, + ); + assert!(result.is_ok(), "nav=0 is fine when disabled"); +} + +/// Cap-exhausted rejection: when the supply cap would be exceeded, reinvestment +/// falls back to a normal cash transfer rather than reverting. +/// +/// Setup: +/// - max_total_supply_shares = 5 005 bps +/// - holder already has 5 000 bps +/// - floor(50 000 / 5 000) = 10 bps delta would exceed the cap by 5 bps +/// - Expected: cap guard kicks in, token transfer happens instead +#[test] +fn auto_reinvest_falls_back_to_cash_when_cap_exhausted() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + // Set a tight supply cap: 5 005 bps total (only 5 extra bps available). + client.set_max_total_supply_shares(&issuer, &symbol_short!("def"), &token, &5_005_i128); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + // NAV 5 000 → delta 10 bps, but only 5 bps headroom under the cap. + client.set_auto_reinvest(&holder, &issuer, &symbol_short!("def"), &token, &true, &5_000_i128); + + // Claim should fall back to cash transfer, not revert. + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(payout, 50_000, "should receive cash when cap is exhausted"); + + // Share must be unchanged. + let share = client.get_holder_share(&issuer, &symbol_short!("def"), &token, &holder); + assert_eq!(share, 5_000, "share should be unchanged after cap rejection"); +} + +/// No config at all → claim behaves as normal cash transfer. +#[test] +fn auto_reinvest_no_config_is_normal_claim() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + // No set_auto_reinvest call at all. + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(payout, 50_000); +} + +/// share_delta rounds down (floor division); tiny dividend below 1 bps NAV +/// still lets the claim succeed (period index advances) with no transfer. +#[test] +fn auto_reinvest_tiny_dividend_below_one_bps_advances_index() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &1); + // 100 units revenue × 1 bps = 0.01 units owed; floor(0 / huge_nav) = 0 delta + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100, &1); + + // NAV so large that share_delta = floor(tiny/huge) = 0. + client.set_auto_reinvest( + &holder, + &issuer, + &symbol_short!("def"), + &token, + &true, + &1_000_000_i128, + ); + + // Claim should succeed with 0 payout (delta is 0, so no reinvest and payout=0). + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + // Payout is 0 because 100 * 1 / 10_000 = 0 (truncated). + assert_eq!(payout, 0); +} + +/// Auto-reinvest can be toggled off and subsequent claim returns cash. +#[test] +fn auto_reinvest_toggle_off_restores_cash_claim() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + // Enable and then immediately disable before claiming. + client.set_auto_reinvest(&holder, &issuer, &symbol_short!("def"), &token, &true, &5_000_i128); + client.set_auto_reinvest(&holder, &issuer, &symbol_short!("def"), &token, &false, &5_000_i128); + + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(payout, 50_000, "should receive cash after toggling reinvest off"); +}