diff --git a/src/lib.rs b/src/lib.rs index afd6a619..2dbaabdd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -327,6 +327,8 @@ const EVENT_ROUNDING_MODE_SET: Symbol = symbol_short!("rnd_mode"); const EVENT_ADMIN_SET: Symbol = symbol_short!("admin_set"); const EVENT_PLATFORM_FEE_SET: Symbol = symbol_short!("fee_set"); const BPS_DENOMINATOR: i128 = 10_000; +/// E18 fixed-point precision. +const E18: i128 = 1_000_000_000_000_000_000; /// Stellar network canonical decimal precision (7 decimal places, i.e., stroops). const STELLAR_CANONICAL_DECIMALS: u32 = 7; /// Maximum accepted decimal precision (safety cap for normalization math). @@ -649,8 +651,12 @@ pub enum DataKey { PeriodEntry(OfferingId, u32), /// Total number of deposited periods for an offering. PeriodCount(OfferingId), + /// Per-offering accrual index in e18 fixed-point. + AccrualIndexE18(OfferingId), /// Holder's share in basis points for (offering_id, holder). HolderShare(OfferingId, Address), + /// Last accrual index a holder has claimed up to. + LastClaimedAccrualIndex(OfferingId, Address), /// Per-offering running total of all persisted holder shares (basis points). HolderShareTotal(OfferingId), /// Next period index to claim for (offering_id, holder). @@ -767,6 +773,11 @@ pub enum DataKey2 { /// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period. ClosedPeriod(OfferingId, u64), + + /// Per-offering supply cap (#96). + SupplyCap(OfferingId), + /// Per-offering total deposited revenue (#96). + DepositedRevenue(OfferingId), } /// Maximum number of offerings returned in a single page. @@ -1251,11 +1262,17 @@ impl RevoraRevenueShare { return Err(RevoraError::InvalidShareBps); } - // Persist updated holder share and running total. + // Persist updated holder share, running total, and update last claimed accrual index. env.storage() .persistent() .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); env.storage().persistent().set(&total_key, &new_total); + + // Update last claimed accrual index to current index to settle existing accrual + let current_accrual_key = DataKey::AccrualIndexE18(offering_id.clone()); + let current_accrual: i128 = env.storage().persistent().get(¤t_accrual_key).unwrap_or(0); + let last_claimed_accrual_key = DataKey::LastClaimedAccrualIndex(offering_id.clone(), holder.clone()); + env.storage().persistent().set(&last_claimed_accrual_key, ¤t_accrual); env.events().publish( (EVENT_SHARE_SET, issuer.clone(), namespace.clone(), token.clone()), @@ -1399,6 +1416,22 @@ impl RevoraRevenueShare { ); } + // Update the e18 accrual index + 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); + + 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 new_accrual = current_accrual.checked_add(accrual_delta).unwrap_or(current_accrual); + env.storage().persistent().set(¤t_accrual_key, &new_accrual); + } + // Versioned event v2: [version: u32, payment_token: Address, amount: i128, period_id: u64] Self::emit_v2_event( env, @@ -5372,7 +5405,6 @@ impl RevoraRevenueShare { let delay_secs: u64 = env.storage().persistent().get(&delay_key).unwrap_or(0); let now = env.ledger().timestamp(); - let mut total_payout: i128 = 0; let mut claimed_periods = Vec::new(&env); let mut last_claimed_idx = start_idx; let mut previous_period_id: Option = None; @@ -5410,17 +5442,6 @@ impl RevoraRevenueShare { if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { break; } - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap(); - let decimals = Self::get_payment_token_decimals( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - let normalized = Self::normalize_amount(revenue, decimals); - let payout = normalized * (share_bps as i128) / 10_000; - total_payout += payout; claimed_periods.push_back(period_id); last_claimed_idx = i + 1; } @@ -5429,6 +5450,18 @@ impl RevoraRevenueShare { return Err(RevoraError::ClaimDelayNotElapsed); } + // Calculate total payout using e18 accrual index + let current_accrual_key = DataKey::AccrualIndexE18(offering_id.clone()); + let current_accrual: i128 = env.storage().persistent().get(¤t_accrual_key).unwrap_or(0); + let last_claimed_accrual_key = DataKey::LastClaimedAccrualIndex(offering_id.clone(), holder.clone()); + let last_claimed_accrual: i128 = env.storage().persistent().get(&last_claimed_accrual_key).unwrap_or(0); + let accrual_delta = current_accrual - last_claimed_accrual; + + let total_payout = (accrual_delta.checked_mul(share_bps as i128)) + .and_then(|x| x.checked_div(BPS_DENOMINATOR)) + .and_then(|x| x.checked_div(E18)) + .unwrap_or(0); + // Transfer only if there is a positive payout if total_payout > 0 { let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id) @@ -5442,8 +5475,9 @@ impl RevoraRevenueShare { } } - // Advance claim index only for periods actually claimed (respecting delay) + // Advance claim indices only for periods actually claimed (respecting delay) env.storage().persistent().set(&idx_key, &last_claimed_idx); + env.storage().persistent().set(&last_claimed_accrual_key, ¤t_accrual); // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) Self::emit_v2_event(