Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 16 additions & 17 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
);
}
Expand Down Expand Up @@ -10978,11 +10985,7 @@ impl RevoraRevenueShare {
let mut payouts: Vec<DistributionEntry> = 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 {
Expand Down Expand Up @@ -16081,17 +16084,13 @@ impl RevoraRevenueShare {
}
}

#[cfg(test)]
mod test_close_period;
#[cfg(test)]
mod test_deferred_priority;
#[cfg(test)]
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;
5 changes: 5 additions & 0 deletions src/merkle_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
219 changes: 208 additions & 11 deletions src/tax_bucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
(
Expand All @@ -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,
Expand All @@ -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");
}
}
Loading
Loading