Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>` | `(Vec<DistributionEntry>, 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. |
Expand Down Expand Up @@ -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<DistributionEntry>, 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`.
Expand Down
182 changes: 129 additions & 53 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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<DistributionEntry>` 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<Address>,
) -> (Vec<DistributionEntry>, 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<DistributionEntry> = 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<u64> {
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 ─────────────
Expand Down Expand Up @@ -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<u64> {
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
Expand Down
4 changes: 2 additions & 2 deletions src/test_claim_transfer_fail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading