From 43d4bc6bbed5d66a995be632b11230c25326b4d8 Mon Sep 17 00:00:00 2001 From: Wetshakat Date: Sun, 28 Jun 2026 23:20:52 +0000 Subject: [PATCH] feat/transfer-restriction-matrix --- src/lib.rs | 150 +++++++++++++++++++++ src/test_compute_share_invariants.rs | 18 +-- src/test_transfer_restriction.rs | 191 +++++++++++++++++++++++++++ src/test_transfer_restrictions.rs | 62 +++++++++ 4 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 src/test_transfer_restriction.rs create mode 100644 src/test_transfer_restrictions.rs diff --git a/src/lib.rs b/src/lib.rs index afd6a619..bbc0b297 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,6 +169,8 @@ pub enum RevoraError { /// /// Wire value: 48. Stable since v1. PeriodAlreadyClosed = 48, + /// Category cap reached. + CategoryCapReached = 51, } pub mod vesting; @@ -190,6 +192,10 @@ mod test_min_revenue_threshold_boundary; // mod test_claim_transfer_fail; #[cfg(test)] mod test_close_period; +#[cfg(test)] +mod test_transfer_restrictions; +#[cfg(test)] +mod test_transfer_restriction; // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); @@ -432,6 +438,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)] @@ -767,6 +781,13 @@ pub enum DataKey2 { /// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period. ClosedPeriod(OfferingId, u64), + + /// 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. @@ -1251,6 +1272,36 @@ impl RevoraRevenueShare { return Err(RevoraError::InvalidShareBps); } + 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() @@ -4082,6 +4133,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 f9452e6d..9c3e66f2 100644 --- a/src/test_compute_share_invariants.rs +++ b/src/test_compute_share_invariants.rs @@ -47,6 +47,7 @@ //! holds for all i128 extremes. #![cfg(test)] +extern crate std; use crate::{RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode}; use soroban_sdk::{testutils::Address as _, Address, Env}; @@ -113,7 +114,7 @@ fn truncation_table_driven() { result, expected, "Truncation: amount={amount}, bps={bps} → expected {expected}, got {result}" ); - assert_bounds(result, amount, &format!("Truncation amount={amount} bps={bps}")); + assert_bounds(result, amount, "Truncation"); } } @@ -156,7 +157,7 @@ fn round_half_up_table_driven() { result, expected, "RoundHalfUp: amount={amount}, bps={bps} → expected {expected}, got {result}" ); - assert_bounds(result, amount, &format!("RoundHalfUp amount={amount} bps={bps}")); + assert_bounds(result, amount, "RoundHalfUp"); } } @@ -318,8 +319,8 @@ fn round_half_up_gte_truncation_for_positive_amounts() { 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_bounds(t, amount, "Truncation"); + assert_bounds(r, amount, "RoundHalfUp"); } } } @@ -601,8 +602,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, &format!("Truncation amount={amount} bps={bps}")); - assert_bounds(result_round, amount, &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 @@ -639,8 +640,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, &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); +}