diff --git a/README.md b/README.md index 34b8fe56..12cc569a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/entrypoint-reference.md b/docs/entrypoint-reference.md index b20be748..f3a2fbe2 100644 --- a/docs/entrypoint-reference.md +++ b/docs/entrypoint-reference.md @@ -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, 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` @@ -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`. - - diff --git a/src/lib.rs b/src/lib.rs index 59dcbdc4..09fb3211 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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() { @@ -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, 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) + } } diff --git a/src/test.rs b/src/test.rs index 590323d1..4753c239 100644 --- a/src/test.rs +++ b/src/test.rs @@ -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); +}