Skip to content

Commit 7609aa3

Browse files
authored
feat(aid_escrow): index packages by recipient for O(1) queries (#445)
Maintain a per-recipient secondary index (rcnt counter + rpidx (recipient, seq) -> package_id entries) written atomically on every package-creation path, and back get_recipient_package_count and list_recipient_packages with it instead of a linear scan of the global package-ID space. get_recipient_package_count is now O(1), list_recipient_packages pages over the recipient's own index (contiguous matches, continuation cursor, no skipped matches for sparse IDs) and clamps limit to a documented MAX_RECIPIENT_PAGE_SIZE. The index uses instance storage: persistent writes metered ~3x higher in Soroban SDK 23 and pushed the 200-package batch past the budget, while instance storage keeps it within limits (see GAS_PROFILING_REPORT.md). Co-authored-by: Xhr!st!n3 <208627422+Xhristin3@users.noreply.github.com>
1 parent 83db75e commit 7609aa3

12 files changed

Lines changed: 26987 additions & 41 deletions

app/onchain/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ Events use stable topic identifiers (struct name in snake_case) so indexers and
9191
| `get_package(id)` | Returns full package details | None |
9292
| `view_package_status(id)` | Returns only the status of a package | None |
9393
| `get_aggregates(token)` | Returns total committed/claimed/expired stats | None |
94+
| `get_recipient_package_count(recipient)` | Returns the number of packages for a recipient (O(1), via the recipient index) | None |
95+
| `list_recipient_packages(recipient, cursor, limit)` | Returns `{ ids, next_cursor }` — a page of recipient package IDs plus the cursor for the next page | None |
96+
97+
#### Recipient pagination contract
98+
99+
`list_recipient_packages` pages over the recipient's secondary index, so pages contain only that recipient's packages — no skipped matches, no empty pages while matches remain — regardless of how sparse the global package-ID space is.
100+
101+
- `cursor` is the per-recipient index ordinal from the previous page's `next_cursor` (`0` for the first page).
102+
- `limit` is clamped to `MAX_RECIPIENT_PAGE_SIZE = 100`, so a single read call can never request an unbounded scan window.
103+
- `next_cursor` is the ordinal to pass as the next `cursor`; when it equals the recipient's total count, there are no further pages.
94104

95105
---
96106

app/onchain/contracts/aid_escrow/GAS_PROFILING_REPORT.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,30 @@ Based on Soroban's current resource limits (approximately 100M CPU instructions
121121

122122
**Recommendation:** Use standard claims when possible. Merkle proofs only when necessary for access control.
123123

124+
## Update: Recipient Secondary Index (issue #424)
125+
126+
`get_recipient_package_count` / `list_recipient_packages` are now backed by a
127+
per-recipient secondary index (`rcnt` counter + `rpidx` entries) instead of a
128+
linear scan of the package-ID space. The index adds one small storage write per
129+
created package. **Measured on the current code (2026-08):**
130+
131+
| Batch Size | Per-Package CPU | Per-Package Memory |
132+
|------------|-----------------|--------------------|
133+
| 10 | 82,152 | 14,599 |
134+
| 25 | 112,285 | 20,110 |
135+
| 50 | 164,236 | 30,187 |
136+
| 100 | 266,928 | 50,676 |
137+
| 200 | 469,938 | 91,820 |
138+
139+
Design note: the index entries live in **instance storage** (`(rpidx, recipient,
140+
seq) -> package_id`). The initial implementation used persistent storage, which
141+
metered ~2-3x higher in Soroban SDK 23 and pushed the 200-package batch past the
142+
100M-instruction / 40MB test budget; instance storage keeps the 200-batch within
143+
budget (94M instructions / 18.4MB) and matches how `KEY_TOTAL_LOCKED` and the
144+
other counters are already stored. The safe-batch guidance below is unchanged:
145+
25-50 packages remains the recommended ceiling, and 200-package batches remain
146+
"Not Recommended" (now at ~94% of the CPU budget).
147+
124148
## Optimization Recommendations
125149

126150
### 1. Implement Pagination for Large Distributions

app/onchain/contracts/aid_escrow/src/lib.rs

Lines changed: 111 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const KEY_VERSION: Symbol = symbol_short!("version");
3636
const KEY_PKG_COUNTER: Symbol = symbol_short!("pkg_cnt");
3737
const KEY_CONFIG: Symbol = symbol_short!("config");
3838
const KEY_PKG_IDX: Symbol = symbol_short!("pkg_idx"); // Aggregation index counter
39+
const KEY_RECIPIENT_COUNT: Symbol = symbol_short!("rcnt"); // Map<Address, u64> — packages per recipient
40+
41+
// Secondary index: instance (KEY_RECIPIENT_IDX, recipient, seq) -> u64 package_id.
42+
// seq is a per-recipient ordinal assigned at creation, so recipient queries never
43+
// scan the global ID space (see issue #424). Instance storage is used because
44+
// persistent writes meter far higher in this SDK (see GAS_PROFILING_REPORT.md).
45+
const KEY_RECIPIENT_IDX: Symbol = symbol_short!("rpidx");
3946
const KEY_DISTRIBUTORS: Symbol = symbol_short!("dstrbtrs"); // Map<Address, bool>
4047
const KEY_PAUSED: Symbol = symbol_short!("paused");
4148
const KEY_PAUSE_CREATE: Symbol = symbol_short!("p_create");
@@ -59,6 +66,11 @@ const DEFAULT_ADMIN_DEADLINE: u64 = 7 * 24 * 60 * 60; // 7 days in seconds
5966
/// this with `Error::InvalidTokenDecimals`. See issue #235.
6067
pub const MAX_TOKEN_DECIMALS: u32 = 38;
6168

69+
/// Upper bound for `list_recipient_packages` page sizes. The `limit` argument
70+
/// is clamped to this value so a single read call can never request a scan
71+
/// window large enough to exhaust Soroban's per-call read budget (issue #424).
72+
pub const MAX_RECIPIENT_PAGE_SIZE: u32 = 100;
73+
6274
/// Initial value of `Config.min_decimals` written by `init()` when no
6375
/// admin has called `set_config()` yet. 0 disables the floor check, so
6476
/// tokens with 0 decimals (e.g. NFTs, indivisible units) are accepted by
@@ -114,6 +126,18 @@ pub struct Aggregates {
114126
pub total_expired_cancelled: i128,
115127
}
116128

129+
/// One page of `list_recipient_packages` results.
130+
///
131+
/// `next_cursor` is the per-recipient index ordinal to pass as `cursor` on the
132+
/// next call; when it equals the recipient's total package count there are no
133+
/// further pages.
134+
#[contracttype]
135+
#[derive(Clone, Debug, PartialEq)]
136+
pub struct RecipientPackagesPage {
137+
pub ids: Vec<u64>,
138+
pub next_cursor: u64,
139+
}
140+
117141
#[contracterror]
118142
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
119143
pub enum Error {
@@ -766,6 +790,9 @@ impl AidEscrow {
766790

767791
env.storage().persistent().set(&key, &package);
768792

793+
// Maintain the recipient → package-id secondary index (issue #424).
794+
Self::index_recipient_package(&env, &recipient, id);
795+
769796
// Increment running committed total for the token
770797
Self::add_to_status_totals(&env, &token, PackageStatus::Created, amount);
771798

@@ -843,6 +870,14 @@ impl AidEscrow {
843870
let mut counter: u64 = env.storage().instance().get(&KEY_PKG_COUNTER).unwrap_or(0);
844871
// Read the current aggregation index
845872
let mut idx: u64 = env.storage().instance().get(&KEY_PKG_IDX).unwrap_or(0);
873+
// Recipient index bookkeeping is hoisted into memory and persisted once
874+
// after the loop: re-serializing the per-recipient count map on every
875+
// iteration would be O(n^2) and blow the read budget on large batches.
876+
let mut recipient_count_map: Map<Address, u64> = env
877+
.storage()
878+
.instance()
879+
.get(&KEY_RECIPIENT_COUNT)
880+
.unwrap_or(Map::new(&env));
846881

847882
let created_at = env.ledger().timestamp();
848883
let expires_at = created_at + expires_in;
@@ -895,6 +930,14 @@ impl AidEscrow {
895930

896931
env.storage().persistent().set(&key, &package);
897932

933+
// Maintain the recipient → package-id secondary index (issue #424).
934+
// Only the small per-recipient entry is written per package; the
935+
// count map is updated in memory and persisted after the loop.
936+
let seq = recipient_count_map.get(recipient.clone()).unwrap_or(0);
937+
let rpidx_key = (KEY_RECIPIENT_IDX, recipient.clone(), seq);
938+
env.storage().instance().set(&rpidx_key, &id);
939+
recipient_count_map.set(recipient.clone(), seq + 1);
940+
898941
// Track running committed total
899942
Self::add_to_status_totals(&env, &token, PackageStatus::Created, amount);
900943

@@ -924,6 +967,9 @@ impl AidEscrow {
924967
env.storage().instance().set(&KEY_TOTAL_LOCKED, &locked_map);
925968
env.storage().instance().set(&KEY_PKG_COUNTER, &counter);
926969
env.storage().instance().set(&KEY_PKG_IDX, &idx);
970+
env.storage()
971+
.instance()
972+
.set(&KEY_RECIPIENT_COUNT, &recipient_count_map);
927973

928974
// Emit batch event
929975
BatchCreatedEvent {
@@ -1956,61 +2002,85 @@ impl AidEscrow {
19562002

19572003
/// Returns the number of stored packages assigned to `recipient`.
19582004
///
1959-
/// This naive helper scans all package IDs from `0..package_counter`, treating the
1960-
/// counter as an upper bound over assigned IDs and skipping gaps.
2005+
/// O(1): reads the per-recipient counter maintained by
2006+
/// [`index_recipient_package`], so the cost is independent of the global
2007+
/// package counter and of how many packages other recipients own.
19612008
pub fn get_recipient_package_count(env: Env, recipient: Address) -> u64 {
1962-
let count: u64 = env.storage().instance().get(&KEY_PKG_COUNTER).unwrap_or(0);
1963-
let mut matches = 0;
1964-
1965-
for id in 0..count {
1966-
let key = (symbol_short!("pkg"), id);
1967-
if let Some(package) = env.storage().persistent().get::<_, Package>(&key) {
1968-
if package.recipient == recipient {
1969-
matches += 1;
1970-
}
1971-
}
1972-
}
1973-
1974-
matches
2009+
let count_map: Map<Address, u64> = env
2010+
.storage()
2011+
.instance()
2012+
.get(&KEY_RECIPIENT_COUNT)
2013+
.unwrap_or(Map::new(&env));
2014+
count_map.get(recipient).unwrap_or(0)
19752015
}
19762016

19772017
/// Lists package IDs for a specific recipient with pagination.
19782018
///
2019+
/// Enumerates the recipient's secondary index, so pages contain only that
2020+
/// recipient's packages (no skipped matches and no empty pages while
2021+
/// matches remain), regardless of how large the global ID space is.
2022+
///
19792023
/// # Arguments
19802024
/// * `recipient` - The address to filter packages by
1981-
/// * `cursor` - Starting position for pagination (0-indexed)
1982-
/// * `limit` - Maximum number of results to return
2025+
/// * `cursor` - Per-recipient index ordinal of the first package to return
2026+
/// (the `next_cursor` from the previous page, or `0` for the first page)
2027+
/// * `limit` - Maximum number of results to return; clamped to
2028+
/// [`MAX_RECIPIENT_PAGE_SIZE`]
19832029
///
19842030
/// # Returns
1985-
/// A Vec<u64> containing package IDs that belong to the recipient,
1986-
/// starting from the cursor position and limited by the limit parameter.
2031+
/// A [`RecipientPackagesPage`] containing up to `limit` package IDs and a
2032+
/// `next_cursor` for the following page. When `next_cursor` equals the
2033+
/// recipient's total package count, no further pages exist.
19872034
pub fn list_recipient_packages(
19882035
env: Env,
19892036
recipient: Address,
19902037
cursor: u64,
19912038
limit: u32,
1992-
) -> Vec<u64> {
1993-
let package_counter: u64 = env.storage().instance().get(&KEY_PKG_COUNTER).unwrap_or(0);
1994-
let mut result: Vec<u64> = Vec::new(&env);
1995-
1996-
// Calculate the end position: cursor + limit or package_counter, whichever comes first
1997-
let end_pos = if cursor.saturating_add(limit as u64) > package_counter {
1998-
package_counter
2039+
) -> RecipientPackagesPage {
2040+
let count = Self::get_recipient_package_count(env.clone(), recipient.clone());
2041+
let limit = u64::from(limit.min(MAX_RECIPIENT_PAGE_SIZE));
2042+
let mut ids: Vec<u64> = Vec::new(&env);
2043+
2044+
let next_cursor = if cursor >= count {
2045+
// Exhausted: nothing to return, and report the end so the caller
2046+
// can stop paginating.
2047+
count
19992048
} else {
2000-
cursor.saturating_add(limit as u64)
2001-
};
2002-
2003-
// Iterate from cursor to end_pos
2004-
for id in cursor..end_pos {
2005-
let key = (symbol_short!("pkg"), id);
2006-
if let Some(package) = env.storage().persistent().get::<_, Package>(&key) {
2007-
if package.recipient == recipient {
2008-
result.push_back(id);
2049+
let end = cursor.saturating_add(limit).min(count);
2050+
for seq in cursor..end {
2051+
let idx_key = (KEY_RECIPIENT_IDX, recipient.clone(), seq);
2052+
if let Some(id) = env.storage().instance().get::<_, u64>(&idx_key) {
2053+
ids.push_back(id);
20092054
}
20102055
}
2011-
}
2056+
end
2057+
};
20122058

2013-
result
2059+
RecipientPackagesPage { ids, next_cursor }
2060+
}
2061+
2062+
/// Appends `package_id` to `recipient`'s secondary index.
2063+
///
2064+
/// Writes one instance-storage entry `(KEY_RECIPIENT_IDX, recipient, seq)`
2065+
/// and bumps the per-recipient counter. Called from every package-creation
2066+
/// path so the index is always consistent with `(pkg, id)` records; a
2067+
/// failed creation reverts the whole transaction, so no orphan entries can
2068+
/// be observed.
2069+
fn index_recipient_package(env: &Env, recipient: &Address, package_id: u64) {
2070+
let mut count_map: Map<Address, u64> = env
2071+
.storage()
2072+
.instance()
2073+
.get(&KEY_RECIPIENT_COUNT)
2074+
.unwrap_or(Map::new(env));
2075+
let seq = count_map.get(recipient.clone()).unwrap_or(0);
2076+
2077+
let idx_key = (KEY_RECIPIENT_IDX, recipient.clone(), seq);
2078+
env.storage().instance().set(&idx_key, &package_id);
2079+
2080+
count_map.set(recipient.clone(), seq + 1);
2081+
env.storage()
2082+
.instance()
2083+
.set(&KEY_RECIPIENT_COUNT, &count_map);
20142084
}
20152085
}
20162086

@@ -2116,7 +2186,8 @@ mod tests {
21162186
);
21172187

21182188
let packages = client.list_recipient_packages(&recipient1, &0, &10);
2119-
assert_eq!(packages.len(), 2);
2189+
assert_eq!(packages.ids.len(), 2);
2190+
assert_eq!(packages.next_cursor, 2);
21202191
}
21212192

21222193
#[test]
@@ -2146,7 +2217,8 @@ mod tests {
21462217
}
21472218

21482219
let page = client.list_recipient_packages(&recipient, &0, &3);
2149-
assert_eq!(page.len(), 3);
2220+
assert_eq!(page.ids.len(), 3);
2221+
assert_eq!(page.next_cursor, 3);
21502222
}
21512223

21522224
#[test]

0 commit comments

Comments
 (0)