Skip to content
Open
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ Each transfer moves through the following states:
- `Cancelled` — sender reclaimed the funds after expiry (terminal).

Only `Pending` transfers can be claimed or cancelled. Claims must happen on or
before the expiry timestamp; cancellations are only allowed strictly after it.
before the expiry timestamp; cancellations and expiry sweeping are only allowed
strictly after it. `sweep_expired_batch(start_id, limit)` is permissionless:
it refunds expired pending transfers to their original senders and returns the
ids swept. It examines at most 50 sequential ids per call (including terminal
or missing records), so callers advance the cursor themselves and retries are
safe: terminal records are skipped and cannot be refunded twice.

## Batch operations

Expand Down
24 changes: 22 additions & 2 deletions docs/entrypoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@ internally tracked `TotalEscrowed` liability.
solvency independently. See [Invariants](./invariants.md) for the
rationale.

## Expiry Sweeping

### `sweep_expired_batch(start_id: u64, limit: u32) -> Result<Vec<u64>, Error>`

Permissionlessly refunds expired pending transfers to their original senders.

* **Authorization**: None. This is deliberate so anyone can run cleanup bots;
the payout is always fixed to the recorded sender, never the caller.
* **Expiry boundary**: A transfer is eligible only when
`ledger_timestamp > expiry`. At `ledger_timestamp == expiry`, it remains
claimable and cannot be swept.
* **Bounded work**: `start_id` is inclusive (`0` is treated as `1`) and the
method inspects at most `min(limit, MAX_SWEEP_BATCH_SIZE)` sequential ids,
where `MAX_SWEEP_BATCH_SIZE` is 50. Missing and terminal records count toward
the bound, ensuring a sparse id range cannot make a sweep unbounded.
* **Result and retry**: Returns only ids swept by this call. Live, missing, and
terminal records are skipped. Therefore callers can retry a cursor safely:
a terminal transfer cannot be refunded twice.
* **Effects**: Each swept transfer becomes `Cancelled`, reduces
`TotalEscrowed`, emits the normal `cancelled` event, and transfers its amount
from contract escrow to the recorded sender.

## Transfer Queries

### `get_transfers_paged(start_id: u64, limit: u32) -> Vec<Transfer>`
Expand All @@ -122,5 +144,3 @@ Returns the contract's configured operational limits.

