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
91 changes: 80 additions & 11 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,17 +330,9 @@ pub enum RevoraError {
/// until the holder relocates to an allowed jurisdiction or the issuer
/// updates the allowlist.
JurisdictionMigrationDeadlineExceeded = 76,
/// The `proof` vector supplied to `verify_merkle_proof` exceeds
/// `MAX_PROOF_DEPTH` (32) siblings.
///
/// Very deep proofs risk exhausting contract memory and gas budgets.
/// The check is performed before any hashing so the contract incurs no
/// additional cost from the oversized payload. A `proof_reject_depth`
/// event is emitted before returning this error so off-chain indexers
/// can observe and alert on oversized-proof attempts.
///
/// Wire value: 77. Stable since v1.
ProofTooDeep = 77,
/// Setting a redemption window that overlaps with an existing unconsumed
/// redemption window is rejected.
RedemptionWindowOverlap = 77,
}

pub mod tax_bucket;
Expand Down Expand Up @@ -1542,6 +1534,28 @@ pub enum DataKey2 {
/// Priority-ordered deferred-distribution queue for an offering.
/// Value: `Vec<DeferredQueueEntry>` stored in `(release_ts, priority, queue_id)` sorted order.
DeferredQueue(OfferingId),

// ── Redemption keys ──
/// Pending redemption request for (offering_id, holder).
RedemptionRequest(OfferingId, Address),
/// Redemption fee configuration for an offering.
RedemptionFeeConfig(OfferingId),
/// Lockup schedule (cliff/taper) for an offering.
LockupSchedule(OfferingId),

// ── Global / admin keys ──
/// Emit v2 compat flag.
EmitV2Compat,
/// Global freeze reason for the contract.
GlobalFreezeReason,
/// Admin rotation delay in seconds.
AdminRotationDelay,

// ── Jurisdiction keys ──
/// Grace period (seconds) for jurisdiction migration.
JurisdictionGracePeriod(OfferingId),
/// Jurisdiction migration state for (offering_id, holder).
JurisdictionMigration(OfferingId, Address),
}

/// Maximum number of offerings returned in a single page.
Expand Down Expand Up @@ -8802,6 +8816,61 @@ impl RevoraRevenueShare {
let offering_id = OfferingId { issuer, namespace, token };
env.storage().persistent().get(&WindowDataKey::Claim(offering_id))
}

/// Configure the redemption window for an offering. If unset, always open.
/// Rejects the request if a stored redemption window overlaps with the new one.
pub fn set_redemption_window(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
start_timestamp: u64,
end_timestamp: u64,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
let current_issuer =
Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone())
.ok_or(RevoraError::OfferingNotFound)?;
if current_issuer != issuer {
return Err(RevoraError::OfferingNotFound);
}
issuer.require_auth();
let new_window = AccessWindow { start_timestamp, end_timestamp };
Self::validate_window(&new_window)?;
let offering_id = OfferingId {
issuer: issuer.clone(),
namespace: namespace.clone(),
token: token.clone(),
};

// Reject if the new window overlaps with the stored window
let existing: Option<AccessWindow> =
env.storage().persistent().get(&WindowDataKey::Redemption(offering_id.clone()));
if let Some(existing) = existing {
if start_timestamp < existing.end_timestamp && existing.start_timestamp < end_timestamp
{
return Err(RevoraError::RedemptionWindowOverlap);
}
}

env.storage().persistent().set(&WindowDataKey::Redemption(offering_id), &new_window);
env.events().publish(
(EVENT_REDEMPTION_WINDOW_SET, issuer, namespace, token),
(start_timestamp, end_timestamp),
);
Ok(())
}

/// Read configured redemption window (if any) for an offering.
pub fn get_redemption_window(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
) -> Option<AccessWindow> {
let offering_id = OfferingId { issuer, namespace, token };
env.storage().persistent().get(&WindowDataKey::Redemption(offering_id))
}
pub fn claim(
env: Env,
holder: Address,
Expand Down
1 change: 1 addition & 0 deletions src/structured_error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ mod tests {
("FreezeReasonMismatch", RevoraError::FreezeReasonMismatch as u32),
("InsufficientClassBalance", RevoraError::InsufficientClassBalance as u32),
("RedemptionWindowClosed", RevoraError::RedemptionWindowClosed as u32),
("RedemptionWindowOverlap", RevoraError::RedemptionWindowOverlap as u32),
("JurisdictionMigrationDeadlineExceeded", RevoraError::JurisdictionMigrationDeadlineExceeded as u32),
];

Expand Down
75 changes: 75 additions & 0 deletions src/test_redemption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,81 @@ fn get_redemption_window_returns_none_when_unset() {
assert_eq!(window, None);
}

// ── Window overlap rejection ──────────────────────────────────────────────────

#[test]
fn set_redemption_window_rejects_overlap() {
let env = Env::default();
let (client, issuer, offering_token, ..) = setup_offering(&env);

// Set first window [500, 2000)
client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000);

// Overlap from the left: [100, 1000) collides with [500, 2000)
let result = client.try_set_redemption_window(
&issuer,
&symbol_short!("def"),
&offering_token,
&100,
&1000,
);
assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap)));

