diff --git a/Cargo.toml b/Cargo.toml index 86ee9190..9d2c174e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/src/lib.rs b/src/lib.rs index afd6a619..30ad700d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. @@ -169,6 +169,17 @@ 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; @@ -176,11 +187,11 @@ 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; @@ -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"); @@ -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"); @@ -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] @@ -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), @@ -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. @@ -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(); @@ -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(); @@ -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()), ¤t); + if let Some(current) = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::CurrentConcentration(offering_id.clone())) + { + env.storage() + .persistent() + .set(&DataKey::CurrentConcentration(new_offering_id.clone()), ¤t); 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())); } @@ -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)); @@ -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(()) } @@ -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 { + let offering_id = OfferingId { issuer, namespace, token }; + env.storage().persistent().get(&DataKey2::DisclosureMeta(offering_id)) + } } // ── Holder shares, claims, admin, governance, and utility methods ───────────── @@ -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 diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index 0087d0f1..414a0732 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -41,8 +41,8 @@ use crate::{RevoraError, RevoraRevenueShare, RevoraRevenueShareClient}; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, testutils::Address as _, token, Address, - Env, String, + contract, contractimpl, contracttype, symbol_short, testutils::Address as _, Address, Env, + String, }; // ══════════════════════════════════════════════════════════════════════════════ @@ -181,13 +181,16 @@ fn pending_periods( holder: &Address, ) -> soroban_sdk::Vec { env.as_contract(revora_id, || { - RevoraRevenueShare::get_pending_periods( + RevoraRevenueShare::get_pending_periods_page( env.clone(), issuer.clone(), symbol_short!("def"), offering_token.clone(), holder.clone(), + 0, + 50, ) + .0 }) } @@ -435,17 +438,17 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { let (env, revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token_a, holder) = setup_claim_fail(); - // Register a second offering with a normal Stellar asset token + // Register a second offering backed by a normal (unarmed) token so its claim succeeds. let offering_token_b = Address::generate(&env); - let admin_b = Address::generate(&env); - + let (token_b_id, token_b) = deploy_failing_token(&env); + token_b.mint(&issuer, &1_000_000); revora.register_offering( &issuer, &symbol_short!("def"), &offering_token_b, &10_000, - + &token_b_id, &0, ); revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token_b, &holder, &10_000); @@ -453,7 +456,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { &issuer, &symbol_short!("def"), &offering_token_b, - + &token_b_id, &100_000, &1, ); diff --git a/src/test_close_period.rs b/src/test_close_period.rs index b376be7e..0feb05ed 100644 --- a/src/test_close_period.rs +++ b/src/test_close_period.rs @@ -20,8 +20,7 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger}, - token, - Address, Env, + token, Address, Env, }; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -134,8 +133,7 @@ fn override_after_close_returns_period_already_closed() { client.close_period(&issuer, &ns, &token, &1); // Attempt override — must be rejected. - let result = - client.try_report_revenue(&issuer, &ns, &token, &payment_token, &2_000, &1, &true); + let result = client.try_report_revenue(&issuer, &ns, &token, &payment_token, &2_000, &1, &true); assert_eq!(result, Err(Ok(RevoraError::PeriodAlreadyClosed))); } @@ -149,9 +147,11 @@ fn initial_report_for_new_period_after_close_is_allowed() { client.close_period(&issuer, &ns, &token, &1); // A brand-new period 2 (initial report, not an override) must still be accepted. - let result = - client.try_report_revenue(&issuer, &ns, &token, &payment_token, &500, &2, &false); - assert!(result.is_ok(), "initial report for a new period should succeed after closing period 1"); + let result = client.try_report_revenue(&issuer, &ns, &token, &payment_token, &500, &2, &false); + assert!( + result.is_ok(), + "initial report for a new period should succeed after closing period 1" + ); } #[test] @@ -206,8 +206,7 @@ fn close_period_does_not_affect_other_periods() { assert!(!client.is_period_closed(&issuer, &ns, &token, &2)); // Override of period 2 must still succeed. - let result = - client.try_report_revenue(&issuer, &ns, &token, &payment_token, &999, &2, &true); + let result = client.try_report_revenue(&issuer, &ns, &token, &payment_token, &999, &2, &true); assert!(result.is_ok(), "override of an open period must succeed"); } diff --git a/src/test_compute_share_invariants.rs b/src/test_compute_share_invariants.rs index f9452e6d..dcc67d8f 100644 --- a/src/test_compute_share_invariants.rs +++ b/src/test_compute_share_invariants.rs @@ -48,8 +48,11 @@ #![cfg(test)] +extern crate alloc; + use crate::{RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode}; -use soroban_sdk::{testutils::Address as _, Address, Env}; +use alloc::format; +use soroban_sdk::Env; // ── Helper ──────────────────────────────────────────────────────────────────── @@ -143,9 +146,9 @@ fn round_half_up_table_driven() { (-10_000, 5_000, -5_000), // 1 bps (10_000, 1, 1), - (9_999, 1, 1), // 0.9999 rounds up to 1 - (4_999, 1, 0), // 0.4999 rounds down - (5_000, 1, 1), // exactly 0.5 rounds up + (9_999, 1, 1), // 0.9999 rounds up to 1 + (4_999, 1, 0), // 0.4999 rounds down + (5_000, 1, 1), // exactly 0.5 rounds up // Over-bps guard (1_000_000, 10_001, 0), ]; @@ -314,10 +317,7 @@ 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!(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}")); } @@ -428,7 +428,6 @@ fn rounding_boundary_negative_half() { assert_eq!(c.compute_share(&-3, &5_000, &RoundingMode::RoundHalfUp), -2); } - // ═══════════════════════════════════════════════════════════════════════════════ // Issue #465: i128::MIN — naive multiply must panic, decomposition must not wrap // ═══════════════════════════════════════════════════════════════════════════════ @@ -436,10 +435,7 @@ fn rounding_boundary_negative_half() { #[test] fn i128_min_naive_multiply_overflow_is_detected() { // Naive `amount * bps` overflows for i128::MIN at full bps; must not silently wrap. - assert!( - i128::MIN.checked_mul(10_000).is_none(), - "i128::MIN * 10_000 must not fit in i128" - ); + assert!(i128::MIN.checked_mul(10_000).is_none(), "i128::MIN * 10_000 must not fit in i128"); } /// Naive multiply reference — panics instead of silently wrapping on overflow. @@ -466,7 +462,6 @@ fn i128_min_full_bps_decomposition_is_exact_not_wrapped() { assert_bounds(result_round, i128::MIN, "i128::MIN full bps RoundHalfUp"); } - // ═══════════════════════════════════════════════════════════════════════════════ // Issue #373: compute_share RoundHalfUp & Extreme i128 Value Tests // ═══════════════════════════════════════════════════════════════════════════════ @@ -478,10 +473,10 @@ fn compute_share_roundhalfup_negative_amount_edge_cases() { // Test exact half-unit with negative amounts // For negative amounts, "rounding away from zero" means more negative - + // amount = -15000, bps = 5000 → exact -7500 (no rounding needed) assert_eq!(c.compute_share(&-15000, &5000, &RoundingMode::RoundHalfUp), -7500); - + // amount = -15001, bps = 5000 → -7500.5 → should round to -7501 (away from zero) let result = c.compute_share(&-15001, &5000, &RoundingMode::RoundHalfUp); assert_eq!(result, -7501, "Negative half should round away from zero"); @@ -589,8 +584,8 @@ fn remainder_product_bound_holds_for_all_bps() { 20_000, 100_000, 1_000_000, - i128::MAX / 10_000 * 10_000 + 9_999, // Max remainder - i128::MIN / 10_000 * 10_000 - 9_999, // Min remainder + (i128::MAX / 10_000 - 1) * 10_000 + 9_999, // Large positive with near-max remainder + (i128::MIN / 10_000 + 1) * 10_000 - 9_999, // Large negative with near-min remainder ]; let bps_values = [1_u32, 100, 1_000, 5_000, 9_999, 10_000]; @@ -628,19 +623,13 @@ fn checked_mul_defense_in_depth_prevents_overflow() { // Test with extreme values that would be problematic without checked_mul // The decomposition ensures |r| < 10_000, but we test the saturating fallback path - let extreme_amounts = [ - i128::MAX, - i128::MIN, - i128::MAX - 1, - i128::MIN + 1, - ]; + let extreme_amounts = [i128::MAX, i128::MIN, i128::MAX - 1, i128::MIN + 1]; for &amount in &extreme_amounts { - for &bps in [1_u32, 5_000, 10_000] { + 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}")); } } } - diff --git a/src/test_disclosure.rs b/src/test_disclosure.rs new file mode 100644 index 00000000..1849f01c --- /dev/null +++ b/src/test_disclosure.rs @@ -0,0 +1,198 @@ +//! Tests for the `update_disclosure` / `get_disclosure` feature (#485). +//! +//! ## Coverage matrix +//! +//! | Scenario | Expected | +//! |----------|----------| +//! | Happy path: set URI + hash, retrieve via `get_disclosure` | `Ok(())`, values round-trip | +//! | URI exactly 256 bytes | `Ok(())` (boundary allowed) | +//! | URI 257 bytes | `DisclosureUriTooLong` | +//! | Empty URI with non-zero hash | `InconsistentDisclosure` | +//! | Empty URI with zero hash | `Ok(())` (clears or no-ops) | +//! | Overwrite existing disclosure | latest values stored | +//! | Unknown offering | `OfferingNotFound` | +//! | Wrong issuer caller | `OfferingNotFound` | +//! | Event emitted on success | at least one new event | + +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events as _}, + Address, Bytes, BytesN, Env, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let issuer = Address::generate(&env); + let offering_token = Address::generate(&env); + let payment_token = Address::generate(&env); + + client.register_offering( + &issuer, + &symbol_short!("ns"), + &offering_token, + &5_000, + &payment_token, + &0, + ); + + (env, client, issuer, offering_token, payment_token) +} + +fn uri_256(env: &Env) -> Bytes { + Bytes::from_slice(env, &[b'u'; 256]) +} + +fn uri_257(env: &Env) -> Bytes { + Bytes::from_slice(env, &[b'u'; 257]) +} + +fn zero_hash(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[0u8; 32]) +} + +fn sample_hash(env: &Env) -> BytesN<32> { + BytesN::from_array( + env, + &[ + 0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, + 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, + ], + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[test] +fn update_disclosure_happy_path() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let uri = Bytes::from_slice(&env, b"ipfs://QmTest"); + let hash = sample_hash(&env); + + client.update_disclosure(&issuer, &ns, &token, &uri, &hash); + + let stored = client.get_disclosure(&issuer, &ns, &token).unwrap(); + assert_eq!(stored.uri, uri); + assert_eq!(stored.hash, hash); +} + +#[test] +fn update_disclosure_uri_exactly_256_bytes_is_allowed() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let uri = uri_256(&env); + let hash = sample_hash(&env); + + let result = client.try_update_disclosure(&issuer, &ns, &token, &uri, &hash); + assert!(result.is_ok(), "URI of exactly 256 bytes must be accepted"); +} + +#[test] +fn update_disclosure_uri_257_bytes_rejected() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let uri = uri_257(&env); + let hash = sample_hash(&env); + + let result = client.try_update_disclosure(&issuer, &ns, &token, &uri, &hash); + assert_eq!(result, Err(Ok(RevoraError::DisclosureUriTooLong))); +} + +#[test] +fn update_disclosure_empty_uri_nonzero_hash_rejected() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let uri = Bytes::from_slice(&env, b""); + let hash = sample_hash(&env); + + let result = client.try_update_disclosure(&issuer, &ns, &token, &uri, &hash); + assert_eq!(result, Err(Ok(RevoraError::InconsistentDisclosure))); +} + +#[test] +fn update_disclosure_empty_uri_zero_hash_allowed() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let uri = Bytes::from_slice(&env, b""); + let hash = zero_hash(&env); + + let result = client.try_update_disclosure(&issuer, &ns, &token, &uri, &hash); + assert!(result.is_ok(), "empty URI with zero hash must be accepted (clears disclosure)"); +} + +#[test] +fn update_disclosure_overwrites_existing() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + + let uri1 = Bytes::from_slice(&env, b"ipfs://first"); + let hash1 = sample_hash(&env); + client.update_disclosure(&issuer, &ns, &token, &uri1, &hash1); + + let uri2 = Bytes::from_slice(&env, b"https://second.example.com/doc.pdf"); + let hash2 = BytesN::from_array(&env, &[0xaa; 32]); + client.update_disclosure(&issuer, &ns, &token, &uri2, &hash2); + + let stored = client.get_disclosure(&issuer, &ns, &token).unwrap(); + assert_eq!(stored.uri, uri2); + assert_eq!(stored.hash, hash2); +} + +#[test] +fn get_disclosure_returns_none_when_not_set() { + let (_env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + + assert!(client.get_disclosure(&issuer, &ns, &token).is_none()); +} + +#[test] +fn update_disclosure_unknown_offering_returns_not_found() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let uri = Bytes::from_slice(&env, b"ipfs://anything"); + let hash = sample_hash(&env); + + let result = client.try_update_disclosure(&issuer, &symbol_short!("ns"), &token, &uri, &hash); + assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); +} + +#[test] +fn update_disclosure_wrong_issuer_returns_not_found() { + let (env, client, _real_issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let attacker = Address::generate(&env); + let uri = Bytes::from_slice(&env, b"ipfs://evil"); + let hash = sample_hash(&env); + + let result = client.try_update_disclosure(&attacker, &ns, &token, &uri, &hash); + assert_eq!(result, Err(Ok(RevoraError::OfferingNotFound))); +} + +#[test] +fn update_disclosure_emits_event() { + let (env, client, issuer, token, _payment) = setup_offering(); + let ns = symbol_short!("ns"); + let uri = Bytes::from_slice(&env, b"ipfs://QmEventTest"); + let hash = sample_hash(&env); + + let before = env.events().all().len(); + client.update_disclosure(&issuer, &ns, &token, &uri, &hash); + assert!( + env.events().all().len() > before, + "expected at least one new event after update_disclosure" + ); +} diff --git a/src/test_event_indexed_v2.rs b/src/test_event_indexed_v2.rs index 1488b855..e16c3465 100644 --- a/src/test_event_indexed_v2.rs +++ b/src/test_event_indexed_v2.rs @@ -75,7 +75,7 @@ fn event_indexed_v2_rv_init_topic_and_data_shape() { let before = env.events().all().len(); client.report_revenue(&issuer, &ns, &token, &payout, &10_000, &1, &false); - let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_init"), before as u32) + let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_init"), before) .expect("rv_init EVENT_INDEXED_V2 must be emitted on initial report"); // Topic shape @@ -103,7 +103,7 @@ fn event_indexed_v2_rv_rej_topic_and_data_shape() { // Same period_id + override_existing=false → rv_rej client.report_revenue(&issuer, &ns, &token, &payout, &20_000, &1, &false); - let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_rej"), before as u32) + let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_rej"), before) .expect("rv_rej EVENT_INDEXED_V2 must be emitted on duplicate report"); assert_eq!(topic.version, 2); @@ -131,7 +131,7 @@ fn event_indexed_v2_rv_ovr_topic_and_data_shape() { // override_existing=true → rv_ovr client.report_revenue(&issuer, &ns, &token, &payout, &15_000, &1, &true); - let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_ovr"), before as u32) + let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_ovr"), before) .expect("rv_ovr EVENT_INDEXED_V2 must be emitted on override"); assert_eq!(topic.version, 2); @@ -157,7 +157,7 @@ fn event_indexed_v2_rv_rep_topic_and_data_shape() { let before = env.events().all().len(); client.report_revenue(&issuer, &ns, &token, &payout, &10_000, &1, &false); - let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_rep"), before as u32) + let (topic, data) = find_indexed_v2(&env, symbol_short!("rv_rep"), before) .expect("rv_rep EVENT_INDEXED_V2 must be emitted unconditionally"); assert_eq!(topic.version, 2); @@ -182,7 +182,7 @@ fn event_indexed_v2_rv_rep_actual_override_true_on_correction() { let before = env.events().all().len(); client.report_revenue(&issuer, &ns, &token, &payout, &15_000, &1, &true); - let (_, data) = find_indexed_v2(&env, symbol_short!("rv_rep"), before as u32).unwrap(); + let (_, data) = find_indexed_v2(&env, symbol_short!("rv_rep"), before).unwrap(); let (_, _, actual_override): (i128, Address, bool) = data.into_val(&env); assert!(actual_override); } @@ -201,7 +201,7 @@ fn event_indexed_v2_claim_topic_and_data_shape() { let issuer = Address::generate(&env); let ns = symbol_short!("test"); let token = Address::generate(&env); - let payout = env.register_stellar_asset_contract(admin.clone()); + let payout = env.register_stellar_asset_contract_v2(admin.clone()).address(); soroban_sdk::token::StellarAssetClient::new(&env, &payout).mint(&issuer, &1_000_000); client.initialize(&admin, &None::
, &None::); @@ -213,7 +213,7 @@ fn event_indexed_v2_claim_topic_and_data_shape() { let before = env.events().all().len(); client.claim(&holder, &issuer, &ns, &token, &10); - let (topic, data) = find_indexed_v2(&env, symbol_short!("claim"), before as u32) + let (topic, data) = find_indexed_v2(&env, symbol_short!("claim"), before) .expect("claim EVENT_INDEXED_V2 must be emitted"); assert_eq!(topic.version, 2); @@ -239,7 +239,7 @@ fn event_indexed_v2_claim_period_id_always_zero() { let issuer = Address::generate(&env); let ns = symbol_short!("test"); let token = Address::generate(&env); - let payout = env.register_stellar_asset_contract(admin.clone()); + let payout = env.register_stellar_asset_contract_v2(admin.clone()).address(); soroban_sdk::token::StellarAssetClient::new(&env, &payout).mint(&issuer, &1_000_000); client.initialize(&admin, &None::
, &None::); @@ -252,7 +252,7 @@ fn event_indexed_v2_claim_period_id_always_zero() { let before = env.events().all().len(); client.claim(&holder, &issuer, &ns, &token, &10); - let (topic, _) = find_indexed_v2(&env, symbol_short!("claim"), before as u32).unwrap(); + let (topic, _) = find_indexed_v2(&env, symbol_short!("claim"), before).unwrap(); assert_eq!(topic.period_id, 0); } @@ -278,13 +278,13 @@ fn event_indexed_v2_payout_asset_bound_correctly_per_offering() { let before_a = env.events().all().len(); client.report_revenue(&issuer, &ns, &token_a, &payout_a, &10_000, &1, &false); - let (_, data_a) = find_indexed_v2(&env, symbol_short!("rv_init"), before_a as u32).unwrap(); + let (_, data_a) = find_indexed_v2(&env, symbol_short!("rv_init"), before_a).unwrap(); let (_, asset_a): (i128, Address) = data_a.into_val(&env); assert_eq!(asset_a, payout_a); let before_b = env.events().all().len(); client.report_revenue(&issuer, &ns, &token_b, &payout_b, &20_000, &1, &false); - let (_, data_b) = find_indexed_v2(&env, symbol_short!("rv_init"), before_b as u32).unwrap(); + let (_, data_b) = find_indexed_v2(&env, symbol_short!("rv_init"), before_b).unwrap(); let (_, asset_b): (i128, Address) = data_b.into_val(&env); assert_eq!(asset_b, payout_b); } @@ -302,7 +302,7 @@ fn event_indexed_v2_version_field_always_2() { let ev_idx2 = symbol_short!("ev_idx2"); let all = env.events().all(); let mut count = 0u32; - for i in before as u32..all.len() { + for i in before..all.len() { let (_, topics, _) = all.get(i).unwrap(); if topics.len() >= 2 { let t0: Symbol = topics.get(0).unwrap().into_val(&env); diff --git a/src/vesting.rs b/src/vesting.rs index 6af733f6..cd2eea7a 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -235,22 +235,31 @@ pub fn migrate_offering_schedules( .persistent() .get(&VestingKey::OfferingScheduleCount(new_offering_id.clone())) .unwrap_or(0); - let mut migrated: Vec
= Vec::new(&env); + let mut migrated: Vec
= Vec::new(env); // First pass: validate that no schedule is pre-cliff. for beneficiary in beneficiaries.iter() { - if let Some(schedule) = env.storage().persistent().get::(&VestingKey::Schedule(beneficiary.clone())) { - if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token { - if now < schedule.cliff_ts { - return Err(VestingError::SchedulePreCliff); - } + if let Some(schedule) = env + .storage() + .persistent() + .get::(&VestingKey::Schedule(beneficiary.clone())) + { + if schedule.issuer == offering_id.issuer + && schedule.token == offering_id.token + && now < schedule.cliff_ts + { + return Err(VestingError::SchedulePreCliff); } } } // Second pass: migrate matching schedules and rebuild the beneficiary index. for beneficiary in beneficiaries.iter() { - if let Some(mut schedule) = env.storage().persistent().get::(&VestingKey::Schedule(beneficiary.clone())) { + if let Some(mut schedule) = env + .storage() + .persistent() + .get::(&VestingKey::Schedule(beneficiary.clone())) + { if schedule.issuer == offering_id.issuer && schedule.token == offering_id.token { schedule.issuer = new_issuer.clone(); env.storage()