Skip to content
Merged
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
179 changes: 154 additions & 25 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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, &current_count);
} else if is_leaving_holder {
env.storage().persistent().set(&count_key, &current_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),
Expand Down Expand Up @@ -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<Symbol> = 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,
Expand Down
31 changes: 12 additions & 19 deletions src/test_compute_share_invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@
#![cfg(test)]
extern crate std;

extern crate alloc;

use crate::{RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode};
use alloc::format;
use soroban_sdk::Env;
Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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");
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
}
}
}
Loading
Loading