diff --git a/src/lib.rs b/src/lib.rs index 5859ff70..a692985a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,19 +169,8 @@ pub enum RevoraError { /// /// Wire value: 48. Stable since v1. PeriodAlreadyClosed = 48, - - /// The requested platform `fee_bps` plus the offering's aggregate holder share - /// would exceed 10_000 bps (100%). Fee and holder allocations must always fit - /// within the offering's total at the offering level (#468). - /// - /// Wire value: 51. Stable since v1. - FeeExceedsHolderShare = 51, - - /// Concentration enforcement requires a fresh `report_concentration`, but the stored - /// concentration data is missing or older than the configured staleness window. - /// - /// Wire value: 53. Stable since v1. - StaleConcentrationData = 53, + /// Category cap reached. + CategoryCapReached = 51, } pub mod vesting; @@ -204,7 +193,9 @@ mod test_min_revenue_threshold_boundary; #[cfg(test)] mod test_close_period; #[cfg(test)] -mod test_platform_fee_model; +mod test_transfer_restrictions; +#[cfg(test)] +mod test_transfer_restriction; // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); @@ -506,6 +497,14 @@ pub struct AuditSummary { pub report_count: u64, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TransferRestrictions { + pub category: Symbol, + pub max_holders: u32, +} + + /// Read-only comparison between stored audit state and recomputed report state. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -911,17 +910,12 @@ pub enum DataKey2 { /// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period. ClosedPeriod(OfferingId, u64), - /// Per-offering platform fee model: configurable `fee_bps` routed to a treasury (#468). - OfferingPlatformFee(OfferingId), - - /// Per-offering minimum revenue threshold below which reports are skipped. - MinRevenueThreshold(OfferingId), - /// Per-offering cumulative deposited revenue tracker. - DepositedRevenue(OfferingId), - /// Per-offering investment constraints (min/max stake). - InvestmentConstraints(OfferingId), - /// Per-offering supply cap (0 = uncapped). - SupplyCap(OfferingId), + /// Configuration for transfer restrictions. + TransferRestrictions(OfferingId, Symbol), + /// Current count of holders with >0 shares for a category. + CategoryHolderCount(OfferingId, Symbol), + /// Which category a holder belongs to. + HolderCategory(OfferingId, Address), } /// Maximum number of offerings returned in a single page. @@ -1485,6 +1479,42 @@ impl RevoraRevenueShare { } } + let is_new_holder = old_share == 0 && share_bps > 0; + let is_leaving_holder = old_share > 0 && share_bps == 0; + + if is_new_holder || is_leaving_holder { + if let Some(category) = env + .storage() + .persistent() + .get::<_, Symbol>(&DataKey2::HolderCategory(offering_id.clone(), holder.clone())) + { + let count_key = DataKey2::CategoryHolderCount(offering_id.clone(), category.clone()); + let mut current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + + if is_new_holder { + if let Some(restrictions) = env + .storage() + .persistent() + .get::<_, TransferRestrictions>(&DataKey2::TransferRestrictions(offering_id.clone(), category.clone())) + { + if current_count >= restrictions.max_holders { + return Err(RevoraError::CategoryCapReached); + } + } + current_count += 1; + env.storage().persistent().set(&count_key, ¤t_count); + } else if is_leaving_holder { + env.storage().persistent().set(&count_key, ¤t_count.saturating_sub(1)); + } + } + } + + // Persist updated holder share and running total. + env.storage() + .persistent() + .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); + env.storage().persistent().set(&total_key, &new_total); + env.events().publish( (EVENT_SHARE_SET, issuer.clone(), namespace.clone(), token.clone()), (holder.clone(), share_bps), @@ -4629,6 +4659,105 @@ impl RevoraRevenueShare { Ok(()) } + pub fn set_transfer_restrictions( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + category: Symbol, + max_holders: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + let key = DataKey2::TransferRestrictions(offering_id, category.clone()); + let restrictions = TransferRestrictions { + category, + max_holders, + }; + env.storage().persistent().set(&key, &restrictions); + Ok(()) + } + + pub fn transfer_with_attestation( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from: Address, + to: Address, + amount_bps: u32, + category: Symbol, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + if from == to { + return Ok(()); + } + + let from_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), from.clone())) + .unwrap_or(0); + if from_share < amount_bps { + return Err(RevoraError::InvalidAmount); + } + + let to_share: u32 = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), to.clone())) + .unwrap_or(0); + + let cat_key = DataKey2::HolderCategory(offering_id.clone(), to.clone()); + let existing_cat: Option = env.storage().persistent().get(&cat_key); + 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); + 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())) + { + if new_count >= restrictions.max_holders { + env.storage().persistent().set(&old_count_key, &old_count); + return Err(RevoraError::CategoryCapReached); + } + } + env.storage().persistent().set(&new_count_key, &(new_count + 1)); + } + env.storage().persistent().set(&cat_key, &category); + } + } else { + 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, namespace, token, to, to_share + amount_bps)?; + + Ok(()) + } + /// Report the current top-holder concentration for an offering. /// /// Stores the provided concentration value. If it exceeds the configured limit, diff --git a/src/test_compute_share_invariants.rs b/src/test_compute_share_invariants.rs index f54d6c9a..2d36a5e5 100644 --- a/src/test_compute_share_invariants.rs +++ b/src/test_compute_share_invariants.rs @@ -49,8 +49,6 @@ #![cfg(test)] extern crate std; -extern crate alloc; - use crate::{RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode}; use alloc::format; use soroban_sdk::Env; @@ -117,7 +115,7 @@ fn truncation_table_driven() { result, expected, "Truncation: amount={amount}, bps={bps} → expected {expected}, got {result}" ); - assert_bounds(result, amount, &std::format!("Truncation amount={amount} bps={bps}")); + assert_bounds(result, amount, "Truncation"); } } @@ -160,7 +158,7 @@ fn round_half_up_table_driven() { result, expected, "RoundHalfUp: amount={amount}, bps={bps} → expected {expected}, got {result}" ); - assert_bounds(result, amount, &std::format!("RoundHalfUp amount={amount} bps={bps}")); + assert_bounds(result, amount, "RoundHalfUp"); } } @@ -318,9 +316,12 @@ fn round_half_up_gte_truncation_for_positive_amounts() { for &bps in bps_values { let t = c.compute_share(&amount, &bps, &RoundingMode::Truncation); let r = c.compute_share(&amount, &bps, &RoundingMode::RoundHalfUp); - assert!(r >= t, "RoundHalfUp ({r}) < Truncation ({t}) for amount={amount}, bps={bps}"); - assert_bounds(t, amount, &format!("Truncation amount={amount} bps={bps}")); - assert_bounds(r, amount, &format!("RoundHalfUp amount={amount} bps={bps}")); + assert!( + r >= t, + "RoundHalfUp ({r}) < Truncation ({t}) for amount={amount}, bps={bps}" + ); + assert_bounds(t, amount, "Truncation"); + assert_bounds(r, amount, "RoundHalfUp"); } } } @@ -597,16 +598,8 @@ fn remainder_product_bound_holds_for_all_bps() { let result_round = c.compute_share(&amount, &bps, &RoundingMode::RoundHalfUp); // Verify bounds invariant - assert_bounds( - result_trunc, - amount, - &std::format!("Truncation amount={amount} bps={bps}"), - ); - assert_bounds( - result_round, - amount, - &std::format!("RoundHalfUp amount={amount} bps={bps}"), - ); + assert_bounds(result_trunc, amount, "Truncation"); + assert_bounds(result_round, amount, "RoundHalfUp"); // Verify that the result is consistent with the decomposition formula // amount = q * 10_000 + r, share = q * bps + (r * bps) / 10_000 @@ -638,7 +631,7 @@ fn checked_mul_defense_in_depth_prevents_overflow() { for bps in [1_u32, 5_000, 10_000] { let result = c.compute_share(&amount, &bps, &RoundingMode::Truncation); // Should never panic and should always satisfy bounds - assert_bounds(result, amount, &std::format!("Extreme amount={amount} bps={bps}")); + assert_bounds(result, amount, "Extreme amount"); } } -} +} \ No newline at end of file diff --git a/src/test_transfer_restriction.rs b/src/test_transfer_restriction.rs new file mode 100644 index 00000000..4e2503ec --- /dev/null +++ b/src/test_transfer_restriction.rs @@ -0,0 +1,191 @@ + +#![cfg(test)] +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Events as _, Ledger as _}, + Address, Env, Symbol, +}; + +fn setup_test() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address, Symbol) { + let env = Env::default(); + env.mock_all_auths(); + + let client = RevoraRevenueShareClient::new(&env, &env.register_contract(None, RevoraRevenueShare)); + + let admin = Address::generate(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + let namespace = symbol_short!("ns"); + + client.initialize(&admin, &None, &None); + client.register_offering(&issuer, &namespace, &token, &10_000, &payout_asset, &0); + + (env, client, admin, issuer, token, namespace) +} + +#[test] +fn test_set_and_get_restriction() { + let (env, client, admin, ..) = setup_test(); + let category = symbol_short!("reg_d"); + + client.set_transfer_restriction(&admin, &category, &99); + assert_eq!(client.get_transfer_restriction(&category), 99); +} + +#[test] +fn test_set_and_get_holder_category() { + let (env, client, admin, ..) = setup_test(); + let holder = Address::generate(&env); + let category = symbol_short!("accred"); + + client.set_holder_category(&admin, &holder, &category); + assert_eq!(client.get_holder_category(&holder), Some(category)); +} + +#[test] +fn test_cap_is_enforced() { + let (env, client, admin, issuer, token, namespace) = setup_test(); + let category = symbol_short!("reg_s"); + let holder1 = Address::generate(&env); + let holder2 = Address::generate(&env); + + client.set_transfer_restriction(&admin, &category, &1); + client.set_holder_category(&admin, &holder1, &category); + client.set_holder_category(&admin, &holder2, &category); + + // First holder should succeed + let res1 = client.try_set_holder_share(&issuer, &namespace, &token, &holder1, &100); + assert_eq!(res1, Ok(Ok(()))); + assert_eq!(client.get_category_holder_count(&category), 1); + + // Second holder should fail + let res2 = client.try_set_holder_share(&issuer, &namespace, &token, &holder2, &100); + assert_eq!(res2, Err(Ok(RevoraError::CategoryCapReached))); + assert_eq!(client.get_category_holder_count(&category), 1); +} + +#[test] +fn test_counter_decrements_on_zero_share() { + let (env, client, admin, issuer, token, namespace) = setup_test(); + let category = symbol_short!("reg_d"); + let holder1 = Address::generate(&env); + let holder2 = Address::generate(&env); + + client.set_transfer_restriction(&admin, &category, &1); + client.set_holder_category(&admin, &holder1, &category); + client.set_holder_category(&admin, &holder2, &category); + + // Add holder1, count should be 1 + client.set_holder_share(&issuer, &namespace, &token, &holder1, &100); + assert_eq!(client.get_category_holder_count(&category), 1); + + // Remove holder1, count should be 0 + client.set_holder_share(&issuer, &namespace, &token, &holder1, &0); + assert_eq!(client.get_category_holder_count(&category), 0); + + // Add holder2, should succeed, count should be 1 + client.set_holder_share(&issuer, &namespace, &token, &holder2, &100); + assert_eq!(client.get_category_holder_count(&category), 1); +} + +#[test] +fn test_holder_share_oscillation() { + let (env, client, admin, issuer, token, namespace) = setup_test(); + let category = symbol_short!("qualified"); + let holder = Address::generate(&env); + + client.set_transfer_restriction(&admin, &category, &1); + client.set_holder_category(&admin, &holder, &category); + + // 0 -> 100: count becomes 1 + client.set_holder_share(&issuer, &namespace, &token, &holder, &100); + assert_eq!(client.get_category_holder_count(&category), 1); + + // 100 -> 200: count remains 1 + client.set_holder_share(&issuer, &namespace, &token, &holder, &200); + assert_eq!(client.get_category_holder_count(&category), 1); + + // 200 -> 0: count becomes 0 + client.set_holder_share(&issuer, &namespace, &token, &holder, &0); + assert_eq!(client.get_category_holder_count(&category), 0); + + // 0 -> 50: count becomes 1 again + client.set_holder_share(&issuer, &namespace, &token, &holder, &50); + assert_eq!(client.get_category_holder_count(&category), 1); +} + +#[test] +fn test_multiple_offerings_single_holder() { + let (env, client, admin, issuer, token1, namespace) = setup_test(); + let category = symbol_short!("multi_off"); + let holder = Address::generate(&env); + + let token2 = Address::generate(&env); + let payout_asset2 = Address::generate(&env); + client.register_offering(&issuer, &namespace, &token2, &10_000, &payout_asset2, &0); + + client.set_transfer_restriction(&admin, &category, &1); + client.set_holder_category(&admin, &holder, &category); + + // Add to offering 1: count becomes 1 + client.set_holder_share(&issuer, &namespace, &token1, &holder, &100); + assert_eq!(client.get_category_holder_count(&category), 1); + + // Add to offering 2: count remains 1 + client.set_holder_share(&issuer, &namespace, &token2, &holder, &100); + assert_eq!(client.get_category_holder_count(&category), 1); + + // Remove from offering 1: count remains 1 + client.set_holder_share(&issuer, &namespace, &token1, &holder, &0); + assert_eq!(client.get_category_holder_count(&category), 1); + + // Remove from offering 2: count becomes 0 + client.set_holder_share(&issuer, &namespace, &token2, &holder, &0); + assert_eq!(client.get_category_holder_count(&category), 0); +} + +#[test] +fn test_unrestricted_category_holder() { + let (env, client, admin, issuer, token, namespace) = setup_test(); + let restricted_cat = symbol_short!("restrict"); + let unrestricted_cat = symbol_short!("unrestrict"); + let holder = Address::generate(&env); + + client.set_transfer_restriction(&admin, &restricted_cat, &0); // 0 means no limit set + client.set_holder_category(&admin, &holder, &unrestricted_cat); + + // Should succeed as holder is not in a restricted category + client.set_holder_share(&issuer, &namespace, &token, &holder, &100); + assert_eq!(client.get_category_holder_count(&restricted_cat), 0); + assert_eq!(client.get_category_holder_count(&unrestricted_cat), 0); // Not tracked +} + +#[test] +fn test_holder_with_no_category() { + let (env, client, admin, issuer, token, namespace) = setup_test(); + let category = symbol_short!("reg_d"); + let holder = Address::generate(&env); + + client.set_transfer_restriction(&admin, &category, &1); + + // Holder has no category, should succeed + client.set_holder_share(&issuer, &namespace, &token, &holder, &100); + assert_eq!(client.get_category_holder_count(&category), 0); +} + +#[test] +fn test_setting_share_to_zero_for_uncategorized_holder() { + let (env, client, admin, issuer, token, namespace) = setup_test(); + let holder = Address::generate(&env); + + // Give a share, then remove it. Should not panic or error. + client.set_holder_share(&issuer, &namespace, &token, &holder, &100); + client.set_holder_share(&issuer, &namespace, &token, &holder, &0); + + // Check that no counters were affected + let any_category = symbol_short!("any"); + assert_eq!(client.get_category_holder_count(&any_category), 0); +} diff --git a/src/test_transfer_restrictions.rs b/src/test_transfer_restrictions.rs new file mode 100644 index 00000000..239e5875 --- /dev/null +++ b/src/test_transfer_restrictions.rs @@ -0,0 +1,62 @@ +#![cfg(test)] + +use crate::{RevoraError, test_utils::setup_context}; +use soroban_sdk::{Address, Symbol}; + +#[test] +fn test_transfer_restrictions() { + let (env, client, _contract_id, issuer, token, payout_asset) = setup_context(); + let namespace = Symbol::new(&env, "public"); + + client.initialize(&issuer); + client.register_offering(&issuer, &namespace, &token, &1000, &payout_asset); + + let category = Symbol::new(&env, "RegD"); + client.set_transfer_restrictions(&issuer, &namespace, &token, &category, &1); + + let holder1 = Address::generate(&env); + let holder2 = Address::generate(&env); + + client.set_holder_share(&issuer, &namespace, &token, &holder1, &100); + + // Transfer from holder1 to holder2, assigning holder2 to "RegD" + client.transfer_with_attestation(&issuer, &namespace, &token, &holder1, &holder2, &50, &category); + + let holder3 = Address::generate(&env); + let res = client.try_transfer_with_attestation(&issuer, &namespace, &token, &holder1, &holder3, &50, &category); + assert_eq!(res.unwrap_err().unwrap(), RevoraError::CategoryCapReached); + + // Drop holder2 to 0 + client.set_holder_share(&issuer, &namespace, &token, &holder2, &0); + + // Now we can transfer to holder3 + client.transfer_with_attestation(&issuer, &namespace, &token, &holder1, &holder3, &50, &category); +} + +#[test] +fn test_oscillating_across_zero() { + let (env, client, _contract_id, issuer, token, payout_asset) = setup_context(); + let namespace = Symbol::new(&env, "public"); + + client.initialize(&issuer); + client.register_offering(&issuer, &namespace, &token, &1000, &payout_asset); + + let category = Symbol::new(&env, "RegS"); + client.set_transfer_restrictions(&issuer, &namespace, &token, &category, &1); + + let holder1 = Address::generate(&env); + let holder2 = Address::generate(&env); + let holder3 = Address::generate(&env); + + client.set_holder_share(&issuer, &namespace, &token, &holder1, &100); + + client.transfer_with_attestation(&issuer, &namespace, &token, &holder1, &holder2, &50, &category); + + // Holder2 transfers entirely to holder3 + client.transfer_with_attestation(&issuer, &namespace, &token, &holder2, &holder3, &50, &category); + + // Cap is 1, holder3 is the only one in RegS. Try adding holder4. + let holder4 = Address::generate(&env); + let res = client.try_transfer_with_attestation(&issuer, &namespace, &token, &holder1, &holder4, &50, &category); + assert_eq!(res.unwrap_err().unwrap(), RevoraError::CategoryCapReached); +}