Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/onchain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Events use stable topic identifiers (struct name in snake_case) so indexers and
| `disburse(id)` | Admin manually sends package funds to recipient | `admin` |
| `revoke(id)` / `cancel_package(id)` | Cancels an active package and unlocks funds | `admin` |
| `refund(id)` | Returns funds from an expired/cancelled package to admin | `admin` |
| `expire_if_past_due(id)` | Transitions a past-due package to `Expired` and releases its locked funds | None |
| `extend_expiration(id, additional_time)` | Extends the expiration of a package | `admin` |
| `withdraw_surplus(to, amount, token)` | Withdraws unallocated (non-locked) funds | `admin` |
| `add_distributor(addr)` | Grants distributor rights to an address | `admin` |
Expand Down
46 changes: 24 additions & 22 deletions app/onchain/contracts/aid_escrow/BOUNDARY_VALIDATION_BEHAVIOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

This document describes the expected behavior of claim timing validation on the ChainForge platform, specifically for Testnet ledgers. It covers claim start time boundaries, expiry boundaries, and auto-expiry logic.
This document describes the expected behavior of claim timing validation on the ChainForge platform, specifically for Testnet ledgers. It covers claim start time boundaries, expiry boundaries, and late-claim expiry-sweep behavior.

## Onchain Contract Behavior (Soroban)

Expand All @@ -26,18 +26,18 @@ The onchain contract enforces the following timing rules:
- **Validation**:
- If `expires_at > 0`, must be > `created_at`
- Must respect `config.max_expires_in` if configured
- **Claim Validation**: Claim attempts where `expires_at > 0 && now > expires_at` trigger auto-expiry
- **Claim Validation**: Claim attempts where `expires_at > 0 && now > expires_at` return `Error::PackageExpired` without mutating state

#### 3. Late Claim Behavior

**Important**: When a claim is attempted after expiry, the contract returns an error but does **NOT** automatically update the package status to `Expired`. The package status remains `Created`.
**Important**: When a claim is attempted after expiry, the contract returns `Error::PackageExpired` and leaves the package in `Created`. Soroban reverts all storage writes when a function returns an error, so a claim attempt **cannot** atomically mark the package `Expired` and return an error in the same call.

- **Trigger**: Any claim attempt (`claim()` or `claim_with_proof()`) where `expires_at > 0 && now > expires_at`
- **State Change**: Package status remains `Created` (no automatic transition)
- **State Change**: None — package status remains `Created`
- **Error Returned**: `Error::PackageExpired`
- **Persistence**: Since status doesn't change, if time is reverted, the package can still be claimed
- **Subsequent Attempts**: Further claim attempts will continue to return `Error::PackageExpired` as long as `now > expires_at`
- **Status Transition**: Package status only transitions to `Expired` through other operations (e.g., `refund()` can transition a `Created` package to `Expired`)
- **Expiry sweep**: `expire_if_past_due(id)` is a permissionless entrypoint that transitions a past-due `Created` package to `Expired`, releases its locked funds, and moves its aggregate totals from `Created` to the expired/cancelled bucket. It is idempotent and returns `Ok`.
- **Refund**: `refund(id)` converts an `Expired` (or `Cancelled`) package to `Refunded` and transfers the funds to the admin, without double-releasing locked funds.
- **Persistence**: Because the late claim leaves status `Created`, a time revert to within the window still allows a successful claim; once `expire_if_past_due` has run, the package is permanently `Expired`.

### Boundary Conditions

Expand Down Expand Up @@ -99,11 +99,11 @@ The backend runs an automated cleanup process:

