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
104 changes: 82 additions & 22 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ pub enum RevoraError {
/// [`set_transfer_cooldown`]) has elapsed since the holder's last transfer.
/// Wire value: 89. Stable since v1.
TransferCooldownActive = 89,
/// Per-holder redemption amount would exceed the per-window cap.
RedemptionCapExceeded = 90,
}

pub mod tax_bucket;
Expand Down Expand Up @@ -391,9 +393,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 +410,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 +547,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 @@ -1308,6 +1307,13 @@ pub struct MetaRevenueApprovalPayload {
pub struct AccessWindow {
pub start_timestamp: u64,
pub end_timestamp: u64,
pub per_holder_redemption_cap: i128,
}

impl AccessWindow {
pub fn new(start_timestamp: u64, end_timestamp: u64) -> Self {
AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 }
}
}

/// Per-holder pending redemption request.
Expand All @@ -1318,6 +1324,22 @@ pub struct PendingRedemption {
pub timestamp: u64,
}

/// Tracks how much a holder has already redeemed within the current window.
/// The `window_start` field acts as a discriminator: when a new window is set,
/// old entries are treated as stale (amount treated as zero).
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct HolderCumulativeRedeemed {
pub window_start: u64,
pub amount: i128,
}

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum CumulativeRedemptionKey {
CumulativeHolderRedeemed(OfferingId, Address),
}

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum WindowDataKey {
Expand Down Expand Up @@ -1514,7 +1536,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 @@ -8932,7 +8954,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 @@ -9788,7 +9815,7 @@ impl RevoraRevenueShare {
return Err(RevoraError::OfferingNotFound);
}
issuer.require_auth();
let window = AccessWindow { start_timestamp, end_timestamp };
let window = AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 };
Self::validate_window(&window)?;
let offering_id = OfferingId {
issuer: issuer.clone(),
Expand Down Expand Up @@ -9820,7 +9847,7 @@ impl RevoraRevenueShare {
return Err(RevoraError::OfferingNotFound);
}
issuer.require_auth();
let window = AccessWindow { start_timestamp, end_timestamp };
let window = AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 };
Self::validate_window(&window)?;
let offering_id = OfferingId {
issuer: issuer.clone(),
Expand Down Expand Up @@ -9859,13 +9886,16 @@ impl RevoraRevenueShare {

/// Configure the redemption window for an offering. If unset, always open.
/// Rejects the request if a stored redemption window overlaps with the new one.
/// `per_holder_cap` limits how much a single holder can redeem within this window
/// (in payment token units). 0 means no cap.
pub fn set_redemption_window(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
start_timestamp: u64,
end_timestamp: u64,
per_holder_cap: i128,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
let current_issuer =
Expand All @@ -9875,7 +9905,11 @@ impl RevoraRevenueShare {
return Err(RevoraError::OfferingNotFound);
}
issuer.require_auth();
let new_window = AccessWindow { start_timestamp, end_timestamp };
let new_window = AccessWindow {
start_timestamp,
end_timestamp,
per_holder_redemption_cap: per_holder_cap,
};
Self::validate_window(&new_window)?;
let offering_id = OfferingId {
issuer: issuer.clone(),
Expand All @@ -9896,7 +9930,7 @@ impl RevoraRevenueShare {
env.storage().persistent().set(&WindowDataKey::Redemption(offering_id), &new_window);
env.events().publish(
(EVENT_REDEMPTION_WINDOW_SET, issuer, namespace, token),
(start_timestamp, end_timestamp),
(start_timestamp, end_timestamp, per_holder_cap),
);
Ok(())
}
Expand Down Expand Up @@ -10368,7 +10402,7 @@ impl RevoraRevenueShare {

Self::require_not_frozen(&env)?;
issuer.require_auth();
let window = AccessWindow { start_timestamp, end_timestamp };
let window = AccessWindow { start_timestamp, end_timestamp, per_holder_redemption_cap: 0 };
Self::validate_window(&window)?;
let offering_id = OfferingId {
issuer: issuer.clone(),
Expand Down Expand Up @@ -10978,11 +11012,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 @@ -12089,6 +12119,40 @@ impl RevoraRevenueShare {
(amount, 0i128, None)
};

// Check per-holder redemption cap (issue #554)
let window_key = WindowDataKey::Redemption(offering_id.clone());
if let Some(window) =
env.storage().persistent().get::<WindowDataKey, AccessWindow>(&window_key)
{
let cap = window.per_holder_redemption_cap;
if cap > 0 {
let cumulative_key = CumulativeRedemptionKey::CumulativeHolderRedeemed(
offering_id.clone(),
holder.clone(),
);
let mut cumulative: HolderCumulativeRedeemed =
env.storage().persistent().get(&cumulative_key).unwrap_or(
HolderCumulativeRedeemed {
window_start: window.start_timestamp,
amount: 0,
},
);
// If window changed, reset cumulative
if cumulative.window_start != window.start_timestamp {
cumulative = HolderCumulativeRedeemed {
window_start: window.start_timestamp,
amount: 0,
};
}
let new_total = cumulative.amount.checked_add(net_amount).unwrap_or(i128::MAX);
if new_total > cap {
return Err(RevoraError::RedemptionCapExceeded);
}
cumulative.amount = new_total;
env.storage().persistent().set(&cumulative_key, &cumulative);
}
}

let token_client = token::Client::new(&env, &payment_token);
if net_amount > 0
&& token_client.try_transfer(&contract_addr, &holder, &net_amount).is_err()
Expand Down Expand Up @@ -16081,17 +16145,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
6 changes: 5 additions & 1 deletion src/structured_error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ mod tests {
("RedemptionWindowOverlap", RevoraError::RedemptionWindowOverlap as u32),
("JurisdictionMigrationDeadlineExceeded", RevoraError::JurisdictionMigrationDeadlineExceeded as u32),
("TransferCooldownActive", RevoraError::TransferCooldownActive as u32),
("RedemptionCapExceeded", RevoraError::RedemptionCapExceeded as u32),
];

// O(n²) uniqueness check — n is small, negligible cost.
Expand Down Expand Up @@ -207,6 +208,7 @@ mod tests {
assert_eq!(RevoraError::MissingReportForOverride as u32, 47);
assert_eq!(RevoraError::JurisdictionMigrationDeadlineExceeded as u32, 76);
assert_eq!(RevoraError::TransferCooldownActive as u32, 89);
assert_eq!(RevoraError::RedemptionCapExceeded as u32, 90);
}

// ─────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -292,9 +294,10 @@ mod tests {
RevoraError::FaucetCooldownActive as u32,
RevoraError::JurisdictionMigrationDeadlineExceeded as u32,
RevoraError::TransferCooldownActive as u32,
RevoraError::RedemptionCapExceeded as u32,
];
for v in all.iter() {
assert!(*v >= 1 && *v <= 89, "discriminant {v} out of expected range 1..=89");
assert!(*v >= 1 && *v <= 90, "discriminant {v} out of expected range 1..=90");
}
}

Expand Down Expand Up @@ -369,6 +372,7 @@ mod tests {
RevoraError::FaucetCooldownActive as u32,
RevoraError::JurisdictionMigrationDeadlineExceeded as u32,
RevoraError::TransferCooldownActive as u32,
RevoraError::RedemptionCapExceeded as u32,
];
for v in all.iter() {
assert_ne!(*v, 0, "discriminant 0 is reserved for Ok; no error variant may use it");
Expand Down
10 changes: 5 additions & 5 deletions src/tax_bucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,11 @@ 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);
Expand Down
13 changes: 6 additions & 7 deletions src/test_close_period.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading