From 54fed408c65a32d096d287c112209979186f8fe9 Mon Sep 17 00:00:00 2001 From: Mayorlay Date: Sun, 31 May 2026 15:05:25 +0000 Subject: [PATCH 1/2] feat: add prove_distribution endpoint for verifiable per-holder payouts - Add DistributionEntry struct (holder, share_bps, normalized_payout) - Add Bytes import for sha256 digest construction - Scaffold for prove_distribution_for_period endpoint --- src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index fa7e55ad..0c6ff335 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ )] use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address, - BytesN, Env, IntoVal, Map, Symbol, Vec, + Bytes, BytesN, Env, IntoVal, Map, Symbol, Vec, }; // Issue #109 — Revenue report correction and audit-summary reconciliation are @@ -400,6 +400,25 @@ pub struct AuditReconciliationResult { pub is_saturated: bool, } +/// One entry in a distribution proof: the holder's address, their share in basis points, +/// and the normalized payout computed by the contract for a specific period. +/// +/// Returned by `prove_distribution_for_period`. The ordering of entries in the returned +/// vector matches the order of the `holders` input slice exactly, enabling deterministic +/// digest verification by off-chain indexers. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DistributionEntry { + /// The holder's address. + pub holder: Address, + /// The holder's share in basis points (0–10000). + pub share_bps: u32, + /// The normalized payout computed by the contract for this period. + /// Equals `compute_share(normalize_amount(period_revenue, decimals), share_bps, rounding_mode)`. + /// Zero when `share_bps == 0` or `period_revenue == 0`. + pub normalized_payout: i128, +} + /// Pending issuer transfer details including expiry tracking. #[contracttype] #[derive(Clone, Debug, PartialEq)] From fbc6b96a856a8b15ba7adf9e69af77383e795296 Mon Sep 17 00:00:00 2001 From: Mayorlay Date: Mon, 1 Jun 2026 03:29:01 +0000 Subject: [PATCH 2/2] feat: add prove_distribution endpoint for verifiable per-holder payouts - Add prove_distribution_for_period to #[contractimpl] block so it is accessible via the generated client - Reuses compute_share, normalize_amount, and per-offering RoundingMode/ decimals for contract-truth payout computation - Returns Vec + SHA-256 digest over XDR payload (issuer || namespace || token || period_id || entries) - Holders capped at MAX_CHUNK_PERIODS (200); input order preserved exactly - Fix Hash<32> -> BytesN<32> (.into()), entries clone before to_xdr move, DataKey::BlacklistSizeLimit -> DataKey2::BlacklistSizeLimit - Move get_pending_periods into contractimpl block (was inaccessible) - Fix pre-existing compile errors in test_claim_transfer_fail.rs - Add 11 tests: normal case, empty holders, unknown period_id, share_bps==0, 6-decimal normalization, RoundHalfUp, ordering, determinism, cap at 200, entry fields, different period digests - Document Distribution Proofs section in README --- README.md | 48 +++ src/lib.rs | 228 +++++++++--- src/test_claim_transfer_fail.rs | 28 +- src/test_prove_distribution.rs | 607 ++++++++++++++++++++++++++++++++ 4 files changed, 844 insertions(+), 67 deletions(-) create mode 100644 src/test_prove_distribution.rs diff --git a/README.md b/README.md index d831d635..2facc095 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Soroban contract for revenue-share offerings and blacklist management. | `set_min_revenue_threshold` | `issuer: Address`, `token: Address`, `min_amount: i128` | `Result<(), RevoraError>` | issuer | Per-offering minimum revenue for new periods. When a new `report_revenue` call is below the threshold, the contract emits `rev_below` and skips report/audit state updates. Stored periods can still be corrected explicitly with `override_existing=true`. | | `get_min_revenue_threshold` | `issuer: Address`, `token: Address` | `i128` | — | Minimum revenue threshold for offering (0 = none). | | `compute_share` | `amount: i128`, `revenue_share_bps: u32`, `mode: RoundingMode` | `i128` | — | Compute share of amount at given bps with given rounding. Bounds: 0 ≤ result ≤ amount. | +| `prove_distribution_for_period` | `issuer: Address`, `namespace: Symbol`, `token: Address`, `period_id: u64`, `holders: Vec
` | `(Vec, BytesN<32>)` | — | Return a deterministic per-holder distribution proof for a single period. See [Distribution Proofs](#distribution-proofs) below. | | `propose_issuer_transfer` | `token: Address`, `new_issuer: Address` | `Result<(), RevoraError>` | current issuer | Propose transferring issuer control to a new address. First step of two-step transfer. | | `accept_issuer_transfer` | `token: Address` | `Result<(), RevoraError>` | proposed new issuer | Accept a pending issuer transfer. Completes the transfer and grants full control to new issuer. | | `cancel_issuer_transfer` | `token: Address` | `Result<(), RevoraError>` | current issuer | Cancel a pending issuer transfer before it's accepted. | @@ -112,12 +113,59 @@ Auth failures (e.g. wrong signer) are signaled by host/panic, not `RevoraError`. - **Off-chain:** Prefer small page sizes and bounded blacklist sizes for predictable gas. See storage/gas tests in `src/test.rs` for stress behavior. - **Holder concentration:** Concentration is not computed on-chain (no token balance reads). Issuer or indexer calls `report_concentration(issuer, token, bps)` with the current top-holder share in bps; the contract stores it and enforces or warns based on `set_concentration_limit`. Use `try_report_revenue` when enforcement may be enabled. - **Rounding:** Use `compute_share(amount, revenue_share_bps, mode)` for consistent distribution math. Per-offering default is `get_rounding_mode(issuer, token)` (Truncation if unset). Sum of shares must not exceed total; both modes keep result in [0, amount]. +- **Distribution proofs:** Use `prove_distribution_for_period(issuer, namespace, token, period_id, holders)` to obtain a contract-computed, verifiable per-holder payout vector and a SHA-256 digest. Off-chain indexers can call this endpoint and compare the returned digest against their own reconstruction to detect drift from contract math. See [Distribution Proofs](#distribution-proofs) below. - **Issuer Transfer:** See [ISSUER_TRANSFER.md](./ISSUER_TRANSFER.md) for comprehensive documentation on securely transferring issuer control via the two-step propose/accept flow. - **Payment token locking:** Once an offering's payout asset is set at registration, all deposits must use that same token. See [docs/payment-token-locking.md](./docs/payment-token-locking.md) for invariants and test coverage. - **Payment token decimals:** Different Stellar assets use different decimal precisions (e.g., USDC=6, XLM=7, WBTC=8). Use `set_payment_token_decimals` to configure the offering's asset precision; the contract normalizes raw amounts to 7-decimal canonical units before computing holder shares. See [docs/payment-token-decimal-compatibility.md](./docs/payment-token-decimal-compatibility.md) for details and examples. - **Testnet mode:** Admin can enable testnet mode via `set_testnet_mode(true)` to relax certain validations for non-production deployments. When enabled: (1) `register_offering` allows `revenue_share_bps > 10000`, (2) `report_revenue` skips concentration enforcement. Use only for testnet/development environments. Check mode with `is_testnet_mode()`. - **Reporting and claiming windows:** Issuers can optionally restrict when `report_revenue` and `claim` are permitted using time-based access windows. See [Time Windows](#time-based-access-windows-reporting--claiming) below. +### Distribution Proofs + +`prove_distribution_for_period(issuer, namespace, token, period_id, holders)` is a **read-only** endpoint that lets off-chain indexers verify their payout reconstruction against contract truth. + +#### What it returns + +`(Vec, BytesN<32>)` — a per-holder vector and a SHA-256 digest. + +Each `DistributionEntry` contains: +- `holder: Address` — the holder's address (same order as the input `holders` slice) +- `share_bps: u32` — the holder's on-chain share in basis points +- `normalized_payout: i128` — `compute_share(normalize_amount(period_revenue, decimals), share_bps, rounding_mode)` + +#### Digest construction + +``` +digest = SHA-256( + XDR(issuer) || XDR(namespace) || XDR(token) || XDR(period_id) || XDR(entries) +) +``` + +The digest covers the full output vector in input order. Off-chain indexers reproduce it by: +1. Calling `prove_distribution_for_period` with the same ordered `holders` slice. +2. Computing the same SHA-256 over the XDR-serialised fields. +3. Comparing — any mismatch indicates drift from contract math. + +#### Deterministic ordering + +The contract preserves the **caller-supplied order** of `holders` exactly. There is no on-chain sorting. Off-chain tools must use a stable, agreed-upon ordering (e.g. lexicographic by address bytes) and pass the same order on every call to get a reproducible digest. + +#### Edge cases + +| Condition | Behaviour | +|-----------|-----------| +| Unknown `period_id` (no deposit) | `normalized_payout = 0` for all holders; digest still valid | +| `share_bps == 0` for a holder | `normalized_payout = 0` | +| Empty `holders` vec | Returns empty `entries`; digest is SHA-256 of the header-only payload | +| `holders.len() > 200` | Silently capped at `MAX_CHUNK_PERIODS` (200); paginate off-chain | +| Decimals ≠ 7 | `normalize_amount` scales the raw revenue before `compute_share` | + +#### Security + +- **Read-only**: no storage writes, no auth required. +- **Tamper-evident**: the digest covers contract-computed values only. It cannot be forged without changing on-chain `HolderShare` or `PeriodRevenue` state. +- **No double-counting risk**: the function does not transfer tokens or advance any index. + ### Time-Based Access Windows (Reporting & Claiming) Issuers can configure per-offering time windows that gate `report_revenue` and `claim`. diff --git a/src/lib.rs b/src/lib.rs index 0c6ff335..de8322d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] #![deny(unsafe_code)] #![allow(dead_code)] #![allow(unused_variables)] @@ -161,12 +161,14 @@ pub enum RevoraError { pub mod vesting; +#[cfg(test)] +mod test_claim_transfer_fail; #[cfg(test)] mod test_duplicates; #[cfg(test)] mod test_min_revenue_threshold_boundary; #[cfg(test)] -mod test_claim_transfer_fail; +mod test_prove_distribution; // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); @@ -2333,7 +2335,15 @@ impl RevoraRevenueShare { let blacklist = if event_only { Vec::new(&env) } else { - Self::get_blacklist_page(env.clone(), issuer.clone(), namespace.clone(), token.clone(), 0, MAX_PAGE_LIMIT).0 + Self::get_blacklist_page( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + 0, + MAX_PAGE_LIMIT, + ) + .0 }; let mut actual_override = false; @@ -3358,8 +3368,8 @@ impl RevoraRevenueShare { /// ### Returns /// The maximum allowed blacklist size for the offering. fn get_effective_blacklist_limit(env: &Env, offering_id: &OfferingId) -> u32 { - let key = DataKey::BlacklistSizeLimit(offering_id.clone()); - env.storage().persistent().get::(&key).unwrap_or(MAX_BLACKLIST_SIZE) + let key = DataKey2::BlacklistSizeLimit(offering_id.clone()); + env.storage().persistent().get::(&key).unwrap_or(MAX_BLACKLIST_SIZE) } /// Set the per-offering blacklist size limit. @@ -4683,41 +4693,87 @@ impl RevoraRevenueShare { } /// Configure the reporting access window for an offering. If unset, always open. - pub fn set_report_window(env: Env, issuer: Address, namespace: Symbol, token: Address, start_timestamp: u64, end_timestamp: u64) -> Result<(), RevoraError> { + pub fn set_report_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); } + 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 window = AccessWindow { start_timestamp, end_timestamp }; Self::validate_window(&window)?; - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; env.storage().persistent().set(&WindowDataKey::Report(offering_id), &window); - env.events().publish((EVENT_REPORT_WINDOW_SET, issuer, namespace, token), (start_timestamp, end_timestamp)); + env.events().publish( + (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); Ok(()) } /// Configure the claiming access window for an offering. If unset, always open. - pub fn set_claim_window(env: Env, issuer: Address, namespace: Symbol, token: Address, start_timestamp: u64, end_timestamp: u64) -> Result<(), RevoraError> { + pub fn set_claim_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); } + 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 window = AccessWindow { start_timestamp, end_timestamp }; Self::validate_window(&window)?; - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; env.storage().persistent().set(&WindowDataKey::Claim(offering_id), &window); - env.events().publish((EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), (start_timestamp, end_timestamp)); + env.events().publish( + (EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); Ok(()) } /// Read configured reporting window (if any) for an offering. - pub fn get_report_window(env: Env, issuer: Address, namespace: Symbol, token: Address) -> Option { + pub fn get_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().get(&WindowDataKey::Report(offering_id)) } /// Read configured claiming window (if any) for an offering. - pub fn get_claim_window(env: Env, issuer: Address, namespace: Symbol, token: Address) -> Option { + pub fn get_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { let offering_id = OfferingId { issuer, namespace, token }; env.storage().persistent().get(&WindowDataKey::Claim(offering_id)) } @@ -4888,6 +4944,114 @@ impl RevoraRevenueShare { Ok(total_payout) } + + /// Return a deterministic per-holder distribution proof for a single period. + /// + /// For each address in `holders` (capped at `MAX_CHUNK_PERIODS`), the contract reads + /// the stored `HolderShare`, normalizes the period revenue to 7-decimal canonical units, + /// and computes the payout using the offering's persisted `RoundingMode`. The result + /// vector preserves the input order exactly, so off-chain indexers can reproduce the + /// digest by applying the same ordering. + /// + /// ### Digest construction + /// `SHA-256(XDR(issuer) || XDR(namespace) || XDR(token) || XDR(period_id) || XDR(entries))` + /// where `entries` is the `Vec` returned alongside the digest. + /// An unknown `period_id` returns zero payouts; callers detect this by checking + /// that all `normalized_payout` values are zero. + /// + /// ### Bounds + /// `holders` is silently capped at `MAX_CHUNK_PERIODS` (200). + /// + /// ### Security + /// - Read-only: no storage writes, no auth required. + /// - Digest covers contract-computed values only; cannot be forged without + /// changing on-chain `HolderShare` or `PeriodRevenue` state. + pub fn prove_distribution_for_period( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + period_id: u64, + holders: Vec
, + ) -> (Vec, BytesN<32>) { + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + // Look up period revenue; treat missing period as zero revenue (unknown period). + let revenue: i128 = env + .storage() + .persistent() + .get(&DataKey::PeriodRevenue(offering_id.clone(), period_id)) + .unwrap_or(0); + + let decimals = Self::get_payment_token_decimals( + env.clone(), + issuer.clone(), + namespace.clone(), + token.clone(), + ); + let normalized_revenue = Self::normalize_amount(revenue, decimals); + + let mode = + Self::get_rounding_mode(env.clone(), issuer.clone(), namespace.clone(), token.clone()); + + // Cap input to MAX_CHUNK_PERIODS to bound compute cost. + let cap = core::cmp::min(holders.len(), MAX_CHUNK_PERIODS); + let mut entries: Vec = Vec::new(&env); + for i in 0..cap { + let holder = holders.get(i).unwrap(); + let share_bps = env + .storage() + .persistent() + .get(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0u32); + let normalized_payout = + Self::compute_share(env.clone(), normalized_revenue, share_bps, mode); + entries.push_back(DistributionEntry { holder, share_bps, normalized_payout }); + } + + // Build digest: SHA-256 over XDR of (issuer, namespace, token, period_id, entries). + let mut payload = Bytes::new(&env); + payload.append(&issuer.to_xdr(&env)); + payload.append(&namespace.to_xdr(&env)); + payload.append(&token.to_xdr(&env)); + payload.append(&period_id.to_xdr(&env)); + payload.append(&entries.clone().to_xdr(&env)); + let digest: BytesN<32> = env.crypto().sha256(&payload).into(); + + (entries, digest) + } + + /// Return unclaimed period IDs for a holder on an offering. + /// Ordering: by deposit index (creation order), deterministic. + pub fn get_pending_periods( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> Vec { + let offering_id = OfferingId { issuer, namespace, token }; + let count_key = DataKey::PeriodCount(offering_id.clone()); + let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + + let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder); + let start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); + + let mut periods = Vec::new(&env); + for i in start_idx..period_count { + let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); + let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); + if period_id == 0 { + continue; + } + periods.push_back(period_id); + } + periods + } } // ── Holder shares, claims, admin, governance, and utility methods ───────────── @@ -5190,34 +5354,6 @@ impl RevoraRevenueShare { /// /// # Events - /// Return unclaimed period IDs for a holder on an offering. - /// Ordering: by deposit index (creation order), deterministic (#38). - pub fn get_pending_periods( - env: Env, - issuer: Address, - namespace: Symbol, - token: Address, - holder: Address, - ) -> Vec { - let offering_id = OfferingId { issuer, namespace, token }; - let count_key = DataKey::PeriodCount(offering_id.clone()); - let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - - let idx_key = DataKey::LastClaimedIdx(offering_id.clone(), holder); - let start_idx: u32 = env.storage().persistent().get(&idx_key).unwrap_or(0); - - let mut periods = Vec::new(&env); - for i in start_idx..period_count { - let entry_key = DataKey::PeriodEntry(offering_id.clone(), i); - let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); - if period_id == 0 { - continue; - } - periods.push_back(period_id); - } - periods - } - /// 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 @@ -6296,5 +6432,3 @@ mod issue_370_373_tests { ); } } - - diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index d3e37c7c..b73c46c6 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -41,9 +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 _, token, Address, + Env, String, }; // ══════════════════════════════════════════════════════════════════════════════ @@ -71,8 +70,7 @@ impl FailingTransferToken { /// Mint tokens to `to` (test helper, no auth). pub fn mint(env: Env, to: Address, amount: i128) { - let bal: i128 = - env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); + let bal: i128 = env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); env.storage().persistent().set(&TokenKey::Balance(to), &(bal + amount)); } @@ -268,11 +266,7 @@ fn claim_transfer_fail_does_not_advance_last_claimed_idx() { pending_before.len(), "LastClaimedIdx must not advance on transfer failure" ); - assert_eq!( - pending_after.get(0), - pending_before.get(0), - "pending period IDs must be unchanged" - ); + assert_eq!(pending_after.get(0), pending_before.get(0), "pending period IDs must be unchanged"); } /// Holder balance is unchanged when claim transfer fails. @@ -326,7 +320,7 @@ fn claim_transfer_fail_then_retry_succeeds() { // Retry — should now succeed let r2 = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); assert!(r2.is_ok(), "retry after fixing token should succeed, got {r2:?}"); - assert_eq!(r2.unwrap(), 100_000, "holder should receive full payout on retry"); + assert_eq!(r2.unwrap().unwrap(), 100_000, "holder should receive full payout on retry"); // Pending periods now empty let pending = @@ -434,7 +428,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { // Register a second offering with a normal Stellar asset token let offering_token_b = Address::generate(&env); let admin_b = Address::generate(&env); - let payment_token_b = env.register_stellar_asset_contract(admin_b.clone()); + let payment_token_b = env.register_stellar_asset_contract_v2(admin_b.clone()).address(); token::StellarAssetClient::new(&env, &payment_token_b).mint(&issuer, &100_000); revora.register_offering( @@ -445,13 +439,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { &payment_token_b, &0, ); - revora.set_holder_share( - &issuer, - &symbol_short!("def"), - &offering_token_b, - &holder, - &10_000, - ); + revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token_b, &holder, &10_000); revora.deposit_revenue( &issuer, &symbol_short!("def"), @@ -468,7 +456,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { // Claim on offering B succeeds (normal token) let r_b = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token_b, &50); assert!(r_b.is_ok(), "sibling offering claim must succeed, got {r_b:?}"); - assert_eq!(r_b.unwrap(), 100_000); + assert_eq!(r_b.unwrap().unwrap(), 100_000); // Offering A: period 1 still pending let pending_a = diff --git a/src/test_prove_distribution.rs b/src/test_prove_distribution.rs new file mode 100644 index 00000000..3ba07a64 --- /dev/null +++ b/src/test_prove_distribution.rs @@ -0,0 +1,607 @@ +//! Tests for `prove_distribution_for_period`. +//! +//! Covers: normal case, empty holders, unknown period_id, share_bps==0, decimals != 7, +//! RoundHalfUp rounding, ordering affects digest, determinism, and holder cap. + +#![cfg(test)] + +use crate::{RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode}; +use soroban_sdk::{symbol_short, testutils::Address as _, token, Address, Env, Vec}; + +fn make_client() -> (Env, RevoraRevenueShareClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + (env, client) +} + +fn create_payment_token(env: &Env) -> Address { + let admin = Address::generate(env); + env.register_stellar_asset_contract_v2(admin).address() +} + +fn mint(env: &Env, token: &Address, to: &Address, amount: i128) { + token::StellarAssetClient::new(env, token).mint(to, &amount); +} + +// ── Normal case ─────────────────────────────────────────────────────────────── + +#[test] +fn prove_distribution_normal_case() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_a, &3_000u32); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_b, &2_000u32); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + holders.push_back(holder_b.clone()); + + let (entries, digest) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + assert_eq!(entries.len(), 2); + + let ea = entries.get(0).unwrap(); + assert_eq!(ea.holder, holder_a); + assert_eq!(ea.share_bps, 3_000u32); + // 10_000_000 * 3000 / 10000 = 3_000_000 + assert_eq!(ea.normalized_payout, 3_000_000i128); + + let eb = entries.get(1).unwrap(); + assert_eq!(eb.holder, holder_b); + assert_eq!(eb.share_bps, 2_000u32); + // 10_000_000 * 2000 / 10000 = 2_000_000 + assert_eq!(eb.normalized_payout, 2_000_000i128); + + // Digest must be 32 bytes and non-zero + assert_eq!(digest.len(), 32); + assert_ne!(digest, soroban_sdk::BytesN::from_array(&env, &[0u8; 32])); +} + +// ── Digest is deterministic ─────────────────────────────────────────────────── + +#[test] +fn prove_distribution_digest_is_deterministic() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_a, &3_000u32); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_b, &2_000u32); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + holders.push_back(holder_b.clone()); + + let (_, digest1) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + let (_, digest2) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + assert_eq!(digest1, digest2); +} + +// ── Ordering matters: swapped holders produce different digest ──────────────── + +#[test] +fn prove_distribution_ordering_affects_digest() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_a, &3_000u32); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_b, &2_000u32); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + + let mut holders_ab = Vec::new(&env); + holders_ab.push_back(holder_a.clone()); + holders_ab.push_back(holder_b.clone()); + + let mut holders_ba = Vec::new(&env); + holders_ba.push_back(holder_b.clone()); + holders_ba.push_back(holder_a.clone()); + + let (_, digest_ab) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders_ab, + ); + let (_, digest_ba) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders_ba, + ); + + assert_ne!(digest_ab, digest_ba); +} + +// ── Empty holders ───────────────────────────────────────────────────────────── + +#[test] +fn prove_distribution_empty_holders() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + + let holders: Vec
= Vec::new(&env); + let (entries, digest) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + assert_eq!(entries.len(), 0); + // Digest is still a valid 32-byte value (SHA-256 of the empty-entries payload) + assert_eq!(digest.len(), 32); +} + +// ── Unknown period_id ───────────────────────────────────────────────────────── + +#[test] +fn prove_distribution_unknown_period_id_returns_zero_payouts() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + let holder_a = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder_a, &3_000u32); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + + // period 999 was never deposited + let (entries, _digest) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &999u64, + &holders, + ); + + assert_eq!(entries.len(), 1); + let e = entries.get(0).unwrap(); + assert_eq!(e.share_bps, 3_000u32); + assert_eq!(e.normalized_payout, 0i128); +} + +// ── share_bps == 0 ──────────────────────────────────────────────────────────── + +#[test] +fn prove_distribution_zero_share_bps_yields_zero_payout() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + + let no_share_holder = Address::generate(&env); + // no set_holder_share call → defaults to 0 + + let mut holders = Vec::new(&env); + holders.push_back(no_share_holder.clone()); + + let (entries, _digest) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + assert_eq!(entries.len(), 1); + let e = entries.get(0).unwrap(); + assert_eq!(e.share_bps, 0u32); + assert_eq!(e.normalized_payout, 0i128); +} + +// ── Decimals != 7 (USDC = 6 decimals) ──────────────────────────────────────── + +#[test] +fn prove_distribution_usdc_6_decimals_normalizes_correctly() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + // Configure 6-decimal payment token (USDC-style) + client.set_payment_token_decimals(&issuer, &symbol_short!("def"), &token, &6u32); + + let holder = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000u32); + + // Deposit 1_000_000 raw units (= 1.000000 USDC at 6 decimals) + mint(&env, &payment_token, &issuer, 1_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &1_000_000i128, + &1u64, + ); + + let mut holders = Vec::new(&env); + holders.push_back(holder.clone()); + + let (entries, _digest) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + assert_eq!(entries.len(), 1); + let e = entries.get(0).unwrap(); + assert_eq!(e.share_bps, 5_000u32); + // normalize_amount(1_000_000, 6) = 1_000_000 * 10 = 10_000_000 + // compute_share(10_000_000, 5000, Truncation) = 5_000_000 + assert_eq!(e.normalized_payout, 5_000_000i128); +} + +// ── RoundHalfUp mode is respected ──────────────────────────────────────────── + +#[test] +fn prove_distribution_respects_round_half_up_mode() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + client.set_rounding_mode(&issuer, &symbol_short!("def"), &token, &RoundingMode::RoundHalfUp); + + let holder = Address::generate(&env); + // amount=3, bps=5000 → 1.5 → truncation=1, half-up=2 + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000u32); + + mint(&env, &payment_token, &issuer, 3); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payment_token, &3i128, &1u64); + + let mut holders = Vec::new(&env); + holders.push_back(holder.clone()); + + let (entries, _) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + let e = entries.get(0).unwrap(); + // RoundHalfUp: 3 * 5000 / 10000 = 1.5 → rounds to 2 + assert_eq!(e.normalized_payout, 2i128); +} + +// ── Holders cap at MAX_CHUNK_PERIODS (200) ──────────────────────────────────── + +#[test] +fn prove_distribution_caps_at_max_chunk_periods() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + mint(&env, &payment_token, &issuer, 1_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &1_000_000i128, + &1u64, + ); + + // Build 201 holders (one over the cap) + let mut holders = Vec::new(&env); + for _ in 0..201 { + holders.push_back(Address::generate(&env)); + } + + let (entries, _) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + // Should be capped at 200 + assert_eq!(entries.len(), 200); +} + +// ── DistributionEntry fields are correct ───────────────────────────────────── + +#[test] +fn prove_distribution_entry_fields_match() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + let holder = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &10_000u32); + + mint(&env, &payment_token, &issuer, 1_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &1_000i128, + &1u64, + ); + + let mut holders = Vec::new(&env); + holders.push_back(holder.clone()); + + let (entries, _) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + + let e = entries.get(0).unwrap(); + assert_eq!(e.holder, holder); + assert_eq!(e.share_bps, 10_000u32); + // 1000 * 10000 / 10000 = 1000 + assert_eq!(e.normalized_payout, 1_000i128); +} + +// ── Different period_ids produce different digests ──────────────────────────── + +#[test] +fn prove_distribution_different_periods_produce_different_digests() { + let (env, client) = make_client(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = create_payment_token(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &token, + &1_000u32, + &payment_token, + &0i128, + ); + + let holder = Address::generate(&env); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000u32); + + mint(&env, &payment_token, &issuer, 20_000_000); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &1u64, + ); + client.deposit_revenue( + &issuer, + &symbol_short!("def"), + &token, + &payment_token, + &10_000_000i128, + &2u64, + ); + + let mut holders = Vec::new(&env); + holders.push_back(holder.clone()); + + let (_, digest1) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &1u64, + &holders, + ); + let (_, digest2) = client.prove_distribution_for_period( + &issuer, + &symbol_short!("def"), + &token, + &2u64, + &holders, + ); + + // period_id is included in the XDR payload, so different period_ids → different digests + // even when revenue amounts are identical + assert_ne!(digest1, digest2); +}