// Overlap from the right: [1500, 3000) collides with [500, 2000)
let result = client.try_set_redemption_window(
&issuer,
&symbol_short!("def"),
&offering_token,
&1500,
&3000,
);
assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap)));

// Full containment: [600, 1800) inside [500, 2000)
let result = client.try_set_redemption_window(
&issuer,
&symbol_short!("def"),
&offering_token,
&600,
&1800,
);
assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap)));

// Surrounding: [100, 3000) fully contains [500, 2000)
let result = client.try_set_redemption_window(
&issuer,
&symbol_short!("def"),
&offering_token,
&100,
&3000,
);
assert_eq!(result, Err(Ok(RevoraError::RedemptionWindowOverlap)));
}

#[test]
fn set_redemption_window_allows_contiguous_non_overlapping() {
let env = Env::default();
let (client, issuer, offering_token, ..) = setup_offering(&env);

// Set first window [500, 2000)
client.set_redemption_window(&issuer, &symbol_short!("def"), &offering_token, &500, &2000);

// Non-overlapping: [2000, 3000) -- exactly adjacent (end == start)
let result = client.try_set_redemption_window(
&issuer,
&symbol_short!("def"),
&offering_token,
&2000,
&3000,
);
assert_eq!(result, Ok(Ok(())));

// Existing window unchanged
let window = client.get_redemption_window(&issuer, &symbol_short!("def"), &offering_token).unwrap();
assert_eq!(window.start_timestamp, 2000);
assert_eq!(window.end_timestamp, 3000);
}

// ── request_redemption ────────────────────────────────────────────────────────

#[test]
Expand Down
6 changes: 6 additions & 0 deletions tools/storage_layout_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const CORE_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revora_reven
("DataKey::StorageLayoutVersion", "u32", "contract"),
("DataKey::PlatformFeeBps", "u32", "contract"),
("DataKey::OfferingFeeBps(OfferingId, Address)", "u32", "offering+asset"),
("DataKey::OfferingRoyaltyBps(OfferingId, Address)", "u32", "offering+asset"),
("DataKey::PlatformFeePerAsset(Address)", "u32", "asset"),
("DataKey::SnapshotFinalizationRequired", "bool", "contract"),
("DataKey::LastSnapshotCommitRef(OfferingId)", "u64", "offering"),
Expand Down Expand Up @@ -127,17 +128,22 @@ const CORE_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revora_reven
("DataKey2::MultisigProposalCount", "u32", "contract"),
("DataKey2::MultisigProposalDuration", "u64", "contract"),
("DataKey2::MultisigProposal(u32)", "GovernanceProposal", "proposal"),
("DataKey2::AccrualAnchor(OfferingId, Address)", "AccrualAnchor", "offering+holder"),
("DataKey2::AccrualIndex(OfferingId)", "u32", "offering"),
("DataKey2::OfferingPlatformFee(OfferingId)", "PlatformFeeConfig", "offering"),
("DataKey2::DenominationMetadata(OfferingId)", "DenominationMetadata", "offering"),
("DataKey2::FxOracleConfig(OfferingId)", "FxOracleConfig", "offering"),
("DataKey2::TransferRestrictions(OfferingId, Symbol)", "TransferRestrictionConfig", "offering+category"),
("DataKey2::HolderCategory(OfferingId, Address)", "Symbol", "offering+holder"),
("DataKey2::CategoryHolderCount(OfferingId, Symbol)", "u32", "offering+category"),
("DataKey2::CheckpointThreshold(OfferingId)", "u32", "offering"),
("DataKey2::EmergencyFreeze(OfferingId, Address)", "bool", "offering+holder"),
("DataKey2::TotalSharesIssued(OfferingId)", "u32", "offering"),
("DataKey2::MaxTotalSupplyShares(OfferingId)", "u32", "offering"),
("DataKey2::FaucetSeedEntry(OfferingId, u32)", "Address", "offering+index"),
("DataKey2::GovernanceProposalCount(OfferingId)", "u32", "offering"),
("DataKey2::GovernanceProposal(OfferingId, u32)", "GovernanceProposal", "offering+proposal"),
("DataKey2::GovernanceProposalMeta(OfferingId, BytesN<32>)", "bool", "offering+hash"),
("DataKey2::GovProposalCount(OfferingId)", "u32", "offering"),
("DataKey2::GovProposal(OfferingId, u32)", "GovProposal", "offering+proposal"),
("DataKey2::VoteRecord(OfferingId, u32, Address)", "bool", "offering+proposal+voter"),
Expand Down
Loading