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