Skip to main content

Idempotency Keys for batch_payout

Overview

The batch_payout_idempotent and batch_payout_idempotent_by entrypoints let callers attach a caller-supplied idempotency key to every batch payout. If the backend retries a payout (e.g. after a network timeout), the contract detects the duplicate key and returns the current state without transferring funds again.

This is the primary defence against double-payment caused by retry storms or at-least-once delivery semantics in the Grainlify backend.


Entrypoints

batch_payout_idempotent

pub fn batch_payout_idempotent(
env: Env,
idempotency_key: String, // unique caller-supplied key
recipients: Vec<Address>,
amounts: Vec<i128>,
) -> ProgramData

batch_payout_idempotent_by (delegate variant)

pub fn batch_payout_idempotent_by(
env: Env,
idempotency_key: String,
caller: Address, // delegate address
recipients: Vec<Address>,
amounts: Vec<i128>,
) -> ProgramData

Both functions share identical idempotency semantics.


Behaviour

ScenarioOutcome
Key is newPayout executes; key is stored; BatchPayoutEvent emitted
Key was already consumedNo funds transferred; BatchPayoutReplayedEvent emitted; current ProgramData returned

Key lifecycle

  1. On the first call the key is absent from storage → payout proceeds.
  2. The key is written to persistent storage only after all transfers succeed (write-after-success prevents a failed payout from consuming the key).
  3. On any subsequent call with the same key the contract emits BatchPayoutReplayedEvent and returns immediately.

Audit Events

BatchPayoutEvent (normal payout)

Emitted on every successful first-time execution.

pub struct BatchPayoutEvent {
pub version: u32, // always 2
pub program_id: String,
pub recipient_count: u32,
pub total_amount: i128,
pub remaining_balance: i128,
pub idempotency_key: Option<String>, // the key that triggered this payout (if any)
}

BatchPayoutReplayedEvent (replay detected)

Emitted instead of BatchPayoutEvent when a duplicate key is detected. Auditors can use this event to confirm that no double-payment occurred.

pub struct BatchPayoutReplayedEvent {
pub version: u32, // always 2
pub program_id: String,
pub idempotency_key: String, // the key that was replayed
}

Event topic symbol: BatPayRp


Storage

KeyTypeLocationDescription
PayIdemVec<String>PersistentSet of consumed idempotency keys

Keys are never expired or pruned. This is intentional: the storage cost is bounded by the number of distinct batch payouts ever executed, which is small relative to the escrow lifetime.


Choosing an Idempotency Key

The key must be unique per logical payout batch. Recommended strategies:

  • UUID v4 generated by the backend before the first attempt.
  • Content hash of (program_id, sorted_recipients, amounts, timestamp).

The key must be ≤ 64 bytes (Soroban String limit for this contract).


Security Assumptions

  1. Replay detection is pre-transfer. The key lookup happens before any token transfer, so a replay can never partially execute.
  2. Write-after-success. The key is persisted only after all transfers complete. A mid-batch failure leaves the key unconsumed, allowing a safe retry.
  3. No key expiry. Keys are permanent. Callers must not reuse keys across logically distinct payouts.
  4. Authorization unchanged. Idempotency wraps the existing batch_payout_internal which enforces the same authorization, pause, and circuit-breaker checks.

Test Coverage

All tests live in contracts/program-escrow/src/test_batch_operations.rs.

TestWhat it verifies
test_idempotent_batch_payout_first_call_succeedsFresh key executes payout and reduces balance
test_idempotent_batch_payout_replay_no_double_paymentReplay returns same balance; no extra transfer
test_idempotent_batch_payout_replay_emits_audit_eventReplay emits BatchPayoutReplayedEvent with correct key
test_idempotent_batch_payout_audit_trail_integrityVerifies full audit trail: BATCH_PAYOUT only on first call, BatPayRp on replay
test_idempotent_batch_payout_distinct_keys_all_executeThree distinct keys each execute independently
test_idempotent_batch_payout_partial_overlapMix of new and duplicate keys; only new keys transfer
test_idempotent_batch_payout_complex_retry_interleavingComplex mix of unique and replayed keys maintains state integrity
test_idempotent_replay_does_not_grow_payout_historyReplays never append to payout_history
test_idempotent_batch_payout_by_replay_no_double_paymentDelegate variant respects idempotency

Run with:

cargo test -p program-escrow test_idempotent