Skip to main content

Shared idempotency-key namespace

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.

The program-escrow contract supports two idempotent payout entrypoints:

Both entrypoints write their consumed keys into a single, shared storage namespace so that a key consumed by one entrypoint is immediately visible to the other. This document explains the design and security model.


Storage layout​

Namespace / keyStorage tierStored typeWritten by
DataKey::IdempotencyKey(String)instanceIdempotencyRecordBoth entrypoints
DataKey::PayoutIdempotency(String)persistentPayoutIdempotencyKeysingle_payout_idempotent (leg.)
PAYOUT_IDEM_KEYSpersistentVec<String>batch_payout_idempotent (leg.)

The primary namespace is DataKey::IdempotencyKey stored via store_idempotency_record. Both single_payout_idempotent and batch_payout_internal (the inner function of batch_payout_idempotent) write to this record, and both replay-detection paths check it before executing any transfer.

The two legacy namespaces (DataKey::PayoutIdempotency and PAYOUT_IDEM_KEYS) exist for backwards compatibility and are checked as fallbacks.


Replay-detection order​

batch_payout_idempotent​

  1. Shared namespace (DataKey::IdempotencyKey, instance storage).
  2. Legacy single namespace (DataKey::PayoutIdempotency, persistent).
  3. Legacy batch set (PAYOUT_IDEM_KEYS, persistent).

If any check finds the key, a BatchPayoutReplayedEvent is emitted and the function returns the current ProgramData without touching any token balance.

single_payout_idempotent​

  1. Shared namespace (DataKey::IdempotencyKey, instance storage).
  2. Legacy namespace (DataKey::PayoutIdempotency, persistent).

If any check finds the key, an IdmReplay event is emitted and the function returns without executing.


is_payout_processed​

This view function checks both the shared and the legacy single namespace:

pub fn is_payout_processed(env: Env, idempotency_key: String) -> bool

Returns true if the key is found in either:

  • DataKey::IdempotencyKey (instance storage)
  • DataKey::PayoutIdempotency (persistent storage)

A key consumed by either entrypoint will therefore make this function return true.


Security model​

Deterministic rejection​

Replay detection runs before any state mutation. This ordering ensures that even if the caller supplies different parameters on replay (e.g. a different recipient or amount), the operation is rejected before any storage is read or written.

No double-payment​

Because cross-entrypoint detection is implemented at the batch_payout_idempotent / single_payout_idempotent level (not inside batch_payout_internal / single_payout_internal), the replay guard triggers before remaining_balance or recipient balances could change.

Event-audit trail​

EventTrigger
BatchPayoutReplayedEventbatch_payout_idempotent detects a replay
IdmReplaysingle_payout_idempotent detects a replay

Both events are published before the early return, so they appear in the ledger even if the operation is a no-op.

Backwards compatibility​

The legacy namespaces are retained so that keys consumed before this shared namespace was introduced are still recognised.


Testing​

The regression tests live in contracts/program-escrow/src/tests/cross_entrypoint_idempotency_tests.rs. They cover the six scenarios listed in the test file's module-level doc comment.

# Run only the cross-entrypoint tests
cargo test -p program-escrow cross_entrypoint_idempotency -- --nocapture