Repository navigation
Description
Problem
When a claim arrives after a package's expires_at, both claim and claim_with_proof return an error but leave the package in PackageStatus::Created:
// app/onchain/contracts/aid_escrow/src/lib.rs (claim)
if package.expires_at > 0 && now > package.expires_at {
return Err(Error::PackageExpired); // ← returns early, no state transition
}No decrement_locked, no add_to_status_totals, and no status write happen on this path. The funds stay counted in KEY_TOTAL_LOCKED and in the Created (KEY_TOTAL_COMMITTED) aggregate until an admin manually calls refund(id).
Consequences, distinctly:
- Solvency pool leak.
create_packagerejects new packages whencontract_balance < current_locked + amount. Because expired-but-unclaimed packages never decrementKEY_TOTAL_LOCKED, their escrowed funds are invisible as available pool, and the pool shrinks over time as packages expire. This is a liveness bug: the admin must remember to sweep every expired package by hand to reclaim capacity. - Aggregates lie.
get_aggregates(token)returnstotal_committedthat overstates genuinely claimable commitments andtotal_expired_cancelledthat undercounts expiry. Dashboards and indexers built onget_aggregatesreport funds as committed that are actually unclaimable. - The documentation contradicts itself.
BOUNDARY_VALIDATION_BEHAVIOR.mdsection "Late Claim Behavior" says the package "remainsCreated", but the same file's "Test Coverage" and "Conclusion" describepackage_status_auto_expires_on_first_late_claim_attemptand "late claim attempts automatically expire the package status on-chain". The shipped tests do not match the shipped code, so a contributor cannot tell which behavior is intended.
Root cause
The expiry check was added as a guard that returns early, but the corresponding state transition (mark Expired, release locked funds, move aggregates Created → Expired) was only implemented in the admin refund path, not on the claim path.
Why this is architecturally hard
- The transition must be atomic with the check. Marking
Expiredon a late claim is not just a status write: it must alsodecrement_lockedand moveKEY_TOTAL_COMMITTED → KEY_TOTAL_EXPIRED_CANCELLEDfor the token, or the invariantΣ locked + Σ claimed + Σ surplus == total_funded(see the closed issue Invariant test:Σ locked_tokens + Σ claimed_tokens + Σ surplus == total_funded#223) drifts. A naive "set status = Expired" makes the problem worse. - Aggregates vs. the storage model.
add_to_status_totalsusesPackageStatus::Refundedas its own bucket mapping; the design must decide whetherExpiredis written by the claim path (and whatrefundthen sees) or only byrefund, and howrefund's existingwas_committedlogic stays correct. - The check is duplicated.
claimandclaim_with_proofeach inline thenow > expires_atguard, andrefund/cancel_package/extend_expiryeach have their own slightly different reading of expiry. The fix must decide whether to introduce a sharedassert_not_expired/expire_if_past_duehelper rather than a fifth copy. - Backend parity.
BOUNDARY_VALIDATION_BEHAVIOR.mdalso documents a backendClaimsService.cleanupExpiredClaims()cron that archives and revokes/refunds. If the contract starts auto-expiring on claim, the backend's "Created → archived" flow and its onchain revoke/refund step must be reconciled so a package is not both auto-expired and then refund-attempted.
Proposed design
Prefer an idempotent transition helper invoked on every claim/claim_with_proof when expires_at > 0 && now > expires_at: set status = Expired, decrement_locked, move the amount from Created to the expired/cancelled bucket, then return Error::PackageExpired. refund must remain able to convert an Expired package to Refunded and transfer the funds. Keep view_package_status cheap (still a single read).
Acceptance criteria
Contract
- A late
claim/claim_with_proofon aCreatedpackage transitions it toExpiredand returnsError::PackageExpired. - After auto-expiry,
get_total_locked(token)no longer includes that package's amount andget_aggregates(token)shows the amount intotal_expired_cancelled, nottotal_committed. -
refund(id)on an auto-expired package still transfers funds to the admin and ends inRefundedwithout double-decrementing locked totals.
Tests
- Tests in
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. - A test pins the
refundafter auto-expiry path so no invariant drift is introduced.
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.
Out of scope
Adding a recipient-index or pagination to package reads, and any change to expires_at == 0 (never-expire) semantics, are separate issues.
Getting started
Files: app/onchain/contracts/aid_escrow/src/lib.rs (claim, claim_with_proof, refund, add_to_status_totals, decrement_locked), app/onchain/contracts/aid_escrow/BOUNDARY_VALIDATION_BEHAVIOR.md, app/onchain/contracts/aid_escrow/tests/boundary_validation_tests.rs.
cd app/onchain
make test # cargo test -- --nocaptureGood first files to read: src/lib.rs refund (the existing Created→Expired→Refunded logic) and tests/aggregates.rs to see how totals are asserted today.
Activity
DeEvelyn commentedon Aug 19, 2026
I will like to work on this, kindly assign me, I have experience.
edochieblessing09-max commentedon Aug 19, 2026
I will love to work on this issue, will produce an excellent result
grantfox-oss commentedon Aug 20, 2026
🦊 GrantFox — @DeEvelyn has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #423)Good luck! Track your progress on GrantFox.
grantfox-oss commentedon Aug 20, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@DeEvelyn's PR #444 was approved and merged by @kilodesodiq-arch.
🏆 @DeEvelyn: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (624 total points). Track your full progress on GrantFox.
👏 Great work, @DeEvelyn! Keep contributing to ChainForgee.