| Aspect | Backend | Onchain |
|--------|---------|---------|
| Trigger | Cron job (hourly) | Claim attempt |
| Status Change | `requested/verified` → `archived` | `Created` → `Expired` |
| Timing | After expiry (batch cleanup) | At claim attempt (immediate) |
| Trigger | Cron job (hourly) | `expire_if_past_due(id)` (permissionless) or `refund(id)` |
| Status Change | `requested/verified` → `archived` | `Created` → `Expired` (then `Refunded` on admin refund) |
| Timing | After expiry (batch cleanup) | On demand (any account may sweep) |
| Reversibility | No (archived) | No (permanent) |
| Fund Recovery | Attempts revoke/refund | Requires manual refund by admin |
| Fund Recovery | Attempts revoke/refund | `refund(id)` transfers funds to admin |

## Frontend Display Behavior

Expand Down Expand Up @@ -170,8 +170,8 @@ New comprehensive boundary validation tests have been added in `tests/boundary_v
2. **Expiry Boundaries**:
- `succeeds_when_claimed_1_second_before_expiry`
- `succeeds_when_claimed_at_exact_expiry`
- `fails_when_claimed_1_second_after_expiry_with_auto_expire`
- `fails_when_claimed_long_after_expiry_with_auto_expire`
- `fails_when_claimed_1_second_after_expiry`
- `fails_when_claimed_long_after_expiry`

3. **Combined Boundaries**:
- `fails_when_claim_starts_at_equals_expires_at_and_claimed_before`
Expand All @@ -180,10 +180,12 @@ New comprehensive boundary validation tests have been added in `tests/boundary_v
- `narrow_claim_window_1_second`
- `zero_claim_window_fails_creation`

4. **Auto-Expiry Behavior**:
- `package_status_auto_expires_on_first_late_claim_attempt`
- `auto_expired_package_cannot_be_claimed_even_if_time_reverted`
- `claim_with_proof_also_auto_expires_on_late_attempt`
4. **Late Claim / Expiry Sweep Behavior**:
- `late_claim_returns_error_without_transitioning`
- `claim_with_proof_fails_after_expiry_without_transitioning`
- `expire_if_past_due_transitions_and_moves_accounting`
- `expire_if_past_due_ignores_never_expiring_and_missing_packages`
- `refund_after_expiry_does_not_unlock_other_packages`

5. **Edge Cases**:
- `package_with_zero_expiry_never_expires`
Expand All @@ -206,10 +208,10 @@ Existing test coverage includes:
- Confirm exact boundary behavior matches expectations
- Test with real ledger timestamps (not just mocked)

2. **Monitor Auto-Expiry**:
2. **Monitor Expiry Sweep**:
- Deploy a test package with short expiry (e.g., 1 hour)
- Attempt claim after expiry to verify auto-expiry
- Check that status updates correctly on-chain
- Attempt a claim after expiry and confirm it returns `Error::PackageExpired`
- Call `expire_if_past_due(id)` and check the package transitions to `Expired` and locked/aggregate totals move

3. **Frontend Integration**:
- Test claim button enable/disable at exact boundaries
Expand Down Expand Up @@ -240,8 +242,8 @@ After Testnet deployment, monitor:

The claim window and expiry boundary validation is implemented with the following key behaviors:

- **Onchain**: Immediate validation at claim attempt with auto-expiry on late claims
- **Onchain**: Immediate validation at claim attempt; late claims return `Error::PackageExpired`, and the permissionless `expire_if_past_due(id)` entrypoint performs the `Created → Expired` transition and its accounting
- **Backend**: Batch cleanup of expired claims via hourly cron job
- **Frontend**: Visual indicators and countdown timers for claim windows

The boundary conditions are well-defined and tested, with the critical behavior being that late claim attempts automatically expire the package status on-chain.
The boundary conditions are well-defined and tested. Late claim attempts return `Error::PackageExpired` without mutating state (Soroban reverts writes on error); the idempotent, permissionless `expire_if_past_due(id)` entrypoint performs the `Created → Expired` transition and its accounting.
3 changes: 2 additions & 1 deletion app/onchain/contracts/aid_escrow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ expires and is refunded.
| `disburse(env, id)` | Admin | Admin manually disburses a package to its recipient. |
| `revoke(env, id)` | Admin | Admin revokes a package, returning funds to the surplus pool. |
| `refund(env, id)` | Admin | Refunds an expired or cancelled package to the admin. |
| `expire_if_past_due(env, id)` | — | Permissionless, idempotent sweep: transitions a past-due `Created` package to `Expired`, releases its locked funds, and moves its aggregate totals to the expired/cancelled bucket. |
| `cancel_package(env, package_id)` | Admin | Cancels a package (transitions to Cancelled status). |
| `extend_expiration(env, package_id, additional_time)` | Admin / Distributor | Extends the expiration time of an active package. |

