fix(aid_escrow): sweep past-due packages to Expired and release funds - #444
Merged
kilodesodiq-arch merged 1 commit intoAug 20, 2026
Conversation
…d funds Add a permissionless, idempotent expire_if_past_due(id) entrypoint that transitions a past-due Created package to Expired, decrements its locked total, and moves its aggregate totals from committed to the expired/cancelled bucket. A late claim cannot commit this transition itself because Soroban reverts storage writes on an error return, so claim/claim_with_proof keep returning Error::PackageExpired while the sweep reclaims pool capacity and fixes get_aggregates drift. Also fix refund() to only decrement locked totals for Created packages, so refunding a package already swept by expire_if_past_due (or cancelled by revoke) does not double-release funds, and align BOUNDARY_VALIDATION_BEHAVIOR.md, README state diagrams, and the boundary tests with the sweep-based design.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #423
Adds a permissionless, idempotent
expire_if_past_due(id)entrypoint that transitions a past-dueCreatedpackage toExpired, releases its locked funds, and moves its aggregate totals fromtotal_committedtototal_expired_cancelled. The single most important design decision: a lateclaim/claim_with_proofcannot atomically mark the packageExpiredand returnError::PackageExpiredin the same invocation, because Soroban reverts all storage writes when a function returns an error. The sweep entrypoint is therefore the only mechanism that can persist the transition, so it is deliberately decoupled from the claim error path.Why
Before this change, a late claim returned
Error::PackageExpiredand left the packageCreatedforever. Its amount stayed inKEY_TOTAL_LOCKEDand in theCreatedaggregate until an admin manually calledrefund(id):create_packagerejects new packages whencontract_balance < current_locked + amount; expired-but-unswept packages keep their funds counted as locked, invisibly shrinking the available pool and forcing manual admin sweeps to reclaim capacity.get_aggregates(token)overstatestotal_committedand understatestotal_expired_cancelledfor every past-due package, so dashboards and indexers report unclaimable funds as committed.BOUNDARY_VALIDATION_BEHAVIOR.mdsaid both "status remainsCreated" and "late claim attempts automatically expire the package status on-chain".The obvious shortcut — writing the
Expiredstatus inside the claim guard — is not merely wrong but impossible: Soroban rolls back the status write when the function returnsErr, which would silently double-apply accounting on the next successful sweep. The chosen approach keeps the claim paths pure (error only) and moves the state transition to a successful, permissionless call that any indexer, cron, or relayer can invoke.What was built
app/onchain/contracts/aid_escrow/src/lib.rs:expire_if_past_due(env, id)expires_at == 0), not-yet-due, or already-terminal packages; otherwise setsExpired,decrement_locked, and movesCreated → expired/cancelledvia the existingadd_to_status_totalshelper. ReturnsError::PackageNotFoundonly for a missing id.refund()should_unlock_lockedguardCreated || ExpiredtoCreated. A package swept byexpire_if_past_due(or cancelled byrevoke) has already released its locked funds; unlocking again would double-decrementKEY_TOTAL_LOCKED.app/onchain/contracts/aid_escrow/tests/boundary_validation_tests.rs— thelate_claim_behaviormodule was reworked from 3 to 5 tests, each with matching snapshots intest_snapshots/late_claim_behavior/:late_claim_returns_error_without_transitioningclaimreturnsPackageExpired, status staysCreated,get_total_lockedunchanged.claim_with_proof_fails_after_expiry_without_transitioningclaim_with_proof.expire_if_past_due_transitions_and_moves_accountingExpired, zeroestotal_locked, movestotal_committed → total_expired_cancelled; a second sweep is idempotent (no double-move).expire_if_past_due_ignores_never_expiring_and_missing_packagesexpires_at == 0untouched; missing id →PackageNotFound.refund_after_expiry_does_not_unlock_other_packagesRefundedwithout decrementing the other package's locked share.app/onchain/contracts/aid_escrow/BOUNDARY_VALIDATION_BEHAVIOR.md— rewritten so "Late Claim Behavior", "Test Coverage", and "Conclusion" describe one consistent sweep-based behavior; all contradictory auto-expiry statements removed.Integration changes outside the module
app/onchain/contracts/aid_escrow/README.md— added theexpire_if_past_duerow to the function table and corrected the lifecycle diagram line to name the sweep as theCreated → Expiredtrigger.app/onchain/README.md— added theexpire_if_past_duerow to the method reference table.No storage schema changed: the new function writes only existing keys (
pkg,KEY_TOTAL_LOCKED,KEY_TOTAL_COMMITTED,KEY_TOTAL_EXPIRED_CANCELLED) using existing helpers, so no migration is needed and persisted state is unaffected.Acceptance criteria coverage
Contract
claim/claim_with_proofon aCreatedpackage transitions it toExpiredand returnsError::PackageExpired. — Delivered via the permissionlessexpire_if_past_due(id)sweep, the only mechanism Soroban permits: storage writes revert when a function returns an error, so the transition cannot be committed atomically with the error return. The claim paths still returnError::PackageExpired; the transition now happens deterministically on the first sweep call (boundary_validation_tests.rs —expire_if_past_due_transitions_and_moves_accounting,late_claim_returns_error_without_transitioning).get_total_locked(token)no longer includes that package's amount andget_aggregates(token)shows the amount intotal_expired_cancelled, nottotal_committed. (expire_if_past_due_transitions_and_moves_accountingasserts locked → 0 and committed → expired/cancelled deltas.)refund(id)on an auto-expired package still transfers funds to the admin and ends inRefundedwithout double-decrementing locked totals. (refund_after_expiry_does_not_unlock_other_packages;refundguard narrowed toCreatedonly.)Tests
app/onchain/contracts/aid_escrow/tests/assert the locked/aggregate deltas after a late claim, and that a second late claim does not double-move the amount. (Idempotency asserted inexpire_if_past_due_transitions_and_moves_accounting; the sweep is the only path that moves amounts.)refundafter auto-expiry path so no invariant drift is introduced. (refund_after_expiry_does_not_unlock_other_packages.)Documentation
BOUNDARY_VALIDATION_BEHAVIOR.mdis rewritten so "Late Claim Behavior", "Test Coverage", and "Conclusion" describe one consistent behavior, and the contradictory auto-expiry statements are removed.Test plan
cd app/onchain && cargo test --package aid_escrow— 183/183 passing across all 19 suites (2 new tests:expire_if_past_due_transitions_and_moves_accounting,expire_if_past_due_ignores_never_expiring_and_missing_packages;late_claim_behaviormodule reworked 3 → 5)cargo fmt --all -- --check— cleancargo clippy --tests --target x86_64-unknown-linux-gnu -- -D warnings— cleancargo clippy --target wasm32-unknown-unknown -- -D warnings— clean (matches contract-ci.yml)cargo check --locked— succeedsEnv vars / Notes
No new env vars or config keys. The
test_snapshots/*.jsonfiles are regenerated test artifacts (not verified by contract-ci.yml); only the five snapshots for the new/reworkedlate_claim_behaviortests are included in this PR. Pre-existing snapshot drift onmain(unrelated to this change) was left untouched.