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
| Scenario | Outcome |
|---|---|
| Key is new | Payout executes; key is stored; BatchPayoutEvent emitted |
| Key was already consumed | No funds transferred; BatchPayoutReplayedEvent emitted; current ProgramData returned |
Key lifecycle
- On the first call the key is absent from storage → payout proceeds.
- The key is written to persistent storage only after all transfers succeed (write-after-success prevents a failed payout from consuming the key).
- On any subsequent call with the same key the contract emits
BatchPayoutReplayedEventand 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
| Key | Type | Location | Description |
|---|---|---|---|
PayIdem | Vec<String> | Persistent | Set 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
- Replay detection is pre-transfer. The key lookup happens before any token transfer, so a replay can never partially execute.
- Write-after-success. The key is persisted only after all transfers complete. A mid-batch failure leaves the key unconsumed, allowing a safe retry.
- No key expiry. Keys are permanent. Callers must not reuse keys across logically distinct payouts.
- Authorization unchanged. Idempotency wraps the existing
batch_payout_internalwhich enforces the same authorization, pause, and circuit-breaker checks.
Test Coverage
All tests live in
contracts/program-escrow/src/test_batch_operations.rs.
| Test | What it verifies |
|---|---|
test_idempotent_batch_payout_first_call_succeeds | Fresh key executes payout and reduces balance |
test_idempotent_batch_payout_replay_no_double_payment | Replay returns same balance; no extra transfer |
test_idempotent_batch_payout_replay_emits_audit_event | Replay emits BatchPayoutReplayedEvent with correct key |
test_idempotent_batch_payout_audit_trail_integrity | Verifies full audit trail: BATCH_PAYOUT only on first call, BatPayRp on replay |
test_idempotent_batch_payout_distinct_keys_all_execute | Three distinct keys each execute independently |
test_idempotent_batch_payout_partial_overlap | Mix of new and duplicate keys; only new keys transfer |
test_idempotent_batch_payout_complex_retry_interleaving | Complex mix of unique and replayed keys maintains state integrity |
test_idempotent_replay_does_not_grow_payout_history | Replays never append to payout_history |
test_idempotent_batch_payout_by_replay_no_double_payment | Delegate variant respects idempotency |
Run with:
cargo test -p program-escrow test_idempotent