Skip to content

get_recipient_package_count scans every package ID: unbounded gas and wrong pagination for sparse recipients #424

Description

@kilodesodiq-arch

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

  1. 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.
  2. 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.
  3. 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.
  4. 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_count does not loop over 0..KEY_PKG_COUNTER; its cost is independent of the global counter.
  • list_recipient_packages returns every matching package across cursor pages (no skipped matches) and yields a continuation cursor.
  • limit is clamped to a documented maximum and large limit values 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.md documents 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 -- --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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third Campaignarea:onchainOn-chain (Soroban) areabugSomething isn't workinghighHigh severity issues

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions