Repository navigation
get_recipient_package_count scans every package ID: unbounded gas and wrong pagination for sparse recipients #424
Description
Problem
The only recipient-oriented reads on aid_escrow are linear scans over the whole package-ID space, and their pagination is wrong for sparse recipients.
// app/onchain/contracts/aid_escrow/src/lib.rs
pub fn get_recipient_package_count(env: Env, recipient: Address) -> u64 {
let count: u64 = env.storage().instance().get(&KEY_PKG_COUNTER).unwrap_or(0);
let mut matches = 0;
for id in 0..count { // ← one persistent get per ID
if let Some(package) = env.storage().persistent().get::<_, Package>(&key) {
if package.recipient == recipient { matches += 1; }
}
}
matches
}
pub fn list_recipient_packages(env: Env, recipient: Address, cursor: u64, limit: u32) -> Vec<u64> {
// iterates cursor..end_pos over the *ID space*, filtering by recipient
}Consequences, distinctly:
- Unbounded read cost.
get_recipient_package_countperforms one persistentgetfor every ID up toKEY_PKG_COUNTER.KEY_PKG_COUNTERis incremented bycreate_package/batch_create_packagesand never shrinks, so as a campaign grows the read's CPU/storage budget grows linearly and will eventually exceed Soroban's per-call budget.GAS_PROFILING_REPORT.mdalready documents non-linear storage cost on the write path; the read path has no cap at all. - Pagination returns wrong pages.
list_recipient_packagesadvances the cursor over the ID space, not over matches. If a recipient owns packages 2 and 200,list_recipient_packages(r, 0, 10)returns only package 2; the caller must know to page through 200 IDs to find the second match, and pages can be empty even when matches remain. There is no recipient-keyed index to enumerate only that recipient's IDs. - No upper bound on
limit.limitis au32accepted verbatim, so a caller can request a scan window that is guaranteed to blow the read budget.
Root cause
Packages are keyed by (Symbol "pkg", u64) with no secondary index keyed by recipient, so any recipient query falls back to a full scan; the pagination cursor was written against the ID space instead of the result set.
Why this is architecturally hard
- This is a storage-index design, not a loop tweak. A real fix needs a recipient→IDs secondary index (e.g.
(Symbol "rpidx", recipient, seq)), maintained oncreate_package/batch_create_packagesand on the recipient-identity ofPackagerecords. Cappinglimitalone leaves the O(counter) scan and the broken cursor semantics. - Index maintenance must be reorg-safe and cheap. The index must be written atomically with package creation inside the same transaction, and its growth must be bounded by the same
batch_create_packagesbudget concerns inGAS_PROFILING_REPORT.md. NaiveVec<u64>appends re-serialize the whole vector per write. - Backward compatibility.
get_recipient_package_count/list_recipient_packagesare public view functions; existing callers (indexers, the backend adapter) must either keep working or be migrated, and the deployed testnet contract (CDSBJ27PKTNFTRW6OKPCVXDRUSSRUIQUG6DW5PUTKLDXTDT23NQIS6JG) requires a redeploy/migration decision. - Deletion is impossible on-chain. Packages are never removed, so a recipient index can only grow; the design must specify a pagination protocol (cursor over index sequence numbers, plus a returned
next_cursor) rather than assume a bounded scan.
Proposed design
Maintain a secondary index Map<(Address, u64), u64> (or a nested map) from (recipient, seq) → package_id, appended on creation. Replace list_recipient_packages with a cursor over that index returning (ids, next_cursor), and derive get_recipient_package_count from the index size or a stored counter. A table of the target surface:
| Entrypoint | Behavior |
|---|---|
get_recipient_package_count(recipient) |
O(1) or index-size read, never a scan |
list_recipient_packages(recipient, cursor, limit) |
returns contiguous matches + next_cursor, limit capped |
Acceptance criteria
Contract
-
get_recipient_package_countdoes not loop over0..KEY_PKG_COUNTER; its cost is independent of the global counter. -
list_recipient_packagesreturns every matching package across cursor pages (no skipped matches) and yields a continuation cursor. -
limitis clamped to a documented maximum and largelimitvalues cannot exhaust the read budget.
Tests
- A test creates packages for two recipients with a large ID gap and asserts both recipients are fully enumerated across pages.
- A test asserts package creation maintains the index, including via
batch_create_packages.
Documentation
-
app/onchain/README.mddocuments the new pagination contract (cursor type, return shape) for indexers.
Out of scope
Expiry auto-transition accounting and Merkle-allowlist leaf encoding are separate issues.
Getting started
Files: app/onchain/contracts/aid_escrow/src/lib.rs (create_package, batch_create_packages, get_recipient_package_count, list_recipient_packages), app/onchain/contracts/aid_escrow/GAS_PROFILING_REPORT.md.
cd app/onchain
make test # cargo test -- --nocaptureGood first files to read: the storage-key conventions at the top of src/lib.rs, and tests/integration.rs for how packages are created and queried today.
Activity
Xhristin3 commentedon Aug 19, 2026
Please assign me this. I have read and understood the scope of the problem,
edochieblessing09-max commentedon Aug 19, 2026
I will love to work on this issue, will produce an excellent result
grantfox-oss commentedon Aug 20, 2026
🦊 GrantFox — @Xhristin3 has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #424)Good luck! Track your progress on GrantFox.
grantfox-oss commentedon Aug 20, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@Xhristin3's PR #445 was approved and merged by @kilodesodiq-arch.
🏆 @Xhristin3: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (532 total points). Track your full progress on GrantFox.
👏 Great work, @Xhristin3! Keep contributing to ChainForgee.