* **Authorization**: None (public view, callable pre/post-initialization)
* **Returns**: [`ConfiguredLimits`](data-types.md#configuredlimits) containing `max_amount`, `max_expiry_window`, `max_total_escrowed`, and `max_page_size`.


59 changes: 59 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ pub const PRIVILEGED_COOLDOWN: u64 = 300;
pub const MAX_PAGE_SIZE: u32 = 100;
/// Maximum number of operations allowed in a single batch_operations call.
pub const MAX_BATCH_SIZE: u32 = 50;
/// Maximum number of transfer ids inspected by one expiry sweep batch.
///
/// The limit bounds both storage reads and token transfers, keeping a sweep
/// invocation within a predictable transaction budget.
pub const MAX_SWEEP_BATCH_SIZE: u32 = 50;

fn require_external_address(env: &Env, address: &Address) -> Result<(), Error> {
if *address == env.current_contract_address() {
Expand Down Expand Up @@ -532,4 +537,58 @@ impl RemitFlowContract {

Ok(())
}

/// Sweeps expired pending transfers in an ascending, bounded id range.
///
/// `start_id` is inclusive and `0` is clamped to `1`. At most
/// `min(limit, MAX_SWEEP_BATCH_SIZE)` ids are inspected, including ids
/// whose records have expired from storage or have already reached a
/// terminal status. This makes cursored retries deterministic and bounds
/// the work performed by a single invocation. A transfer is swept only
/// when the ledger timestamp is strictly greater than its expiry; funds
/// are returned to its original sender. The call is permissionless and
/// returns the ids swept during this invocation. Repeating a range is
/// idempotent because terminal records are skipped.
pub fn sweep_expired_batch(env: Env, start_id: u64, limit: u32) -> Result<Vec<u64>, Error> {
let token = storage::get_token(&env).ok_or(Error::NotInitialized)?;
let mut swept = Vec::new(&env);
let mut id = start_id.max(1);
let mut inspected = 0;
let max_inspected = limit.min(MAX_SWEEP_BATCH_SIZE);
let last = storage::get_counter(&env);
let now = env.ledger().timestamp();

while id <= last && inspected < max_inspected {
if let Some(mut transfer) = storage::get_transfer(&env, id) {
if transfer.status == Status::Pending && now > transfer.expiry {
token::Client::new(&env, &token).transfer(
&env.current_contract_address(),
&transfer.from,
&transfer.amount,
);
transfer.status = Status::Cancelled;
storage::set_total_escrowed(
&env,
storage::get_total_escrowed(&env).saturating_sub(transfer.amount),
);
let from = transfer.from.clone();
let amount = transfer.amount;
storage::set_transfer(&env, &transfer);
events::cancelled(&env, id, &from, amount);
swept.push_back(id);
}
}
inspected += 1;
match id.checked_add(1) {
Some(next_id) => id = next_id,
None => break,
}
}

if !swept.is_empty() {
assert_supply_invariant(&env, &token)?;
storage::extend_instance(&env);
}
Ok(swept)
}
}
96 changes: 96 additions & 0 deletions src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2272,3 +2272,99 @@ fn test_sweep_expired_not_pending_fails() {
let res = s.client.try_sweep_expired(&id);
assert_eq!(res, Err(Ok(crate::error::Error::NotPending)));
}

#[test]
fn test_sweep_expired_batch_respects_expiry_boundary_and_skips_live_transfers() {
let s = setup();
let expiry = s.env.ledger().timestamp() + 10;
let expired_id = s
.client
.create_transfer(&s.from, &s.recipient, &100, &expiry);
let live_id = s.client.create_transfer(
&s.from,
&s.recipient,
&100,
&(expiry + DEFAULT_EXPIRY_OFFSET),
);

s.env.ledger().with_mut(|ledger| ledger.timestamp = expiry);
assert_eq!(s.client.sweep_expired_batch(&0, &2), vec![&s.env]);
assert_eq!(s.client.get_status(&expired_id), Status::Pending);

s.env.ledger().with_mut(|ledger| ledger.timestamp += 1);
assert_eq!(
s.client.sweep_expired_batch(&0, &2),
vec![&s.env, expired_id]
);
assert_eq!(s.client.get_status(&expired_id), Status::Cancelled);
assert_eq!(s.client.get_status(&live_id), Status::Pending);
}

#[test]
fn test_sweep_expired_batch_is_permissionless_idempotent_and_conserves_funds() {
let s = setup();
let id = s.create_default_transfer();
s.env
.ledger()
.with_mut(|ledger| ledger.timestamp += DEFAULT_EXPIRY_OFFSET + 1);
let sender_before = s.token_client().balance(&s.from);
let escrow_before = s.token_client().balance(&s.client.address);
let liability_before = s.client.total_escrowed();

// Disable the fixture's auth mocking: sweeping must not require either
// sender or administrator authorization.
s.env.set_auths(&[]);
assert_eq!(s.client.sweep_expired_batch(&id, &1), vec![&s.env, id]);
assert_eq!(
s.token_client().balance(&s.from),
sender_before + DEFAULT_TRANSFER_AMOUNT
);
assert_eq!(
s.token_client().balance(&s.client.address),
escrow_before - DEFAULT_TRANSFER_AMOUNT
);
assert_eq!(
s.client.total_escrowed(),
liability_before - DEFAULT_TRANSFER_AMOUNT
);
s.client.check_supply_invariant();

// Retrying the same range has no refund side effect.
assert_eq!(s.client.sweep_expired_batch(&id, &1), vec![&s.env]);
assert_eq!(
s.token_client().balance(&s.from),
sender_before + DEFAULT_TRANSFER_AMOUNT
);
}

#[test]
fn test_sweep_expired_batch_limits_work_and_advances_by_cursor() {
let s = setup();
let expiry = s.future_expiry();
let first = s
.client
.create_transfer(&s.from, &s.recipient, &200, &expiry);
let second = s
.client
.create_transfer(&s.from, &s.recipient, &200, &expiry);
let third = s
.client
.create_transfer(&s.from, &s.recipient, &200, &expiry);
s.env
.ledger()
.with_mut(|ledger| ledger.timestamp += DEFAULT_EXPIRY_OFFSET + 1);

assert_eq!(
s.client.sweep_expired_batch(&first, &2),
vec![&s.env, first, second]
);
assert_eq!(s.client.get_status(&first), Status::Cancelled);
assert_eq!(s.client.get_status(&second), Status::Cancelled);
assert_eq!(s.client.get_status(&third), Status::Pending);

assert_eq!(
s.client.sweep_expired_batch(&third, &1),
vec![&s.env, third]
);
assert_eq!(s.client.get_status(&third), Status::Cancelled);
}