Skip to content
Open
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
98 changes: 98 additions & 0 deletions docs/pending-periods-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Pending Periods Pagination

## Scope

This document covers the `get_pending_periods_page` read-only helper in
[`src/lib.rs`](/home/chinonso-peter/Revora-Contracts/src/lib.rs) and the
corresponding test coverage in
[`src/test.rs`](/home/chinonso-peter/Revora-Contracts/src/test.rs).

The feature is intentionally scoped to contracts code only. It does not change
off-chain indexers, frontend behavior, or deployment flow.

## API Summary

`get_pending_periods_page(env, issuer, namespace, token, holder, start, limit) -> (Vec<u64>, Option<u32>)`

Behavior:

- Returns pending period ids in deposit order.
- Uses `start` as a storage-index cursor, not as a `period_id`.
- Returns `Some(next_cursor)` only when more pending entries remain.
- Treats `limit = 0` as "use the default page size".
- Caps `limit` to `MAX_PAGE_LIMIT` to keep read cost predictable.
- Returns an empty page and `None` when `start` is already at or beyond the end.

## Security Assumptions

The hardened implementation makes the following assumptions explicit:

- Pending-period enumeration is treated as entitlement-scoped data, not public discovery data.
- A holder with `share_bps == 0` should not learn which deposited periods exist through
pagination-only queries.
- Claim progress is represented by `LastClaimedIdx`, so pagination must never return
periods before the holder's current claim cursor even if the caller supplies a stale `start`.
- Deposited periods are stored by append-only index (`PeriodEntry(offering_id, index)`),
so the returned order is deterministic and stable across calls.

## Abuse and Failure Paths

### Zero-share probing

Risk:
A caller with no configured share could repeatedly page through results to infer offering
activity.

Mitigation:
Both `get_pending_periods` and `get_pending_periods_page` now return empty results for
zero-share holders.

### Oversized page requests

Risk:
Large `limit` values could encourage unexpectedly expensive read-only loops.

Mitigation:
The function normalizes page size with the same `MAX_PAGE_LIMIT` cap used by other
pagination endpoints.

### Stale or malicious cursors

Risk:
A caller can pass `start = 0` after partially claiming to try to reread already-claimed
entries.

Mitigation:
The effective cursor is `max(start, LastClaimedIdx)`, so the contract never pages before
the holder's claim boundary.

### Boundary arithmetic

Risk:
`start + limit` can overflow in edge cases.

Mitigation:
Cursor end calculations use `saturating_add` before clamping to the stored count.

## Deterministic Test Coverage

The test suite covers:

- first-page retrieval and cursor emission
- multi-page iteration to exhaustion
- stale cursor handling after partial claims
- `limit = 0` default-cap behavior
- oversized limit capping
- end-of-list empty-page behavior
- zero-share holders receiving empty results

Key tests live in:

- [`src/test.rs`](/home/chinonso-peter/Revora-Contracts/src/test.rs)
- [`src/chunking_tests.rs`](/home/chinonso-peter/Revora-Contracts/src/chunking_tests.rs)

## Reviewer Notes

- This change does not alter the write path for deposits or claims.
- The pagination cursor remains index-based for deterministic continuation.
- Returning empty results for zero-share holders is an intentional privacy hardening choice.
69 changes: 61 additions & 8 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,16 @@ impl AmountValidationMatrix {
}
}

/// Normalize page size across read-only pagination endpoints.
/// `0` means "use the default page size" and oversized requests are capped.
fn normalized_page_limit(limit: u32) -> u32 {
if limit == 0 || limit > MAX_PAGE_LIMIT {
MAX_PAGE_LIMIT
} else {
limit
}
}

// ── Contract ─────────────────────────────────────────────────
#[contract]
pub struct RevoraRevenueShare;
Expand Down Expand Up @@ -972,7 +982,6 @@ impl RevoraRevenueShare {
.ok_or(RevoraError::OfferingNotFound)?;
Ok(offering.payout_asset)
}

/// Internal helper for revenue deposits.
/// Validates amount using the Negative Amount Validation Matrix (#163).
fn do_deposit_revenue(
Expand All @@ -994,6 +1003,9 @@ impl RevoraRevenueShare {
);
return Err(err);
}
// The token transfer below spends funds from `issuer`, so explicit issuer auth must
// be present before we call into the token contract.
issuer.require_auth();

let offering_id = OfferingId {
issuer: issuer.clone(),
Expand Down Expand Up @@ -1939,14 +1951,13 @@ impl RevoraRevenueShare {
let count = Self::get_offering_count(env.clone(), issuer.clone(), namespace.clone());
let tenant_id = TenantId { issuer, namespace };

let effective_limit =
if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit };
let effective_limit = normalized_page_limit(limit);

if start >= count {
return (Vec::new(&env), None);
}

let end = core::cmp::min(start + effective_limit, count);
let end = core::cmp::min(start.saturating_add(effective_limit), count);
let mut results = Vec::new(&env);

for i in start..end {
Expand Down Expand Up @@ -3740,13 +3751,28 @@ impl RevoraRevenueShare {

/// Return unclaimed period IDs for a holder on an offering.
/// Ordering: by deposit index (creation order), deterministic (#38).
///
/// Security assumption: this read path only exposes period ids to holders with a non-zero
/// configured share. Zero-share holders receive an empty list to avoid leaking offering
/// activity through unactionable read-only queries.
pub fn get_pending_periods(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
holder: Address,
) -> Vec<u64> {
let share_bps = Self::get_holder_share(
env.clone(),
issuer.clone(),
namespace.clone(),
token.clone(),
holder.clone(),
);
if share_bps == 0 {
return Vec::new(&env);
}

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);
Expand All @@ -3770,6 +3796,9 @@ impl RevoraRevenueShare {
/// 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
/// capped to `MAX_PAGE_LIMIT` to keep calls predictable.
///
/// Security assumption: zero-share holders receive an empty page with no cursor so callers
/// cannot infer deposit activity without current entitlement.
pub fn get_pending_periods_page(
env: Env,
issuer: Address,
Expand All @@ -3779,6 +3808,17 @@ impl RevoraRevenueShare {
start: u32,
limit: u32,
) -> (Vec<u64>, Option<u32>) {
let share_bps = Self::get_holder_share(
env.clone(),
issuer.clone(),
namespace.clone(),
token.clone(),
holder.clone(),
);
if share_bps == 0 {
return (Vec::new(&env), None);
}

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);
Expand All @@ -3792,9 +3832,8 @@ impl RevoraRevenueShare {
return (Vec::new(&env), None);
}

let effective_limit =
if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit };
let end = core::cmp::min(actual_start + effective_limit, period_count);
let effective_limit = normalized_page_limit(limit);
let end = core::cmp::min(actual_start.saturating_add(effective_limit), period_count);

let mut results = Vec::new(&env);
for i in actual_start..end {
Expand Down Expand Up @@ -5143,4 +5182,18 @@ impl RevenueDepositContract {
});
fixtures
}
}
}
pub mod vesting;

#[cfg(test)]
mod vesting_test;

#[cfg(test)]
mod test_utils;

#[cfg(test)]
mod chunking_tests;
mod test;
mod test_auth;
mod test_cross_contract;
mod test_namespaces;
Loading
Loading