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 e18da537..3dfa1ae5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,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 @@ -167,34 +167,11 @@ pub mod vesting; #[cfg(test)] mod test_claim_transfer_fail; #[cfg(test)] -mod test_compute_share_decomposition_prop; -#[cfg(test)] mod test_duplicates; #[cfg(test)] mod test_min_revenue_threshold_boundary; #[cfg(test)] -mod test_claim_transfer_fail; -#[cfg(test)] -mod test_pause_tiers; -#[cfg(test)] -mod test_snapshot_monotonicity_replay; - -/// Two-tier pause state stored at `DataKey::Paused`. -/// -/// - `NotPaused` – normal operation; all entrypoints are open. -/// - `SoftPaused` – blocks reports and deposits but **allows** `claim`, so -/// holders can still withdraw their funds during incident response. -/// - `HardPaused` – blocks every state-mutating operation including `claim`. -/// -/// Wire values are stable: do not renumber. -#[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum PauseState { - NotPaused = 0, - SoftPaused = 1, - HardPaused = 2, -} +mod test_prove_distribution; // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); @@ -434,6 +411,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)] @@ -5163,6 +5159,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 ───────────── @@ -5465,34 +5569,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 diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index fdc4a334..042f9c97 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -463,8 +463,8 @@ 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_v2(admin_b.clone()); - token::StellarAssetClient::new(&env, &payment_token_b.address()).mint(&issuer, &100_000); + 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( &issuer, 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); +}