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_count performs one persistent get for every ID up to KEY_PKG_COUNTER. KEY_PKG_COUNTER is incremented by create_package/batch_create_packages and 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.md already documents non-linear storage cost on the write path; the read path has no cap at all.
- Pagination returns wrong pages.
list_recipient_packages advances 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. limit is a u32 accepted 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 on create_package/batch_create_packages and on the recipient-identity of Package records. Capping limit alone 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_packages budget concerns in GAS_PROFILING_REPORT.md. Naive Vec<u64> appends re-serialize the whole vector per write.
- Backward compatibility.
get_recipient_package_count/list_recipient_packages are 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
Tests
Documentation
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 -- --nocapture
Good 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.
Problem
The only recipient-oriented reads on
aid_escroware linear scans over the whole package-ID space, and their pagination is wrong for sparse recipients.Consequences, distinctly:
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.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.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
(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.batch_create_packagesbudget concerns inGAS_PROFILING_REPORT.md. NaiveVec<u64>appends re-serialize the whole vector per write.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.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. Replacelist_recipient_packageswith a cursor over that index returning(ids, next_cursor), and deriveget_recipient_package_countfrom the index size or a stored counter. A table of the target surface:get_recipient_package_count(recipient)list_recipient_packages(recipient, cursor, limit)next_cursor,limitcappedAcceptance 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
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.Good first files to read: the storage-key conventions at the top of
src/lib.rs, andtests/integration.rsfor how packages are created and queried today.