fix(terraclassic): bound list pagination to the contract page cap (#139) #163

Merged
PlasticDigits merged 1 commit from fix/gl-139-migrate-gas-evidence into main 2026-09-02 01:38:47 +00:00
PlasticDigits commented 2026-09-01 07:35:04 +00:00 (Migrated from gitlab.com)

Summary

  • Fix a live pagination trap: Terra list queries cap at 30 rows (WITHDRAW_LIST_MAX_LIMIT). The frontend hash monitor (and canceler default) requested 50 and treated len < 50 as EOF, so only the first 30 history rows were listed.
  • Add additive next_start_after on pending_withdrawals (all-status row semantics unchanged). Operator, canceler, and hash monitor clamp to 30 and follow the cursor.
  • Record LCD-sized migrate batch evidence from columbus-5 (2026-09-01): 106 canonical rows (95 executed, 11 approved-not-executed) reconstruct in 3 batches of 50 or 2 of 100. Live same-code_id wasm migrate is allowed on wasmd v0.61.8 / CosmWasm 1.5.

Related #139 (kept open: first on-chain v2.1 gas_used, post-deploy operator/canceler soak, browser transfer-status against mainnet SPA)

Invariants / docs

Test plan

  • test_active_index_scale: 106-row production mix (batch 50 → 3, batch 100 → 2); 2000 terminal + 11 active still one active page; JSON size bound; limit=50 still pages via cursor
  • test_withdraw_flow pagination asserts next_start_after on full/last pages (31 tests)
  • Operator terra_list soak: 106-row mix is 4 legacy pages / 1 active page; oversized request without cursor stops early
  • Canceler terra_withdraw_list: clamp 30; 50-without-cursor misses later approvals; cursor repairs it
  • Frontend hashMonitor.test.ts: never queries active_withdrawals; continues past a 30-row cap; clamps requested 50
  • Pre-commit: fmt, clippy (operator/canceler/terra), frontend tsc + eslint, gitleaks
  • On-chain gas_used of first v2.1 wasm migrate (contract still v2.0 code_id 10971)
  • Operator/canceler soak against deployed v2.1 with terminal history
  • Manual/browser transfer-status against the mainnet SPA (unit + LCD evidence in this MR)

Remaining issue notes (do not close #139)

  • Measured on-chain migrate gas on production-sized PENDING_WITHDRAWS (batch 50 / cap 100)
  • Operator/canceler soak against a contract with substantial terminal history
  • Frontend historical listing: hash monitor stays on pending_withdrawals / pending_withdraw; page size 30 + next_start_after (INV-FE-TC-AW1). Unit-tested; live SPA click-through still pending deploy
  • Live columbus-5 same-code_id wasm migrate: wasmd v0.61.8 does not reject same code_id; CosmWasm 1.5 ContractMigrateVersion is nil so migrate is invoked. Repeat wasm migrate until active_index_complete=true. Keep admin ContinueActiveIndexMigrate as a fallback

Not in this MR

  • Closing #139 until live migrate gas_used and a post-deploy soak are recorded
  • Operator RPC livelock / stale-unapproved retry — #138
  • Switching the frontend hash monitor to active_withdrawals (would hide completed transfers)
## Summary - Fix a live pagination trap: Terra list queries cap at **30** rows (`WITHDRAW_LIST_MAX_LIMIT`). The frontend hash monitor (and canceler default) requested **50** and treated `len < 50` as EOF, so only the first 30 history rows were listed. - Add additive `next_start_after` on `pending_withdrawals` (all-status row semantics unchanged). Operator, canceler, and hash monitor clamp to 30 and follow the cursor. - Record LCD-sized migrate batch evidence from columbus-5 (2026-09-01): 106 canonical rows (95 executed, 11 approved-not-executed) reconstruct in 3 batches of 50 or 2 of 100. Live same-`code_id` wasm migrate is allowed on wasmd v0.61.8 / CosmWasm 1.5. Related #139 (kept open: first on-chain v2.1 `gas_used`, post-deploy operator/canceler soak, browser transfer-status against mainnet SPA) ## Invariants / docs - [docs/TERRACLASSIC_BRIDGE_INVARIANTS.md](docs/TERRACLASSIC_BRIDGE_INVARIANTS.md) — **INV-TC-AW1–AW5** (page cap, cursors, same-`code_id` continue, LCD batch evidence) - [skills/agent-terraclassic-active-withdrawals.md](skills/agent-terraclassic-active-withdrawals.md) - [docs/FRONTEND_BRIDGE_INVARIANTS.md](docs/FRONTEND_BRIDGE_INVARIANTS.md) **INV-FE-TC-AW1** - [docs/deployment-terraclassic-upgrade.md](docs/deployment-terraclassic-upgrade.md), [docs/contracts-terraclassic.md](docs/contracts-terraclassic.md) ## Test plan - [x] `test_active_index_scale`: 106-row production mix (batch 50 → 3, batch 100 → 2); 2000 terminal + 11 active still one active page; JSON size bound; `limit=50` still pages via cursor - [x] `test_withdraw_flow` pagination asserts `next_start_after` on full/last pages (31 tests) - [x] Operator `terra_list` soak: 106-row mix is 4 legacy pages / 1 active page; oversized request without cursor stops early - [x] Canceler `terra_withdraw_list`: clamp 30; 50-without-cursor misses later approvals; cursor repairs it - [x] Frontend `hashMonitor.test.ts`: never queries `active_withdrawals`; continues past a 30-row cap; clamps requested 50 - [x] Pre-commit: fmt, clippy (operator/canceler/terra), frontend tsc + eslint, gitleaks - [ ] On-chain `gas_used` of first v2.1 `wasm migrate` (contract still v2.0 code_id 10971) - [ ] Operator/canceler soak against **deployed** v2.1 with terminal history - [ ] Manual/browser transfer-status against the mainnet SPA (unit + LCD evidence in this MR) ## Remaining issue notes (do not close #139) - [ ] Measured on-chain migrate gas on production-sized `PENDING_WITHDRAWS` (batch 50 / cap 100) - [ ] Operator/canceler soak against a contract with substantial terminal history - [x] Frontend historical listing: hash monitor stays on `pending_withdrawals` / `pending_withdraw`; page size 30 + `next_start_after` (INV-FE-TC-AW1). Unit-tested; live SPA click-through still pending deploy - [x] Live columbus-5 same-`code_id` wasm migrate: wasmd **v0.61.8** does **not** reject same `code_id`; CosmWasm 1.5 `ContractMigrateVersion` is nil so `migrate` **is** invoked. Repeat wasm migrate until `active_index_complete=true`. Keep admin `ContinueActiveIndexMigrate` as a fallback ## Not in this MR - Closing #139 until live migrate `gas_used` and a post-deploy soak are recorded - Operator RPC livelock / stale-unapproved retry — #138 - Switching the frontend hash monitor to `active_withdrawals` (would hide completed transfers)
PlasticDigits commented 2026-09-01 07:35:21 +00:00 (Migrated from gitlab.com)

mentioned in issue #139

mentioned in issue #139
PlasticDigits commented 2026-09-01 08:00:22 +00:00 (Migrated from gitlab.com)

RECOMMEND: ACCEPT

Safe pagination-cap follow-up to !158 for #139. Additive next_start_after on pending_withdrawals (all-status row semantics unchanged); operator / canceler / hash monitor clamp to the contract cap of 30 and follow the cursor. No replay, double-payout, privilege escalation, fail-open migrate, or prompt-injection issues. Do not close #139.

What this fixes

Terra list queries cap at 30 (WITHDRAW_LIST_MAX_LIMIT). The frontend hash monitor and canceler default requested 50 and treated len < 50 as EOF, so only the first 30 history rows were listed. On the 106-row columbus-5 mix (95 executed + 11 approved-not-executed) that meant the canceler processed one page and never saw the 11 cancel candidates. Operator was already requesting 30.

Issue #139 remaining after !158

Item Status
50/EOF pagination trap MET (code + unit/soak tests)
Additive next_start_after MET
Frontend stays on pending_withdrawals (INV-FE-TC-AW1) MET in code + unit tests; live SPA pending deploy
Same-code_id wasm migrate on columbus-5 MET (docs: wasmd v0.61.8 / CosmWasm 1.5 invokes migrate; keep ContinueActiveIndexMigrate)
LCD-sized migrate batch counts MET (3×50 or 2×100 for 106 rows)
On-chain v2.1 gas_used NOT MET (still code_id 10971 / v2.0)
Post-deploy operator/canceler soak NOT MET (in-memory soak only)

!158 lifecycle / replay / RBAC / fail-closed incomplete index is intact. No silent semantic break of pending_withdrawals.

Security (no BLOCK)

Vector Result
Replay / double payout Pass — canonical map + nonce unused by this diff
Unauthorized execute / migrate Pass — ContinueActiveIndexMigrate still contract-admin only; rebuilds index, does not delete canonical rows
Same-code_id migrate surprise Fail closed — incomplete index errors active_withdrawals; clients fall back to pending_withdrawals
Crafted cursors Read-only skip; exclusive storage bound; approve still verifies EVM deposit
Query-limit abuse Pass — limit.min(30); peek is limit+1
Old contract vs new client Fail closed — missing field + clamped 30 still pages; old SPA requesting 50 still stops at 30 until this frontend ships
Unapproved spam griefing Out of scope (#138)
Oracle manipulation N/A
Log / PII leaks No new per-entry debug
Tokenomic / economic N/A — no payout math changes

Deploy / ops

Ship frontend + canceler with the wasm. An old SPA that still requests 50 and treats len < 50 as EOF will keep listing only the first 30 Terra history rows even after the contract cursor exists.

Keep #139 open until the first live v2.1 wasm migrate gas_used and a post-deploy soak are recorded.

Nice-to-have (not merge blockers)

  • Deduplicate advance_withdraw_list_page into multichain-rs (copied in operator and canceler).
  • Treat JSON null next_start_after as EOF in hashMonitor.ts (Rust already does; frontend falls through to len < 30 and may make one extra empty LCD call).
  • Clamp contract query limit to 1..=30 (Some(0) is a footgun).
  • Operator max pages / LCD livelock bound remains #138.
RECOMMEND: ACCEPT Safe pagination-cap follow-up to !158 for #139. Additive `next_start_after` on `pending_withdrawals` (all-status row semantics unchanged); operator / canceler / hash monitor clamp to the contract cap of 30 and follow the cursor. No replay, double-payout, privilege escalation, fail-open migrate, or prompt-injection issues. **Do not close #139.** ## What this fixes Terra list queries cap at 30 (`WITHDRAW_LIST_MAX_LIMIT`). The frontend hash monitor and canceler default requested **50** and treated `len < 50` as EOF, so only the first 30 history rows were listed. On the 106-row columbus-5 mix (95 executed + 11 approved-not-executed) that meant the canceler processed one page and **never saw the 11 cancel candidates**. Operator was already requesting 30. ## Issue #139 remaining after !158 | Item | Status | |------|--------| | 50/EOF pagination trap | MET (code + unit/soak tests) | | Additive `next_start_after` | MET | | Frontend stays on `pending_withdrawals` (INV-FE-TC-AW1) | MET in code + unit tests; live SPA pending deploy | | Same-`code_id` wasm migrate on columbus-5 | MET (docs: wasmd v0.61.8 / CosmWasm 1.5 invokes `migrate`; keep `ContinueActiveIndexMigrate`) | | LCD-sized migrate batch counts | MET (3×50 or 2×100 for 106 rows) | | On-chain v2.1 `gas_used` | NOT MET (still code_id 10971 / v2.0) | | Post-deploy operator/canceler soak | NOT MET (in-memory soak only) | !158 lifecycle / replay / RBAC / fail-closed incomplete index is intact. No silent semantic break of `pending_withdrawals`. ## Security (no BLOCK) | Vector | Result | |--------|--------| | Replay / double payout | Pass — canonical map + nonce unused by this diff | | Unauthorized execute / migrate | Pass — `ContinueActiveIndexMigrate` still contract-admin only; rebuilds index, does not delete canonical rows | | Same-`code_id` migrate surprise | Fail closed — incomplete index errors `active_withdrawals`; clients fall back to `pending_withdrawals` | | Crafted cursors | Read-only skip; exclusive storage bound; approve still verifies EVM deposit | | Query-limit abuse | Pass — `limit.min(30)`; peek is `limit+1` | | Old contract vs new client | Fail closed — missing field + clamped 30 still pages; **old SPA requesting 50 still stops at 30 until this frontend ships** | | Unapproved spam griefing | Out of scope (#138) | | Oracle manipulation | N/A | | Log / PII leaks | No new per-entry debug | | Tokenomic / economic | N/A — no payout math changes | ## Deploy / ops Ship **frontend + canceler** with the wasm. An old SPA that still requests 50 and treats `len < 50` as EOF will keep listing only the first 30 Terra history rows even after the contract cursor exists. Keep #139 open until the first live v2.1 `wasm migrate` `gas_used` and a post-deploy soak are recorded. ## Nice-to-have (not merge blockers) - Deduplicate `advance_withdraw_list_page` into `multichain-rs` (copied in operator and canceler). - Treat JSON `null` `next_start_after` as EOF in `hashMonitor.ts` (Rust already does; frontend falls through to `len < 30` and may make one extra empty LCD call). - Clamp contract query `limit` to `1..=30` (`Some(0)` is a footgun). - Operator max pages / LCD livelock bound remains #138.
PlasticDigits (Migrated from gitlab.com) merged commit 5294018bf3 into main 2026-09-02 01:38:47 +00:00
PlasticDigits commented 2026-09-02 01:38:49 +00:00 (Migrated from gitlab.com)

mentioned in commit 5294018bf3

mentioned in commit 5294018bf3daf3ed5b2649ea5666f34575a3cd3c
Sign in to join this conversation.
No description provided.