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
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,11 @@ soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] }
opt-level = "z"
overflow-checks = true

# Tell rustc/clippy that `cfg(kani)` is a known condition (set by the Kani
# verification runner, not a Cargo feature). Without this, -D warnings fails on
# `unexpected_cfgs` in the kani harness during regular CI.
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }

[workspace]

218 changes: 190 additions & 28 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ pub enum RevoraError {
/// Issuer transfer has expired.
IssuerTransferExpired = 43,
/// Transfer blocked because the offering has pre-cliff vesting schedules.
VestingTransferBlocked = 48,
VestingTransferBlocked = 52,
/// Contract is paused.
ContractPaused = 44,
/// Blacklist size limit exceeded.
Expand All @@ -169,18 +169,29 @@ pub enum RevoraError {
///
/// Wire value: 48. Stable since v1.
PeriodAlreadyClosed = 48,

/// 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,

/// Disclosure URI exceeds the 256-byte maximum.
DisclosureUriTooLong = 54,
/// Empty URI paired with a non-zero hash is incoherent.
InconsistentDisclosure = 55,
}

pub mod vesting;

#[cfg(feature = "kani")]
pub mod kani_harness;

#[cfg(test)]
mod test_compute_share_invariants;
#[cfg(test)]
mod test_claim_transfer_fail;
#[cfg(test)]
mod test_compute_share_invariants;
#[cfg(test)]
mod test_duplicates;
#[cfg(test)]
mod test_event_indexed_v2;
Expand All @@ -190,6 +201,8 @@ mod test_min_revenue_threshold_boundary;
// mod test_claim_transfer_fail;
#[cfg(test)]
mod test_close_period;
#[cfg(test)]
mod test_disclosure;

// ── Event symbols ────────────────────────────────────────────
const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep");
Expand Down Expand Up @@ -300,6 +313,8 @@ const EVENT_INDEXED_V2: Symbol = symbol_short!("ev_idx2");
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");
/// Emitted when an offering's off-chain disclosure metadata is set or updated (#485).
const EVENT_DISCLOSURE_UPDATED: Symbol = symbol_short!("disc_upd");
const EVENT_TYPE_REV_INIT: Symbol = symbol_short!("rv_init");
const EVENT_TYPE_REV_OVR: Symbol = symbol_short!("rv_ovr");
const EVENT_TYPE_REV_REJ: Symbol = symbol_short!("rv_rej");
Expand Down Expand Up @@ -421,6 +436,18 @@ pub struct InvestmentConstraintsConfig {
pub max_stake: i128,
}

/// Off-chain disclosure binding for an offering (#485).
/// Binds a URI (PPM, K-1 template, etc.) to a 32-byte integrity hash so
/// investors can verify the off-chain document without trusting the issuer alone.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct DisclosureMeta {
/// Off-chain document URI, e.g. `ipfs://…` or `https://…`. Max 256 bytes.
pub uri: Bytes,
/// SHA-256 (or equivalent) hash of the document at `uri`. Exactly 32 bytes.
pub hash: BytesN<32>,
}

/// Per-offering audit log summary (#34).
/// Summarizes the audit trail for a specific offering.
#[contracttype]
Expand Down Expand Up @@ -614,11 +641,28 @@ pub struct SnapshotEntry {
pub total_bps: u32,
}

/// Tiered pause state for the contract.
///
/// - `NotPaused` – all operations open.
/// - `SoftPaused` – reports/deposits blocked; `claim` still allowed.
/// - `HardPaused` – all state-mutating operations blocked, including `claim`.
#[contracttype]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PauseState {
NotPaused,
SoftPaused,
HardPaused,
}