Expand All @@ -89,7 +90,7 @@ expires and is refunded.

```
Created --> Claimed (recipient or delegate claims)
Created --> Expired (past expiry, recipient tries to claim)
Created --> Expired (past expiry, expire_if_past_due sweep)
Created --> Cancelled (admin cancels)
Created --> Claimed (admin) (admin disburses)
Expired --> Refunded (admin refunds)
Expand Down
60 changes: 53 additions & 7 deletions app/onchain/contracts/aid_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,11 +1220,12 @@ impl AidEscrow {
.get(&key)
.ok_or(Error::PackageNotFound)?;

// Can only refund if Expired or Cancelled.
// If Created, must Revoke first. If Claimed, impossible.
// If Refunded, impossible.
let should_unlock_locked =
package.status == PackageStatus::Created || package.status == PackageStatus::Expired;
// Only a `Created` package still holds its amount in KEY_TOTAL_LOCKED.
// A package auto-expired on the claim path has already had its locked
// funds released and its aggregates moved, so it must NOT be unlocked
// again here (that would double-decrement). Cancelled packages were
// already unlocked in `revoke`.
let should_unlock_locked = package.status == PackageStatus::Created;

// Capture whether the package was in Created status before any mutations.
// Packages already in Expired or Cancelled are already counted in
Expand All @@ -1244,8 +1245,9 @@ impl AidEscrow {
return Err(Error::InvalidState);
}

// If Cancelled, funds were already unlocked in `revoke`.
// Expired packages are unlocked only after a successful refund transfer.
// Cancelled packages were already unlocked in `revoke`; auto-expired
// packages were already unlocked in `expire_if_past_due`. Only a
// `Created` package is unlocked here (see `should_unlock_locked`).

// Transfer Contract -> Admin
Self::transfer_token(
Expand Down Expand Up @@ -1525,6 +1527,50 @@ impl AidEscrow {
env.storage().instance().set(&KEY_TOTAL_LOCKED, &locked_map);
}

/// Permissionless. Idempotently transitions a past-due `Created` package
/// to `Expired`, releasing its locked funds and moving its aggregate totals
/// from `Created` to the expired/cancelled bucket.
///
/// Returns `Ok` whether the package was expired, is still claimable, or was
/// already in a terminal state (idempotent). The transition must be a
/// successful call because Soroban reverts storage writes when a function
/// returns an error, so a late `claim` cannot both transition the package
/// and return `Error::PackageExpired` in the same invocation.
pub fn expire_if_past_due(env: Env, id: u64) -> Result<(), Error> {
let key = (symbol_short!("pkg"), id);
let mut package: Package = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::PackageNotFound)?;

let now = env.ledger().timestamp();

// `expires_at == 0` means the package never expires.
if package.expires_at == 0 || now <= package.expires_at {
return Ok(());
}
// Only a `Created` package can transition; an already-terminal package
// has either been paid out or already released its funds.
if package.status != PackageStatus::Created {
return Ok(());
}

package.status = PackageStatus::Expired;
env.storage().persistent().set(&key, &package);

Self::decrement_locked(&env, &package.token, package.amount);
Self::add_to_status_totals(
&env,
&package.token,
PackageStatus::Created,
-package.amount,
);
Self::add_to_status_totals(&env, &package.token, PackageStatus::Expired, package.amount);

Ok(())
}

/// Validates a token contract against the configured decimals policy.
///
/// # Behaviour
Expand Down
Loading
Loading