diff --git a/src/lib.rs b/src/lib.rs index 88a4e178..11e50d84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -391,9 +391,9 @@ mod test_time_windows; // #[cfg(test)] // mod test_claim_transfer_fail; #[cfg(test)] -mod test_close_period; +mod test_accrual_reconciliation_prop; #[cfg(test)] -mod test_compute_share_decomposition_prop; +mod test_close_period; #[cfg(test)] mod test_compute_share_decomposition_prop; #[cfg(test)] @@ -408,8 +408,6 @@ mod test_quorum_check; #[cfg(test)] mod test_reg_limit_delta; #[cfg(test)] -mod test_accrual_reconciliation_prop; -#[cfg(test)] mod test_tax_year; #[cfg(test)] mod test_transfer_cooldown; @@ -547,8 +545,7 @@ const EVENT_ROYALTY_CONFIG: Symbol = symbol_short!("roy_cfg"); const EVENT_ROYALTY_PAID: Symbol = symbol_short!("roy_paid"); const EVENT_INDEXED_V2: Symbol = symbol_short!("ev_idx2"); const EVENT_INDEXED_V3: Symbol = symbol_short!("ev_idx3"); -pub const EVENT_PROOF_REJECT_DEPTH: Symbol = symbol_short!("proof_reject_depth"); -pub const MAX_PROOF_DEPTH: u32 = 32; +pub use crate::merkle_helpers::MAX_PROOF_DEPTH; const EVENT_TYPE_OFFER: Symbol = symbol_short!("offer"); /// Emitted when a period is sealed by `close_period`. const EVENT_PERIOD_CLOSED: Symbol = symbol_short!("per_clos"); @@ -1514,7 +1511,7 @@ pub struct AccrualAnchor { /// Overflow enum to keep DataKey within the Soroban XDR union variant limit. #[contracttype] #[derive(Clone)] -pub enum DataKey2 { +pub(crate) enum DataKey2 { /// Whether the snapshot has been finalized successfully. SnapshotFinalized(OfferingId, u64), /// Per-offering supply cap (max total deposited revenue). @@ -1684,6 +1681,11 @@ pub enum DataKey2 { /// Ledger timestamp of the last transfer for (offering_id, holder). /// Used by the cooldown check to reject premature transfers. HolderLastTransferTime(OfferingId, Address), + + // ── Tax bucket (issue #535) ── + /// Remaining cost basis for a holder in an offering. + /// Used to cap return-of-capital distributions. + RemainingBasis(OfferingId, Address), } /// Maximum number of offerings returned in a single page. @@ -8932,7 +8934,12 @@ impl RevoraRevenueShare { } if temp_total_shares == max_shares { env.events().publish( - (EVENT_SUPPLY_CAP_SATURATED, offering_id.issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()), + ( + EVENT_SUPPLY_CAP_SATURATED, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), (temp_total_shares, max_shares), ); } @@ -10978,11 +10985,7 @@ impl RevoraRevenueShare { let mut payouts: Vec = Vec::new(env); for (bounded_bps, share_bps, holder, normalized_payout) in payout_rows { let _ = bounded_bps; - payouts.push_back(DistributionEntry { - holder, - share_bps, - normalized_payout, - }); + payouts.push_back(DistributionEntry { holder, share_bps, normalized_payout }); } PreflightCloseResult { @@ -16081,8 +16084,6 @@ impl RevoraRevenueShare { } } -#[cfg(test)] -mod test_close_period; #[cfg(test)] mod test_deferred_priority; #[cfg(test)] @@ -16090,8 +16091,6 @@ mod test_merkle_proof_depth; #[cfg(test)] mod test_merkle_root_rotation; #[cfg(test)] -mod test_merkle_root_rotation; -#[cfg(test)] mod test_snapshot_voting_weight; #[cfg(test)] mod test_storage_layout_version; diff --git a/src/merkle_helpers.rs b/src/merkle_helpers.rs index a1364698..6c671c07 100644 --- a/src/merkle_helpers.rs +++ b/src/merkle_helpers.rs @@ -116,6 +116,11 @@ pub enum MerkleError { ProofTooDeep = 1003, } +/// Maximum number of sibling hashes accepted in a Merkle proof. +/// +/// Proofs longer than this are rejected with [`MerkleError::ProofTooDeep`]. +pub const MAX_PROOF_DEPTH: u32 = 32; + // ── Public helpers ────────────────────────────────────────────────────────── /// One entry in a canonical Merkle-leaf sequence. diff --git a/src/tax_bucket.rs b/src/tax_bucket.rs index 073f1b62..fb4a94c3 100644 --- a/src/tax_bucket.rs +++ b/src/tax_bucket.rs @@ -17,6 +17,13 @@ pub const EVENT_TAX_ROLLOVER: Symbol = symbol_short!("tax_roll"); /// 5. `timestamp` — Ledger timestamp at the time of the event. pub const EVENT_TAX_LOT_V1: Symbol = symbol_short!("tax_lt1"); +/// Emitted when return-of-capital is capped by remaining cost basis. +/// The excess amount is reclassified as capital gains. +/// +/// Topic: `(tax_recls, issuer, namespace, token)` +/// Data: `(holder: Address, capped_amount: i128, reclassified_amount: i128)` +pub const EVENT_TAX_RECLASSIFY: Symbol = symbol_short!("tax_recls"); + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct TaxBucketResult { @@ -146,18 +153,22 @@ pub fn update_tax_year_accumulator( return_of_capital: i128, ) { let year_key = DataKey2::TaxYearEntry(offering_id.clone(), holder.clone(), fiscal_year); - let mut summary: TaxYearSummary = env.storage().persistent().get(&year_key).unwrap_or(TaxYearSummary { - ordinary_income: 0, - capital_gains: 0, - return_of_capital: 0, - }); + let mut summary: TaxYearSummary = env + .storage() + .persistent() + .get(&year_key) + .unwrap_or(TaxYearSummary { ordinary_income: 0, capital_gains: 0, return_of_capital: 0 }); summary.ordinary_income = summary.ordinary_income.saturating_add(ordinary_income); summary.capital_gains = summary.capital_gains.saturating_add(capital_gains); summary.return_of_capital = summary.return_of_capital.saturating_add(return_of_capital); env.storage().persistent().set(&year_key, &summary); } -pub fn rollover_distribution( +/// Apply return-of-capital with a hard cap at remaining cost basis. +/// Any excess is reclassified as capital gains. +/// Emits `EVENT_TAX_RECLASSIFY` when the cap is hit. +/// Uses checked subtraction to avoid underflow. +pub fn apply_return_of_capital_with_cap( env: &Env, offering_id: &OfferingId, holder: &Address, @@ -168,13 +179,46 @@ pub fn rollover_distribution( let key = DataKey2::RemainingBasis(offering_id.clone(), holder.clone()); let remaining_basis: i128 = env.storage().persistent().get(&key).unwrap_or(0); - let (return_of_capital, capital_gains) = if remaining_basis >= amount { - let new_basis = remaining_basis - amount; + if remaining_basis <= 0 { + let result = TaxBucketResult { return_of_capital: 0, capital_gains: amount }; + env.events().publish( + ( + EVENT_TAX_LOT_V1, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + ( + holder.clone(), + result.return_of_capital, + result.capital_gains, + amount, + period_id, + timestamp, + ), + ); + return result; + } + + let (return_of_capital, capital_gains) = if amount <= remaining_basis { + let new_basis = remaining_basis.checked_sub(amount).unwrap_or(0); env.storage().persistent().set(&key, &new_basis); (amount, 0i128) } else { let roc = remaining_basis; - let cg = amount - remaining_basis; + let cg = amount.checked_sub(remaining_basis).unwrap_or(0); + + env.storage().persistent().set(&key, &0i128); + + env.events().publish( + ( + EVENT_TAX_RECLASSIFY, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder.clone(), roc, cg), + ); env.events().publish( ( @@ -186,11 +230,9 @@ pub fn rollover_distribution( (holder.clone(), remaining_basis, 0i128), ); - env.storage().persistent().set(&key, &0i128); (roc, cg) }; - // Emit tax_lot_v1 event for every tax-bucket update env.events().publish( ( EVENT_TAX_LOT_V1, @@ -203,3 +245,158 @@ pub fn rollover_distribution( TaxBucketResult { return_of_capital, capital_gains } } + +pub fn rollover_distribution( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + amount: i128, + period_id: u64, + timestamp: u64, +) -> TaxBucketResult { + apply_return_of_capital_with_cap(env, offering_id, holder, amount, period_id, timestamp) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Events; + use soroban_sdk::{symbol_short, Address, Env}; + + fn setup_env() -> (Env, OfferingId, Address) { + let env = Env::default(); + env.mock_all_auths(); + let holder = Address::generate(&env); + let issuer = Address::generate(&env); + let offering_id = + OfferingId { issuer, namespace: symbol_short!("def"), token: Address::generate(&env) }; + (env, offering_id, holder) + } + + #[test] + fn test_track_and_rollover_within_basis() { + let (env, offering_id, holder) = setup_env(); + + track_cost_basis(&env, &offering_id, &holder, 100_000); + let result = rollover_distribution(&env, &offering_id, &holder, 30_000, 1, 1000); + + assert_eq!(result.return_of_capital, 30_000); + assert_eq!(result.capital_gains, 0); + + let key = DataKey2::RemainingBasis(offering_id.clone(), holder.clone()); + let remaining: i128 = env.storage().persistent().get(&key).unwrap(); + assert_eq!(remaining, 70_000); + } + + #[test] + fn test_rollover_exact_basis() { + let (env, offering_id, holder) = setup_env(); + + track_cost_basis(&env, &offering_id, &holder, 50_000); + let result = rollover_distribution(&env, &offering_id, &holder, 50_000, 1, 1000); + + assert_eq!(result.return_of_capital, 50_000); + assert_eq!(result.capital_gains, 0); + + let key = DataKey2::RemainingBasis(offering_id.clone(), holder.clone()); + let remaining: i128 = env.storage().persistent().get(&key).unwrap(); + assert_eq!(remaining, 0); + } + + #[test] + fn test_rollover_exceeds_basis_emits_reclassify() { + let (env, offering_id, holder) = setup_env(); + + track_cost_basis(&env, &offering_id, &holder, 30_000); + let result = rollover_distribution(&env, &offering_id, &holder, 100_000, 1, 1000); + + assert_eq!(result.return_of_capital, 30_000); + assert_eq!(result.capital_gains, 70_000); + + let key = DataKey2::RemainingBasis(offering_id.clone(), holder.clone()); + let remaining: i128 = env.storage().persistent().get(&key).unwrap(); + assert_eq!(remaining, 0); + + let events = env.events().all(); + let reclassify_events = events + .iter() + .filter(|e| { + e.0 == ( + EVENT_TAX_RECLASSIFY, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ) + }) + .count(); + assert!(reclassify_events > 0, "expected tax_recls event"); + } + + #[test] + fn test_rollover_zero_basis() { + let (env, offering_id, holder) = setup_env(); + + let result = rollover_distribution(&env, &offering_id, &holder, 50_000, 1, 1000); + + assert_eq!(result.return_of_capital, 0); + assert_eq!(result.capital_gains, 50_000); + } + + #[test] + fn test_rollover_zero_amount() { + let (env, offering_id, holder) = setup_env(); + + track_cost_basis(&env, &offering_id, &holder, 100_000); + let result = rollover_distribution(&env, &offering_id, &holder, 0, 1, 1000); + + assert_eq!(result.return_of_capital, 0); + assert_eq!(result.capital_gains, 0); + + let key = DataKey2::RemainingBasis(offering_id.clone(), holder.clone()); + let remaining: i128 = env.storage().persistent().get(&key).unwrap(); + assert_eq!(remaining, 100_000); + } + + #[test] + fn test_apply_return_of_capital_with_cap_multiple_distributions() { + let (env, offering_id, holder) = setup_env(); + + track_cost_basis(&env, &offering_id, &holder, 100_000); + + let r1 = apply_return_of_capital_with_cap(&env, &offering_id, &holder, 40_000, 1, 1000); + assert_eq!(r1.return_of_capital, 40_000); + assert_eq!(r1.capital_gains, 0); + + let r2 = apply_return_of_capital_with_cap(&env, &offering_id, &holder, 30_000, 2, 2000); + assert_eq!(r2.return_of_capital, 30_000); + assert_eq!(r2.capital_gains, 0); + + let r3 = apply_return_of_capital_with_cap(&env, &offering_id, &holder, 50_000, 3, 3000); + assert_eq!(r3.return_of_capital, 30_000); + assert_eq!(r3.capital_gains, 20_000); + + let r4 = apply_return_of_capital_with_cap(&env, &offering_id, &holder, 10_000, 4, 4000); + assert_eq!(r4.return_of_capital, 0); + assert_eq!(r4.capital_gains, 10_000); + } + + #[test] + fn test_reclassify_event_contains_correct_data() { + let (env, offering_id, holder) = setup_env(); + + track_cost_basis(&env, &offering_id, &holder, 25_000); + + apply_return_of_capital_with_cap(&env, &offering_id, &holder, 100_000, 1, 5000); + + let events = env.events().all(); + let found = events.iter().any(|e| { + e.0 == ( + EVENT_TAX_RECLASSIFY, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ) + }); + assert!(found, "expected tax_recls event"); + } +} diff --git a/src/test_close_period.rs b/src/test_close_period.rs index a7f686a4..89277e84 100644 --- a/src/test_close_period.rs +++ b/src/test_close_period.rs @@ -71,21 +71,20 @@ fn setup_offering_with_contract_id( client.register_offering( &issuer, + &Vec::from_array(&env, []), + &1u32, &symbol_short!("ns"), &offering_token, - &10_000, + &10_000u32, &payment_token, - &0, + &0i128, + &symbol_short!(""), + &0u32, ); (env, client, issuer, offering_token, payment_token, contract_id) } -fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address) { - let (env, client, issuer, token, payment_token, _) = setup_offering_with_contract_id(); - (env, client, issuer, token, payment_token) -} - proptest! { #![proptest_config(ProptestConfig { cases: 16, diff --git a/src/test_tax_year.rs b/src/test_tax_year.rs index ee457f6b..ca5895e2 100644 --- a/src/test_tax_year.rs +++ b/src/test_tax_year.rs @@ -52,11 +52,7 @@ fn fiscal_year_config_default_and_roundtrip() { let ns = symbol_short!("def"); // Default is January (1). - assert_eq!( - client.get_fiscal_year_start(&issuer, &ns, &token), - 1, - "default should be January", - ); + assert_eq!(client.get_fiscal_year_start(&issuer, &ns, &token), 1, "default should be January",); // Set to April (4). client.set_fiscal_year_start(&issuer, &ns, &token, &4); diff --git a/tools/storage_layout_schema.rs b/tools/storage_layout_schema.rs index e7867fe7..d29ce859 100644 --- a/tools/storage_layout_schema.rs +++ b/tools/storage_layout_schema.rs @@ -145,6 +145,7 @@ const CORE_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revora_reven ("DataKey2::FaucetSeedCount(OfferingId)", "u32", "offering"), ("DataKey2::FiscalYearStartMonth(OfferingId)", "u32", "offering"), ("DataKey2::TaxYearEntry(OfferingId, Address, u64)", "TaxYearSummary", "offering+holder+year"), + ("DataKey2::RemainingBasis(OfferingId, Address)", "i128", "offering+holder"), ("DataKey2::GovernanceProposalCount(OfferingId)", "u32", "offering"), ("DataKey2::GovernanceProposal(OfferingId, u32)", "GovernanceProposal", "offering+proposal"), ("DataKey2::GovernanceProposalMeta(OfferingId, BytesN<32>)", "bool", "offering+hash"),