Skip to main content

Idempotency Keys for batch_payout

Last updated: 16 August 2026

Documents a contract that does not exist

The only Soroban contract in this project is grainhack-escrow (initialise, fund, publish_root, claim, is_claimed, sweep), and it has never been deployed. The program-escrow and bounty-escrow surfaces this page describes — publish_program, lock_program_funds, single_payout, batch_payout, create_bounty and the rest — have no implementation in any repository, verified 16 August 2026.

This page is kept as a design record. Do not read it as a description of working software, and do not implement against it without checking what the contract actually exposes. See Payout path — what exists today.

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