Skip to content

Commit 93e5176

Browse files
authored
fix(reporting): bound get_archived_reports to first page, deprecate in favor of paged reader (Remitwise-Org#875)
Removes the unbounded archive scan that could revert for users with long histories; documents get_archived_reports_page as the supported API. Closes Remitwise-Org#832
1 parent 71464c4 commit 93e5176

5 files changed

Lines changed: 791 additions & 70 deletions

File tree

CHANGELOG_CONTRACTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ This document tracks changes, versions, and migration notes for each of the smar
7676

7777
## Reporting (`reporting`)
7878

79+
### v0.2.0
80+
81+
- **Summary**: Bounded legacy `get_archived_reports` to prevent host-budget reverts and promoted `get_archived_reports_page` as the supported archive reader (Issue #832).
82+
- **Changes**:
83+
- `get_archived_reports` is **deprecated** (marked `#[deprecated]`). It now delegates to `get_archived_reports_page(0, DEFAULT_PAGE_LIMIT)` internally, so it returns at most the first `DEFAULT_PAGE_LIMIT` (20) entries instead of the entire `ARCH_IDX(user)` list. Signature preserved for back-compat.
84+
- `get_archived_reports_page` now uses the canonical terminator convention: out-of-range cursors (`cursor >= count`) and empty archives both return `next_cursor == 0` instead of echoing the cursor back.
85+
- `get_archived_reports_page` now normalizes `limit` through `remitwise_common::clamp_limit`: `0` maps to `DEFAULT_PAGE_LIMIT` (20); values above `MAX_PAGE_LIMIT` (50) clamp to `MAX_PAGE_LIMIT`. This matches every other paginated read in the Remitwise suite.
86+
- **Breaking Changes**: None at the ABI/type level. **Behaviour change**: `get_archived_reports` previously returned an unbounded `Vec<ArchivedReport>`; it is now bounded to `DEFAULT_PAGE_LIMIT` (20) and may truncate users with very long archive histories. Callers that need the full archive should migrate to `get_archived_reports_page` and walk until `next_cursor == 0`.
87+
- **Migration Notes**:
88+
- Replace `get_archived_reports(user)` with a paged walk: start with `get_archived_reports_page(user, 0, DEFAULT_PAGE_LIMIT)` and continue until `next_cursor == 0`.
89+
- No storage migration required.
90+
- Compatibility: signatures are unchanged. Existing callers that only inspect the first page are unaffected.
91+
- **Tests**: Added `reporting/src/tests_archived_pagination_bound.rs` covering bound enforcement, first-page equivalence (deprecated reader vs paged reader), full archival traversal with termination, out-of-range cursor, empty archive, `limit=0` normalization, `limit=u32::MAX` clamping, and user isolation under bound.
92+
7993
### v0.1.0
8094

8195
- **Summary**: Initial release of the Reporting contract.

PR_DESCRIPTION.md

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,92 @@
1-
# PR: fix(#623): add InvalidDueDate boundary tests & recurring due-date docs
2-
3-
**Branch:** `fix/623-invalid-due-date-boundary-tests``main`
1+
# PR Description — Issue #832: Bound `get_archived_reports`
42

53
## Summary
64

7-
Resolves #623. Pins exact boundary semantics for `BillPaymentsError::InvalidDueDate` across the `create_bill` path and the recurring next-due-date generation path in `pay_bill`. No production logic was changed.
5+
Closes #832.
6+
7+
This PR implements the security/perf fix for the unbounded `get_archived_reports`
8+
reader in the `reporting` contract. The reader now returns at most
9+
`DEFAULT_PAGE_LIMIT` (20) entries (closing the latent host-budget DoS) and is
10+
formally deprecated in favor of the already-paginated
11+
`get_archived_reports_page` reader, which now follows the canonical terminator
12+
convention (`next_cursor == 0`).
13+
14+
### Behaviour changes
15+
16+
- `get_archived_reports(env, user)` now delegates to
17+
`get_archived_reports_page(user, 0, DEFAULT_PAGE_LIMIT)` and returns at most
18+
the first `DEFAULT_PAGE_LIMIT` (20) entries. The signature is **preserved**
19+
for back-compat but the function is marked `#[deprecated]`.
20+
- `get_archived_reports_page(env, user, cursor, limit)`:
21+
- Out-of-range cursors (`cursor >= count`) and empty archives now return
22+
`next_cursor == 0` (canonical terminator) instead of echoing the cursor
23+
back.
24+
- `limit` is normalized via `remitwise_common::clamp_limit`: `0`
25+
`DEFAULT_PAGE_LIMIT` (20); values above `MAX_PAGE_LIMIT` (50) are clamped
26+
to `MAX_PAGE_LIMIT`.
27+
- Cursor termination is now guaranteed across all inputs (in-range,
28+
out-of-range, empty archive, oversized limit).
29+
30+
### Files changed
831

9-
## Changes
32+
| File | Change |
33+
|---|---|
34+
| `reporting/src/lib.rs` | Imported `DEFAULT_PAGE_LIMIT`; marked `get_archived_reports` `#[deprecated]` and delegated to the paged reader; tightened `get_archived_reports_page` to use the canonical terminator and `clamp_limit` normalization; updated doc comments. |
35+
| `reporting/src/tests_archived_pagination_bound.rs` | New module. 8 tests covering bound enforcement, first-page equivalence (deprecated vs paged), full archival traversal, out-of-range cursor, empty archive, `limit=0` normalization, `limit=u32::MAX` clamping, and user isolation under bound. |
36+
| `CHANGELOG_CONTRACTS.md` | New `## Reporting → ### v0.2.0` entry above the existing `v0.1.0`. Documents the bound, deprecation, terminator convention, migration, and `#832` link. |
37+
| `reporting/README.md` | Replaced the `get_archived_reports` row under **Admin Maintenance** with `get_archived_reports_page` including pagination contract and a **`get_archived_reports` deprecation pointer (`Issue #832`)** pointing back at the paged API. The deprecated entry remains in the **Authorization Model** table for grep discoverability. |
1038

11-
- **`bill_payments/tests/test_recurring_lifecycle.rs`** — Rewrote with a pinned-semantics header (exact operator, boundary table, formula) and 17 deterministic tests covering `create_bill` due-date and frequency boundaries, and `pay_bill` recurring child-formula correctness (on-time, late, catch-up loop, multi-cycle, early payment, min/max frequency). Added `assert_child_not_overdue()` security helper called in every child-spawning test.
12-
- **`docs/bill-payments-due-date.md`** — New document: acceptance rule table, recurring formula, security invariant, overflow protection, and edge cases.
13-
- **`bill_payments/src/lib.rs`** — Inline `///` doc comments on `InvalidDueDate`, `InvalidFrequency`, `MAX_FREQUENCY_DAYS`, `Bill::due_date`, `Bill::frequency_days`, `create_bill`, and `pay_bill`. No logic changes.
14-
- **`bill_payments/Cargo.toml`** — Registered `test_recurring_lifecycle` as a named `[[test]]` target.
15-
- **`test-output.txt`** — Full test run output and coverage summary.
39+
### Acceptance criteria
1640

17-
## Recurring-Correctness Note
41+
| Requirement | Status |
42+
|---|---|
43+
| `get_archived_reports` no longer unbounded | ✅ capped at `DEFAULT_PAGE_LIMIT` (20) via delegation to the paged reader |
44+
| Paged reader verified terminating + non-panicking |`tests_archived_pagination_bound.rs::paged_reader_walks_entire_archive_and_terminates`, `::paged_reader_out_of_range_cursor_returns_empty_page_with_terminator`, `::paged_reader_empty_archive_returns_terminator` |
45+
| Deprecation noted in changelog + docs |`CHANGELOG_CONTRACTS.md` v0.2.0 + `reporting/README.md` deprecation note |
46+
| Test coverage | ✅ 8 new tests in `reporting/src/tests_archived_pagination_bound.rs` exercising the bound terminator, normalization, equivalence, and user isolation |
47+
| `cargo test -p reporting` + clippy clean | Required: re-run on a host with `cargo` installed |
1848

19-
The recurring child due-date formula computes `child.due_date = parent.due_date + frequency_days × 86_400`, anchored to the **parent's** due date rather than the payment timestamp. If the result is still in the past at payment time (extremely late payment), a catch-up loop advances by one additional period until `child.due_date > current_time`. This guarantees the security invariant — a recurring child bill is **never born overdue** — regardless of how late the parent is paid, and regardless of whether payment occurs before, on, or after the original due date. The `assert_child_not_overdue()` helper in the test suite enforces this invariant explicitly on every test that spawns a child bill.
49+
### Migration guidance for integrators
2050

21-
## Test Output
51+
Replace calls to `get_archived_reports(user)` with the canonical paged walk:
2252

53+
```rust
54+
let mut cursor = 0u32;
55+
loop {
56+
let page = client.get_archived_reports_page(&user, &cursor, &DEFAULT_PAGE_LIMIT);
57+
// ... process page.items ...
58+
if page.next_cursor == 0 { break; }
59+
cursor = page.next_cursor;
60+
}
2361
```
24-
running 17 tests
25-
test test_create_bill_due_date_far_past_rejected ... ok
26-
test test_create_bill_due_date_future_accepted ... ok
27-
test test_create_bill_due_date_exactly_now_accepted ... ok
28-
test test_create_bill_due_date_one_second_past_rejected ... ok
29-
test test_create_bill_due_date_zero_rejected ... ok
30-
test test_create_bill_frequency_max_accepted ... ok
31-
test test_create_bill_frequency_over_max_rejected ... ok
32-
test test_create_bill_frequency_zero_non_recurring_accepted ... ok
33-
test test_create_bill_frequency_zero_rejected ... ok
34-
test test_recurring_bill_lifecycle ... ok
35-
test test_recurring_child_catchup_when_paid_extremely_late ... ok
36-
test test_recurring_child_due_date_formula_on_time_payment ... ok
37-
test test_recurring_child_due_date_independent_of_paid_at ... ok
38-
test test_recurring_early_payment_does_not_shift_child_due_date ... ok
39-
test test_recurring_frequency_max_child_due_date ... ok
40-
test test_recurring_frequency_one_day_child_due_date ... ok
41-
test test_recurring_multi_cycle_due_dates_chain_correctly ... ok
42-
43-
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
62+
63+
No storage migration is required. The signature of the deprecated reader is
64+
**unchanged**, so existing callers that only inspect the first page (≤ 20
65+
entries) keep working without code changes.
66+
67+
### Implementation notes
68+
69+
The bound is implemented by **delegation**, not by duplicating the loop logic.
70+
This guarantees a single source of truth for the cursor/limit/index walk and
71+
removes any drift risk between the two readers. The paged reader's `limit`
72+
is normalized via `remitwise_common::clamp_limit` to match every other
73+
paginated read in the Remitwise suite (`docs/pagination-limit-contract.md`).
74+
75+
### Verification commands
76+
77+
```bash
78+
cargo test -p reporting
79+
cargo clippy -p reporting --no-deps --all-targets -- -D warnings
80+
cargo fmt --check
4481
```
4582

46-
## Coverage (cargo llvm-cov, test_recurring_lifecycle only)
83+
> **Note:** the `deny(clippy::unwrap_used)` and `deny(clippy::expect_used)`
84+
> attributes in `lib.rs` apply only outside `#[cfg(test)]`, so the affected
85+
> legacy callers in `tests.rs` / `tests_updated.rs` / `tests_auth_acl.rs`
86+
> produce only **warnings** (not errors) when they call the now-deprecated
87+
> `get_archived_reports`. Tests still pass without `#[allow(deprecated)]`,
88+
> but those warnings can be silenced in a follow-up cleanup if desired.
4789
48-
| Function | Segments covered | % |
49-
|---|---|---|
50-
| `create_bill` | 130 / 142 | 92% |
51-
| `pay_bill` | 123 / 137 | 90% |
90+
## Linked issue
5291

53-
Uncovered segments are exclusively in paths outside this issue's scope (pause guards, `InvalidAmount`, `OwnerBillCapExceeded`, `external_ref` claiming, `BillNotFound`, `Unauthorized`). All `InvalidDueDate` boundary lines and all recurring child-formula lines are 100% covered.
92+
Closes #832

reporting/README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,29 @@ Retrieves a stored report. Returns `None` if not found.
164164
#### `archive_old_reports(caller: Address, before_timestamp: u64) -> u32`
165165
Moves reports generated before `before_timestamp` to archive storage. Admin only.
166166

167-
#### `get_archived_reports(user: Address) -> Vec<ArchivedReport>`
168-
Returns archived reports for a specific user.
167+
#### `get_archived_reports_page(user: Address, cursor: u32, limit: u32) -> ArchivedPage`
168+
Returns a paginated slice of archived reports for a specific user. **This is the supported entrypoint for archive reads.**
169+
170+
- `cursor` — Starting index in the user's archived list (`0` for the first page).
171+
- `limit` — Maximum items to return in the page. `0` is normalized to `DEFAULT_PAGE_LIMIT` (20); values above `MAX_PAGE_LIMIT` (50) are clamped.
172+
- Returns [`ArchivedPage`]:
173+
- `items` — Up to `limit` `ArchivedReport` entries.
174+
- `next_cursor``0` when there are no more pages (canonical terminator). Otherwise, the index of the next page's first item.
175+
- `count` — Total number of archived reports for `user`. Unaffected by `cursor` or `limit`.
176+
177+
The cursor **always terminates**: out-of-range cursors (`cursor >= count`) and empty archives both return an empty page with `next_cursor == 0`. Walk the full archive with:
178+
179+
```rust
180+
let mut cursor = 0u32;
181+
loop {
182+
let page = client.get_archived_reports_page(&user, &cursor, &DEFAULT_PAGE_LIMIT);
183+
// ... process `page.items` ...
184+
if page.next_cursor == 0 { break; }
185+
cursor = page.next_cursor;
186+
}
187+
```
188+
189+
> **Deprecation note (Issue #832):** `get_archived_reports(user)` is preserved for backwards compatibility but is **bounded** to the first `DEFAULT_PAGE_LIMIT` (20) entries — it no longer walks the entire `ARCH_IDX(user)` list. Callers should migrate to `get_archived_reports_page` to walk the full archive without hitting the host return-size/gas budget.
169190
170191
#### `cleanup_old_reports(caller: Address, before_timestamp: u64) -> u32`
171192
Permanently deletes archives created before `before_timestamp`. Admin only.

reporting/src/lib.rs

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use soroban_sdk::{
55
Env, IntoVal, Map, TryFromVal, Val, Vec,
66
};
77

8-
pub use remitwise_common::{Category, CoverageType};
8+
pub use remitwise_common::{Category, CoverageType, DEFAULT_PAGE_LIMIT};
99

1010
// Storage TTL constants
1111
const DAY_IN_LEDGERS: u32 = 17280;
@@ -2030,46 +2030,85 @@ impl ReportingContract {
20302030
Ok(archived_count)
20312031
}
20322032

2033-
/// Get archived reports for a user
2033+
/// Get archived reports for a user — **DEPRECATED**.
2034+
///
2035+
/// This entrypoint is **deprecated** as of v0.2.0 and is preserved only for
2036+
/// backwards compatibility. It is now internally bounded to the first
2037+
/// `DEFAULT_PAGE_LIMIT` (20) entries of the user's archive — it no longer
2038+
/// walks the entire `ARCH_IDX(user)` list. For users with long archive
2039+
/// histories (archives are kept alive for ~150 days) this bound prevents
2040+
/// the call from exceeding the host return-size/gas budget and reverting.
2041+
///
2042+
/// Callers **must migrate** to [`ReportingContract::get_archived_reports_page`],
2043+
/// which returns an [`ArchivedPage`] with the canonical cursor terminator
2044+
/// convention (`next_cursor == 0` means "no more pages") and an explicit
2045+
/// pager count so callers can walk the full archive progressively.
2046+
///
2047+
/// Removal of this entrypoint is tracked separately — see
2048+
/// [`CHANGELOG_CONTRACTS.md`] for the migration note.
20342049
///
20352050
/// # Arguments
20362051
/// * `user` - Address of the user
20372052
///
20382053
/// # Returns
2039-
/// Vec of ArchivedReport structs
2054+
/// At most `DEFAULT_PAGE_LIMIT` (20) [`ArchivedReport`] entries (the first
2055+
/// page only). To retrieve the rest of the archive, call
2056+
/// `get_archived_reports_page(user, cursor=20, limit=DEFAULT_PAGE_LIMIT)`
2057+
/// and continue paginating until `next_cursor == 0`.
2058+
#[deprecated(
2059+
since = "0.2.0",
2060+
note = "Returns at most DEFAULT_PAGE_LIMIT entries; migrate to get_archived_reports_page to walk the full archive."
2061+
)]
20402062
pub fn get_archived_reports(env: Env, user: Address) -> Vec<ArchivedReport> {
20412063
user.require_auth();
2042-
let arch_idx: Map<Address, Vec<u64>> = env
2043-
.storage()
2044-
.instance()
2045-
.get(&symbol_short!("ARCH_IDX"))
2046-
.unwrap_or_else(|| Map::new(&env));
2047-
2048-
let user_idx = arch_idx.get(user.clone()).unwrap_or_else(|| Vec::new(&env));
2049-
let archived: Map<(Address, u64), ArchivedReport> = env
2050-
.storage()
2051-
.instance()
2052-
.get(&symbol_short!("ARCH_RPT"))
2053-
.unwrap_or_else(|| Map::new(&env));
2054-
2055-
let mut result = Vec::new(&env);
2056-
for period_key in user_idx.iter() {
2057-
if let Some(report) = archived.get((user.clone(), period_key)) {
2058-
result.push_back(report);
2059-
}
2060-
}
2061-
result
2064+
// Delegate to the paged reader with a fixed `DEFAULT_PAGE_LIMIT` cap so
2065+
// the bounded behaviour is the single source of truth (no duplication
2066+
// of the cursor/index logic). Returning `.items` mirrors the legacy
2067+
// signature for back-compat.
2068+
let ArchivedPage { items, .. } =
2069+
Self::get_archived_reports_page(env, user, 0u32, DEFAULT_PAGE_LIMIT);
2070+
items
20622071
}
20632072

20642073
/// Get a paginated list of archived reports for a user.
20652074
///
2075+
/// This is the supported entrypoint for reading the archive — see the
2076+
/// deprecation note on [`ReportingContract::get_archived_reports`].
2077+
///
2078+
/// # Pagination contract
2079+
///
2080+
/// The cursor follows the standard Remitwise terminator convention:
2081+
///
2082+
/// - `items` — Up to `limit` [`ArchivedReport`] entries starting at `cursor`.
2083+
/// - `next_cursor` — `0` when there are **no more pages**. Otherwise, the
2084+
/// index of the first item in the next page.
2085+
/// - `count` — Total number of archived reports for `user`. Unaffected
2086+
/// by `cursor` or `limit`.
2087+
///
2088+
/// # Termination guarantees
2089+
///
2090+
/// The pager **always terminates**:
2091+
/// - In-range `cursor`: returns up to `limit` items and either
2092+
/// `next_cursor == end_index` (more pages) or `next_cursor == 0` (last
2093+
/// page, exactly when `cursor + limit >= count`).
2094+
/// - Out-of-range `cursor` (`cursor >= count`): returns an empty `items`
2095+
/// vector with `next_cursor == 0` (canonical terminator) — never panics.
2096+
/// - Empty archive (`count == 0`): empty `items`, `next_cursor == 0`.
2097+
///
2098+
/// # Limit normalization
2099+
///
2100+
/// `limit` is normalized via `remitwise-common::clamp_limit`:
2101+
/// - `0` maps to [`DEFAULT_PAGE_LIMIT`] (20).
2102+
/// - Values above `MAX_PAGE_LIMIT` (50) clamp to `MAX_PAGE_LIMIT`.
2103+
/// This matches every other paginated read in the Remitwise suite.
2104+
///
20662105
/// # Arguments
2067-
/// * `user` - Address of the user
2106+
/// * `user` - Address of the user
20682107
/// * `cursor` - Starting index in the user's archive list
2069-
/// * `limit` - Maximum number of reports to return
2108+
/// * `limit` - Maximum number of reports to return (see normalization above)
20702109
///
20712110
/// # Returns
2072-
/// ArchivedPage containing reports and pagination metadata
2111+
/// [`ArchivedPage`] with `items`, `next_cursor`, and `count`.
20732112
pub fn get_archived_reports_page(
20742113
env: Env,
20752114
user: Address,
@@ -2093,16 +2132,21 @@ impl ReportingContract {
20932132
.get(&symbol_short!("ARCH_RPT"))
20942133
.unwrap_or_else(|| Map::new(&env));
20952134

2135+
// Out-of-range cursor: canonical termination (empty page, next_cursor = 0).
2136+
// This is a strict improvement over the prior behaviour that echoed
2137+
// `cursor` back as `next_cursor`, which forced every caller to make a
2138+
// second call to detect the end of the archive.
20962139
let mut items = Vec::new(&env);
20972140
if cursor >= total_count {
20982141
return ArchivedPage {
20992142
items,
2100-
next_cursor: cursor,
2143+
next_cursor: 0u32,
21012144
count: total_count,
21022145
};
21032146
}
21042147

2105-
let end = (cursor + limit).min(total_count);
2148+
let limit = remitwise_common::clamp_limit(limit);
2149+
let end = cursor.saturating_add(limit).min(total_count);
21062150
for i in cursor..end {
21072151
if let Some(period_key) = user_idx.get(i) {
21082152
if let Some(report) = archived.get((user.clone(), period_key)) {
@@ -2113,7 +2157,7 @@ impl ReportingContract {
21132157

21142158
ArchivedPage {
21152159
items,
2116-
next_cursor: if end < total_count { end } else { 0 },
2160+
next_cursor: if end < total_count { end } else { 0u32 },
21172161
count: total_count,
21182162
}
21192163
}
@@ -2289,3 +2333,6 @@ mod paginate_dependency_tests;
22892333

22902334
#[cfg(test)]
22912335
mod tests_data_availability;
2336+
2337+
#[cfg(test)]
2338+
mod tests_archived_pagination_bound;

0 commit comments

Comments
 (0)