/// Primary storage keys for core contract state.
/// Split from the full key set to stay within the Soroban XDR union variant limit (≤50).
///
/// Scoped to the crate: storage keys are an internal implementation detail and are not part
/// of the contract's external interface, so no contract spec entry is generated for them.
/// This also keeps the enum clear of the 50-case spec union limit as new keys are added.
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
pub(crate) enum DataKey {
/// Deprecated shared period tracker retained for backward compatibility with older storage.
LastPeriodId(OfferingId),
Blacklist(OfferingId),
Expand Down Expand Up @@ -767,6 +811,18 @@ pub enum DataKey2 {

/// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period.
ClosedPeriod(OfferingId, u64),

/// Off-chain disclosure metadata (URI + hash) for an offering (#485).
DisclosureMeta(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),
}

/// Maximum number of offerings returned in a single page.
Expand Down Expand Up @@ -1676,7 +1732,11 @@ impl RevoraRevenueShare {

/// Admin-only setter to adjust the stored layout version (used by migrations/tests).
/// Emits `EVENT_LAYOUT_VERSION` when the stored value is changed.
pub fn set_storage_layout_version(env: Env, caller: Address, v: u32) -> Result<(), RevoraError> {
pub fn set_storage_layout_version(
env: Env,
caller: Address,
v: u32,
) -> Result<(), RevoraError> {
let admin: Address =
env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?;
admin.require_auth();
Expand Down Expand Up @@ -1814,7 +1874,7 @@ impl RevoraRevenueShare {
let effective_expiry = if expiry_secs == 0 {
0
} else {
expiry_secs.max(MIN_ISSUER_TRANSFER_EXPIRY_SECS).min(MAX_ISSUER_TRANSFER_EXPIRY_SECS)
expiry_secs.clamp(MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS)
};

let timestamp = env.ledger().timestamp();
Expand Down Expand Up @@ -2016,32 +2076,66 @@ impl RevoraRevenueShare {
.set(&DataKey::OfferingIssuer(new_offering_id.clone()), &new_issuer.clone());

// Migrate configuration state linked to the old OfferingId (#1344)
if let Some(config) = env.storage().persistent().get::<_, ConcentrationLimitConfig>(&DataKey::ConcentrationLimit(offering_id.clone())) {
env.storage().persistent().set(&DataKey::ConcentrationLimit(new_offering_id.clone()), &config);
if let Some(config) = env
.storage()
.persistent()
.get::<_, ConcentrationLimitConfig>(&DataKey::ConcentrationLimit(offering_id.clone()))
{
env.storage()
.persistent()
.set(&DataKey::ConcentrationLimit(new_offering_id.clone()), &config);
env.storage().persistent().remove(&DataKey::ConcentrationLimit(offering_id.clone()));
}
if let Some(current) = env.storage().persistent().get::<_, u32>(&DataKey::CurrentConcentration(offering_id.clone())) {
env.storage().persistent().set(&DataKey::CurrentConcentration(new_offering_id.clone()), &current);
if let Some(current) = env
.storage()
.persistent()
.get::<_, u32>(&DataKey::CurrentConcentration(offering_id.clone()))
{
env.storage()
.persistent()
.set(&DataKey::CurrentConcentration(new_offering_id.clone()), &current);
env.storage().persistent().remove(&DataKey::CurrentConcentration(offering_id.clone()));
}
if let Some(mode) = env.storage().persistent().get::<_, RoundingMode>(&DataKey::RoundingMode(offering_id.clone())) {
if let Some(mode) = env
.storage()
.persistent()
.get::<_, RoundingMode>(&DataKey::RoundingMode(offering_id.clone()))
{
env.storage().persistent().set(&DataKey::RoundingMode(new_offering_id.clone()), &mode);
env.storage().persistent().remove(&DataKey::RoundingMode(offering_id.clone()));
}
if let Some(constraints) = env.storage().persistent().get::<_, InvestmentConstraintsConfig>(&DataKey2::InvestmentConstraints(offering_id.clone())) {
env.storage().persistent().set(&DataKey2::InvestmentConstraints(new_offering_id.clone()), &constraints);
env.storage().persistent().remove(&DataKey2::InvestmentConstraints(offering_id.clone()));
if let Some(constraints) = env.storage().persistent().get::<_, InvestmentConstraintsConfig>(
&DataKey2::InvestmentConstraints(offering_id.clone()),
) {
env.storage()
.persistent()
.set(&DataKey2::InvestmentConstraints(new_offering_id.clone()), &constraints);
env.storage()
.persistent()
.remove(&DataKey2::InvestmentConstraints(offering_id.clone()));
}
if let Some(delay) = env.storage().persistent().get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone())) {
env.storage().persistent().set(&DataKey::ClaimDelaySecs(new_offering_id.clone()), &delay);
if let Some(delay) =
env.storage().persistent().get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone()))
{
env.storage()
.persistent()
.set(&DataKey::ClaimDelaySecs(new_offering_id.clone()), &delay);
env.storage().persistent().remove(&DataKey::ClaimDelaySecs(offering_id.clone()));
}
if let Some(snap_config) = env.storage().persistent().get::<_, bool>(&DataKey::SnapshotConfig(offering_id.clone())) {
env.storage().persistent().set(&DataKey::SnapshotConfig(new_offering_id.clone()), &snap_config);
if let Some(snap_config) =
env.storage().persistent().get::<_, bool>(&DataKey::SnapshotConfig(offering_id.clone()))
{
env.storage()
.persistent()
.set(&DataKey::SnapshotConfig(new_offering_id.clone()), &snap_config);
env.storage().persistent().remove(&DataKey::SnapshotConfig(offering_id.clone()));
}
if let Some(snap_ref) = env.storage().persistent().get::<_, u64>(&DataKey::LastSnapshotRef(offering_id.clone())) {
env.storage().persistent().set(&DataKey::LastSnapshotRef(new_offering_id.clone()), &snap_ref);
if let Some(snap_ref) =
env.storage().persistent().get::<_, u64>(&DataKey::LastSnapshotRef(offering_id.clone()))
{
env.storage()
.persistent()
.set(&DataKey::LastSnapshotRef(new_offering_id.clone()), &snap_ref);
env.storage().persistent().remove(&DataKey::LastSnapshotRef(offering_id.clone()));
}

Expand Down Expand Up @@ -2147,9 +2241,7 @@ impl RevoraRevenueShare {
let eo = event_only.unwrap_or(false);
env.storage().persistent().set(&DataKey2::ContractFlags, &(false, eo));
// Stamp storage layout version for future compatibility checks.
env.storage()
.persistent()
.set(&DataKey::StorageLayoutVersion, &STORAGE_LAYOUT_VERSION);
env.storage().persistent().set(&DataKey::StorageLayoutVersion, &STORAGE_LAYOUT_VERSION);
env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION);

env.events().publish((EVENT_INIT, admin.clone()), (safety, eo));
Expand Down Expand Up @@ -5533,10 +5625,8 @@ impl RevoraRevenueShare {
let closed_at = env.ledger().timestamp();
env.storage().persistent().set(&closed_key, &closed_at);

env.events().publish(
(EVENT_PERIOD_CLOSED, issuer, namespace, token),
(period_id, closed_at),
);
env.events()
.publish((EVENT_PERIOD_CLOSED, issuer, namespace, token), (period_id, closed_at));

Ok(())
}
Expand All @@ -5552,6 +5642,79 @@ impl RevoraRevenueShare {
let offering_id = OfferingId { issuer, namespace, token };
env.storage().persistent().has(&DataKey2::ClosedPeriod(offering_id, period_id))
}

/// Attach or replace off-chain disclosure metadata for an offering (#485).
///
/// Issuers use this to bind a private placement memorandum (PPM), K-1 template,
/// or any other off-chain document to the on-chain record so investors can verify
/// the document's integrity via the stored hash.
///
/// ### Validation
/// - `uri` must be at most 256 bytes; longer values return `DisclosureUriTooLong`.
/// - An empty `uri` paired with a non-zero `hash` returns `InconsistentDisclosure`.
/// (A zero-hash with an empty URI clears any previous disclosure.)
///
/// ### Auth ordering
/// `issuer.require_auth()` is called immediately after the frozen guard.
pub fn update_disclosure(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
uri: Bytes,
hash: BytesN<32>,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
issuer.require_auth();

let offering_id = OfferingId {
issuer: issuer.clone(),
namespace: namespace.clone(),
token: token.clone(),
};

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);
}

// URI length guard: max 256 bytes.
if uri.len() > 256 {
return Err(RevoraError::DisclosureUriTooLong);
}

// Coherence guard: non-zero hash requires a URI.
let zero_hash = BytesN::from_array(&env, &[0u8; 32]);
if uri.is_empty() && hash != zero_hash {
return Err(RevoraError::InconsistentDisclosure);
}

let key = DataKey2::DisclosureMeta(offering_id);
env.storage()
.persistent()
.set(&key, &DisclosureMeta { uri: uri.clone(), hash: hash.clone() });

Self::emit_v2_event(
&env,
(EVENT_DISCLOSURE_UPDATED, issuer, namespace, token),
(uri, hash),
);

Ok(())
}

/// Return the off-chain disclosure metadata for an offering, if set.
pub fn get_disclosure(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
) -> Option<DisclosureMeta> {
let offering_id = OfferingId { issuer, namespace, token };
env.storage().persistent().get(&DataKey2::DisclosureMeta(offering_id))
}
}

// ── Holder shares, claims, admin, governance, and utility methods ─────────────
Expand Down Expand Up @@ -5853,7 +6016,6 @@ impl RevoraRevenueShare {
/// * `max_periods` - The maximum number of periods to claim in this call.
///
/// # Events

/// Read-only: return a page of pending period IDs for a holder, bounded by `limit`.
/// Returns `(periods_page, next_cursor)` where `next_cursor` is `Some(next_index)` when more
/// periods remain, otherwise `None`. `limit` of 0 or greater than `MAX_PAGE_LIMIT` will be
Expand Down
Loading
Loading