diff --git a/cargo_search.txt b/cargo_search.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/contracts/batch_claim/src/errors.rs b/contracts/batch_claim/src/errors.rs
index d1b12f03..18eade34 100644
--- a/contracts/batch_claim/src/errors.rs
+++ b/contracts/batch_claim/src/errors.rs
@@ -11,6 +11,8 @@ use soroban_sdk::contracterror;
/// | 5 | AlreadySettled | Claim has already been collected |
/// | 6 | InvalidAmount | Claim amount must be positive |
/// | 7 | Overflow | Arithmetic overflow in pending-amount accumulation|
+/// | 8 | ClaimIdAlreadyUsed | The claim identifier has already been consumed |
+/// | 9 | ClaimIdMismatch | Provided claim_id does not match stored record |
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
@@ -29,4 +31,8 @@ pub enum BatchClaimError {
InvalidAmount = 6,
/// Arithmetic overflow in pending-amount accumulation (code 7).
Overflow = 7,
+ /// The claim identifier has already been consumed; replay rejected (code 8).
+ ClaimIdAlreadyUsed = 8,
+ /// The provided claim_id does not match the stored record (code 9).
+ ClaimIdMismatch = 9,
}
diff --git a/contracts/batch_claim/src/events.rs b/contracts/batch_claim/src/events.rs
index e49a6797..11b765ef 100644
--- a/contracts/batch_claim/src/events.rs
+++ b/contracts/batch_claim/src/events.rs
@@ -34,3 +34,12 @@ pub fn event_claims_settled(env: &Env) -> Symbol {
pub fn event_claim_cancelled(env: &Env) -> Symbol {
Symbol::new(env, "claim_cancelled")
}
+
+/// Returns the Symbol for the `"claim_consumed"` event topic.
+///
+/// Emitted when a claim identifier is permanently consumed (marked spent) by
+/// [`crate::CalloraBatchClaim::batch_claim`]. Off-chain indexers can use this
+/// event to track which identifiers are no longer replayable.
+pub fn event_claim_consumed(env: &Env) -> Symbol {
+ Symbol::new(env, "claim_consumed")
+}
diff --git a/contracts/batch_claim/src/lib.rs b/contracts/batch_claim/src/lib.rs
index 0aff8ef7..3642b8ca 100644
--- a/contracts/batch_claim/src/lib.rs
+++ b/contracts/batch_claim/src/lib.rs
@@ -6,13 +6,40 @@
//! the entry TTL so claims can never be silently archived before the claimant
//! processes them.
//!
+//! ## Replay protection
+//!
+//! Every claim carries an explicit **claim identifier** (`BytesN<32>`). The
+//! lifecycle is:
+//!
+//! 1. Admin calls [`CalloraBatchClaim::add_claim`] supplying a unique
+//! `claim_id`. The identifier is stored inside [`ClaimRecord`] and a
+//! separate [`StorageKey::ClaimConsumed`] entry is created and set to
+//! `false`.
+//! 2. The claimant calls [`CalloraBatchClaim::batch_claim`], passing the same
+//! `claim_id`. The contract:
+//! a. Verifies `claim_id` matches the stored record (`ClaimIdMismatch`).
+//! b. Reads the [`StorageKey::ClaimConsumed`] flag; if `true` it returns
+//! [`BatchClaimError::ClaimIdAlreadyUsed`] **without** modifying any
+//! other state — failed validation never consumes state.
+//! c. Sets `ClaimConsumed(claim_id) = true` **before** any other mutation
+//! (write-before-settle) so concurrent invocations cannot both release
+//! the same entitlement.
+//! d. Sets `record.settled = true` and persists.
+//! e. Emits [`events::event_claim_consumed`] so off-chain indexers track
+//! spent identifiers.
+//!
+//! The `ClaimConsumed` key lives in **persistent** storage (independent TTL)
+//! so the consumed tombstone outlives the `ClaimRecord` if the record is later
+//! archived or re-opened.
+//!
//! ## Storage layout
//!
-//! | Scope | Key | Value |
-//! |------------|---------------------------------|----------------------------|
-//! | Instance | `StorageKey::Admin` | `Address` |
-//! | Persistent | `StorageKey::Claim(claimant)` | `ClaimRecord` |
-//! | Instance | `StorageKey::TotalClaims` | `u32` |
+//! | Scope | Key | Value |
+//! |------------|---------------------------------------|----------------------------|
+//! | Instance | `StorageKey::Admin` | `Address` |
+//! | Persistent | `StorageKey::Claim(claimant)` | `ClaimRecord` |
+//! | Persistent | `StorageKey::ClaimConsumed(claim_id)` | `bool` |
+//! | Instance | `StorageKey::TotalClaims` | `u32` |
//!
//! ## TTL management
//!
@@ -27,9 +54,8 @@
//! `PERSISTENT_BUMP` of 10 000 ledgers (≈16 days).
//! - **Every write** also bumps the instance-storage TTL via
//! `extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT)`.
-//!
-//! This ensures that active claimants never experience archival pressure on
-//! their pending claims.
+//! - The `ClaimConsumed` persistent entry is bumped alongside the `Claim`
+//! entry on every relevant read/write.
//!
//! ## Events
//!
@@ -38,16 +64,15 @@
//! | `init` | `"bc_init"` | `admin: Address` |
//! | `add_claim` | `"claim_added"` | `(claimant, amount)` |
//! | `batch_claim` | `"claims_settled"` | `(claimant, total_claimed)` |
+//! | `batch_claim` | `"claim_consumed"` | `claim_id: BytesN<32>` |
//! | `cancel_claim` | `"claim_cancelled"` | `claimant: Address` |
-//!
-//! Closes CalloraOrg/Callora-Contracts#830.
pub mod errors;
pub mod events;
pub use errors::BatchClaimError;
-use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec};
+use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Vec};
// ---------------------------------------------------------------------------
// Constants
@@ -83,6 +108,9 @@ pub enum StorageKey {
Claim(Address),
/// Monotonically-increasing count of total claims ever created.
TotalClaims,
+ /// Per-claim-id consumed tombstone. Set to `true` once the identifier
+ /// has been successfully settled; prevents replay of the same id.
+ ClaimConsumed(BytesN<32>),
}
// ---------------------------------------------------------------------------
@@ -99,6 +127,15 @@ pub struct ClaimRecord {
pub pending_amount: i128,
/// Whether the claim has already been settled (collected).
pub settled: bool,
+ /// Replay-safe identifier assigned by the admin at `add_claim` time.
+ ///
+ /// The identifier is globally unique per issuance and stored as a
+ /// 32-byte value so it can encode a UUID, content hash, or sequence
+ /// number without truncation. The contract rejects any `batch_claim`
+ /// call that presents a `claim_id` differing from this field
+ /// (`ClaimIdMismatch`) and permanently rejects any call once
+ /// `ClaimConsumed(claim_id) == true` (`ClaimIdAlreadyUsed`).
+ pub claim_id: BytesN<32>,
}
// ---------------------------------------------------------------------------
@@ -138,22 +175,28 @@ impl CalloraBatchClaim {
// Mutating entrypoints
// -----------------------------------------------------------------------
- /// Accumulate a pending reward amount for `claimant`.
+ /// Accumulate a pending reward amount for `claimant` under `claim_id`.
///
/// Only the admin may add claims. If a claim already exists for
/// `claimant`, the `amount` is added to the existing pending total
/// (overflow-safe).
///
+ /// `claim_id` must be a fresh 32-byte identifier that has **not** been
+ /// consumed before. The identifier is stored in the [`ClaimRecord`] and a
+ /// separate [`StorageKey::ClaimConsumed`] entry is initialised to `false`.
+ ///
/// # Errors
/// - [`BatchClaimError::NotInitialized`] if `init` has not been called.
/// - [`BatchClaimError::Unauthorized`] if `caller` is not the admin.
/// - [`BatchClaimError::InvalidAmount`] if `amount` ≤ 0.
+ /// - [`BatchClaimError::ClaimIdAlreadyUsed`] if `claim_id` has already been consumed.
/// - [`BatchClaimError::Overflow`] if the accumulated total would overflow.
pub fn add_claim(
env: Env,
caller: Address,
claimant: Address,
amount: i128,
+ claim_id: BytesN<32>,
) -> Result<(), BatchClaimError> {
caller.require_auth();
let admin = Self::require_admin(&env)?;
@@ -164,10 +207,25 @@ impl CalloraBatchClaim {
return Err(BatchClaimError::InvalidAmount);
}
+ // Reject re-use of a previously consumed claim identifier.
+ let consumed_key = StorageKey::ClaimConsumed(claim_id.clone());
+ if env.storage().persistent().has(&consumed_key) {
+ env.storage()
+ .persistent()
+ .extend_ttl(&consumed_key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+ let already_consumed: bool = env
+ .storage()
+ .persistent()
+ .get(&consumed_key)
+ .unwrap_or(false);
+ if already_consumed {
+ return Err(BatchClaimError::ClaimIdAlreadyUsed);
+ }
+ }
+
let key = StorageKey::Claim(claimant.clone());
// Bump TTL on the existing persistent entry before reading it.
- // This is the hot-read TTL bump described in the module-level docs.
if env.storage().persistent().has(&key) {
env.storage()
.persistent()
@@ -200,13 +258,22 @@ impl CalloraBatchClaim {
claimant: claimant.clone(),
pending_amount: new_pending,
settled: false,
+ claim_id: claim_id.clone(),
};
env.storage().persistent().set(&key, &record);
- // Bump TTL after writing the new/updated record.
+ // Bump claim record TTL after writing.
env.storage()
.persistent()
.extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+ // Initialise the consumed tombstone (false = not yet consumed).
+ env.storage()
+ .persistent()
+ .set(&consumed_key, &false);
+ env.storage()
+ .persistent()
+ .extend_ttl(&consumed_key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+
Self::bump_instance(&env);
env.events()
.publish((events::event_claim_added(&env),), (claimant, new_pending));
@@ -215,28 +282,46 @@ impl CalloraBatchClaim {
/// Settle (collect) all pending claims for a batch of claimants.
///
- /// The `claimant` in each entry must authorize their own collection.
- /// Settled claims are marked `settled = true` so double-claims are
- /// rejected. Returns the total amount collected across all claimants.
+ /// Each entry in `claimants` is a tuple of `(address, claim_id)`. The
+ /// `claimant` must authorize their own collection, and the supplied
+ /// `claim_id` must match the one stored in their [`ClaimRecord`].
+ ///
+ /// ## Replay-safe semantics
+ ///
+ /// The consumed flag for each `claim_id` is set to `true` **before** the
+ /// `settled` bit is written and before the event is emitted. This
+ /// write-before-settle ordering means:
+ ///
+ /// - A retry of the exact same call after partial success will fail on the
+ /// already-consumed identifier, not silently re-release funds.
+ /// - Failed validation (wrong `claim_id`, `ClaimNotFound`, already settled)
+ /// does **not** touch the consumed flag — state is only mutated on
+ /// the success path.
+ ///
+ /// Returns the total amount collected across all claimants.
///
/// # Errors
/// - [`BatchClaimError::NotInitialized`] if `init` has not been called.
- /// - [`BatchClaimError::ClaimNotFound`] if any `claimant` has no pending record.
- /// - [`BatchClaimError::AlreadySettled`] if any `claimant`'s claim is already settled.
+ /// - [`BatchClaimError::ClaimNotFound`] if a claimant has no pending record.
+ /// - [`BatchClaimError::AlreadySettled`] if a claimant's claim is already settled.
+ /// - [`BatchClaimError::ClaimIdMismatch`] if the supplied `claim_id` does not
+ /// match the stored record.
+ /// - [`BatchClaimError::ClaimIdAlreadyUsed`] if the `claim_id` has already been
+ /// consumed (replay attempt).
/// - [`BatchClaimError::Overflow`] if the running total overflows.
- pub fn batch_claim(env: Env, claimants: Vec
) -> Result {
+ pub fn batch_claim(
+ env: Env,
+ claimants: Vec<(Address, BytesN<32>)>,
+ ) -> Result {
Self::require_admin(&env)?;
let mut total_claimed: i128 = 0;
- for claimant in claimants.iter() {
+ for (claimant, claim_id) in claimants.iter() {
claimant.require_auth();
let key = StorageKey::Claim(claimant.clone());
+ let consumed_key = StorageKey::ClaimConsumed(claim_id.clone());
- // --- Hot-read path TTL bump ---
- // Bump BEFORE reading so the entry is always refreshed even when
- // the call ultimately fails (e.g. AlreadySettled). This prevents
- // the most recently active claims from being archived while the
- // claimant resolves an error.
+ // --- Hot-read path TTL bump on claim record ---
if env.storage().persistent().has(&key) {
env.storage()
.persistent()
@@ -249,21 +334,70 @@ impl CalloraBatchClaim {
.get(&key)
.ok_or(BatchClaimError::ClaimNotFound)?;
+ // Validation checks — none of these mutate state, so a failed
+ // call leaves storage exactly as it was found.
+
+ // 1. Verify the claim_id matches the stored record.
+ if record.claim_id != claim_id {
+ return Err(BatchClaimError::ClaimIdMismatch);
+ }
+
+ // 2. Verify the claim_id has not been consumed by a prior call.
+ // This check MUST precede the settled check so that the
+ // consumed tombstone is the authoritative replay guard.
+ // Bump consumed-tombstone TTL on every read (hot path).
+ if env.storage().persistent().has(&consumed_key) {
+ env.storage()
+ .persistent()
+ .extend_ttl(&consumed_key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+ let already_consumed: bool = env
+ .storage()
+ .persistent()
+ .get(&consumed_key)
+ .unwrap_or(false);
+ if already_consumed {
+ return Err(BatchClaimError::ClaimIdAlreadyUsed);
+ }
+ }
+
+ // 3. Verify the record has not already been settled.
+ // This is a secondary consistency check; the consumed tombstone
+ // above is the primary replay guard.
if record.settled {
return Err(BatchClaimError::AlreadySettled);
}
+ // All validation passed. Accumulate into running total before
+ // writing any state so an overflow error is also non-mutating.
total_claimed = total_claimed
.checked_add(record.pending_amount)
.ok_or(BatchClaimError::Overflow)?;
+ // Write-before-settle: mark the claim_id as consumed FIRST.
+ // This is the concurrency-safety invariant: if two transactions
+ // race, the one that commits this write second will read
+ // `already_consumed = true` and fail with ClaimIdAlreadyUsed.
+ env.storage()
+ .persistent()
+ .set(&consumed_key, &true);
+ env.storage()
+ .persistent()
+ .extend_ttl(&consumed_key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+
+ // Now mark the claim record itself as settled.
record.settled = true;
env.storage().persistent().set(&key, &record);
- // Bump again after the write to keep the settled tombstone live.
env.storage()
.persistent()
.extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+ // Emit consumed event so off-chain indexers can track spent ids.
+ env.events().publish(
+ (events::event_claim_consumed(&env),),
+ claim_id.clone(),
+ );
+
+ // Emit settled event with claimant + amount.
env.events().publish(
(events::event_claims_settled(&env),),
(claimant.clone(), record.pending_amount),
@@ -376,6 +510,35 @@ impl CalloraBatchClaim {
Ok(!record.settled)
}
+ /// Return whether a given `claim_id` has already been consumed.
+ ///
+ /// Returns `true` if the identifier has been spent (successfully settled),
+ /// `false` if it is either not yet registered or registered but still
+ /// pending.
+ ///
+ /// **TTL bump**: bumps the persistent entry TTL on every call.
+ ///
+ /// No auth required.
+ ///
+ /// # Errors
+ /// Returns [`BatchClaimError::NotInitialized`] if `init` has not been called.
+ pub fn claim_id_consumed(env: Env, claim_id: BytesN<32>) -> Result {
+ Self::require_admin(&env)?;
+ let key = StorageKey::ClaimConsumed(claim_id);
+
+ if env.storage().persistent().has(&key) {
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
+ return Ok(env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or(false));
+ }
+ Ok(false)
+ }
+
/// Total number of claims ever created (monotonically increasing).
///
/// No auth required.
@@ -432,7 +595,18 @@ extern crate std;
mod tests {
use super::*;
use soroban_sdk::testutils::Address as _;
- use soroban_sdk::{Address, Env, Vec};
+ use soroban_sdk::{Address, BytesN, Env, Vec};
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ /// Build a deterministic 32-byte claim id from a small integer seed.
+ fn make_id(env: &Env, seed: u8) -> BytesN<32> {
+ let mut raw = [0u8; 32];
+ raw[31] = seed;
+ BytesN::from_array(env, &raw)
+ }
fn setup(env: &Env) -> (Address, Address, CalloraBatchClaimClient<'_>) {
env.mock_all_auths();
@@ -468,224 +642,403 @@ mod tests {
}
// -----------------------------------------------------------------------
- // add_claim
+ // add_claim authorization
// -----------------------------------------------------------------------
#[test]
- fn test_add_claim_success() {
+ fn test_add_claim_non_admin_rejected() {
let env = Env::default();
- let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &500);
- let rec = client.get_claim(&claimant);
- assert_eq!(rec.pending_amount, 500);
- assert!(!rec.settled);
+ let (_admin, claimant, client) = setup(&env);
+ let intruder = Address::generate(&env);
+ let id = make_id(&env, 1);
+ let res = client.try_add_claim(&intruder, &claimant, &100, &id);
+ assert_eq!(res, Err(Ok(BatchClaimError::Unauthorized)));
}
#[test]
- fn test_add_claim_accumulates() {
+ fn test_add_claim_zero_amount_rejected() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &300);
- client.add_claim(&admin, &claimant, &200);
- let rec = client.get_claim(&claimant);
- assert_eq!(rec.pending_amount, 500);
+ let id = make_id(&env, 1);
+ let res = client.try_add_claim(&admin, &claimant, &0, &id);
+ assert_eq!(res, Err(Ok(BatchClaimError::InvalidAmount)));
}
#[test]
- fn test_add_claim_invalid_amount() {
+ fn test_add_claim_negative_amount_rejected() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- let res = client.try_add_claim(&admin, &claimant, &0);
+ let id = make_id(&env, 1);
+ let res = client.try_add_claim(&admin, &claimant, &-1, &id);
assert_eq!(res, Err(Ok(BatchClaimError::InvalidAmount)));
- let res2 = client.try_add_claim(&admin, &claimant, &-1);
- assert_eq!(res2, Err(Ok(BatchClaimError::InvalidAmount)));
}
+ /// Boundary: amount of 1 is the smallest accepted value.
#[test]
- fn test_add_claim_unauthorized() {
+ fn test_add_claim_boundary_amount_one_accepted() {
let env = Env::default();
- let (_, claimant, client) = setup(&env);
- let non_admin = Address::generate(&env);
- let res = client.try_add_claim(&non_admin, &claimant, &100);
- assert_eq!(res, Err(Ok(BatchClaimError::Unauthorized)));
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 1);
+ client.add_claim(&admin, &claimant, &1, &id);
+ let rec = client.get_claim(&claimant);
+ assert_eq!(rec.pending_amount, 1);
}
// -----------------------------------------------------------------------
- // batch_claim
+ // Replay protection: claim_id uniqueness in add_claim
// -----------------------------------------------------------------------
+ /// A consumed claim_id cannot be re-issued by the admin.
#[test]
- fn test_batch_claim_single() {
+ fn test_add_claim_rejects_consumed_id() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &1_000);
+ let id = make_id(&env, 7);
+
+ client.add_claim(&admin, &claimant, &500, &id);
+
+ // Settle so the id is consumed.
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), id.clone()));
+ client.batch_claim(&batch);
+
+ // Admin tries to reuse the same claim_id for a new issuance → rejected.
+ let claimant2 = Address::generate(&env);
+ let res = client.try_add_claim(&admin, &claimant2, &200, &id);
+ assert_eq!(res, Err(Ok(BatchClaimError::ClaimIdAlreadyUsed)));
+ }
+
+ // -----------------------------------------------------------------------
+ // batch_claim: replay / retry protection
+ // -----------------------------------------------------------------------
- let mut claimants = Vec::new(&env);
- claimants.push_back(claimant.clone());
- let total = client.batch_claim(&claimants);
+ /// Happy path: a valid claim is settled exactly once and returns the amount.
+ #[test]
+ fn test_batch_claim_happy_path() {
+ let env = Env::default();
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 1);
+ client.add_claim(&admin, &claimant, &1_000, &id);
+
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), id.clone()));
+ let total = client.batch_claim(&batch);
assert_eq!(total, 1_000);
+ // Verify settled state.
let rec = client.get_claim(&claimant);
assert!(rec.settled);
}
+ /// Retrying the exact same batch after success returns ClaimIdAlreadyUsed
+ /// (the consumed tombstone is set), not AlreadySettled.
#[test]
- fn test_batch_claim_multiple() {
+ fn test_retry_same_id_rejected_with_claim_id_already_used() {
let env = Env::default();
- let (admin, _, client) = setup(&env);
- let c1 = Address::generate(&env);
- let c2 = Address::generate(&env);
- client.add_claim(&admin, &c1, &400);
- client.add_claim(&admin, &c2, &600);
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 2);
+ client.add_claim(&admin, &claimant, &500, &id);
- let mut claimants = Vec::new(&env);
- claimants.push_back(c1);
- claimants.push_back(c2);
- let total = client.batch_claim(&claimants);
- assert_eq!(total, 1_000);
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), id.clone()));
+
+ client.batch_claim(&batch);
+
+ // Second attempt with the same id must fail.
+ let res = client.try_batch_claim(&batch);
+ assert_eq!(res, Err(Ok(BatchClaimError::ClaimIdAlreadyUsed)));
}
+ /// Supplying a wrong claim_id returns ClaimIdMismatch and does NOT consume state.
#[test]
- fn test_batch_claim_already_settled() {
+ fn test_wrong_claim_id_returns_mismatch_and_does_not_consume_state() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &100);
+ let real_id = make_id(&env, 10);
+ let wrong_id = make_id(&env, 11);
+ client.add_claim(&admin, &claimant, &250, &real_id);
- let mut cv = Vec::new(&env);
- cv.push_back(claimant.clone());
- client.batch_claim(&cv);
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), wrong_id.clone()));
+ let res = client.try_batch_claim(&batch);
+ assert_eq!(res, Err(Ok(BatchClaimError::ClaimIdMismatch)));
- let res = client.try_batch_claim(&cv);
- assert_eq!(res, Err(Ok(BatchClaimError::AlreadySettled)));
+ // real_id must still be unconsumed — claim is still claimable.
+ assert_eq!(client.claim_id_consumed(&real_id), false);
+ assert!(client.has_claim(&claimant));
+ }
+
+ /// Failed validation (ClaimNotFound) does not mutate consumed state.
+ #[test]
+ fn test_claim_not_found_does_not_consume_state() {
+ let env = Env::default();
+ let (_admin, _claimant, client) = setup(&env);
+ let missing = Address::generate(&env);
+ let id = make_id(&env, 3);
+
+ let mut batch = Vec::new(&env);
+ batch.push_back((missing.clone(), id.clone()));
+ let res = client.try_batch_claim(&batch);
+ assert_eq!(res, Err(Ok(BatchClaimError::ClaimNotFound)));
+
+ // Consumed flag was never written — must report false.
+ assert_eq!(client.claim_id_consumed(&id), false);
+ }
+
+ /// AlreadySettled path: does not mutate the consumed tombstone further.
+ #[test]
+ fn test_already_settled_does_not_re_consume() {
+ let env = Env::default();
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 4);
+ client.add_claim(&admin, &claimant, &100, &id);
+
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), id.clone()));
+
+ client.batch_claim(&batch); // first — succeeds, consumes id
+ assert_eq!(client.claim_id_consumed(&id), true);
+
+ // The claim is settled AND id consumed, so next attempt hits
+ // ClaimIdAlreadyUsed (consumed check comes before settled check).
+ let res = client.try_batch_claim(&batch);
+ assert_eq!(res, Err(Ok(BatchClaimError::ClaimIdAlreadyUsed)));
+ }
+
+ // -----------------------------------------------------------------------
+ // claim_id_consumed view
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn test_claim_id_consumed_view_accurate() {
+ let env = Env::default();
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 5);
+
+ // Before add_claim: not known.
+ assert_eq!(client.claim_id_consumed(&id), false);
+
+ client.add_claim(&admin, &claimant, &300, &id);
+ // Registered but not yet consumed.
+ assert_eq!(client.claim_id_consumed(&id), false);
+
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), id.clone()));
+ client.batch_claim(&batch);
+
+ // After successful settlement: consumed.
+ assert_eq!(client.claim_id_consumed(&id), true);
+ }
+
+ // -----------------------------------------------------------------------
+ // Concurrent / same-id only one succeeds
+ // -----------------------------------------------------------------------
+
+ /// Simulate two independent batches carrying the same claim_id. Only the
+ /// first to execute wins; the second is rejected with ClaimIdAlreadyUsed.
+ #[test]
+ fn test_concurrent_same_id_only_one_succeeds() {
+ let env = Env::default();
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 6);
+ client.add_claim(&admin, &claimant, &999, &id);
+
+ let mut batch_a = Vec::new(&env);
+ batch_a.push_back((claimant.clone(), id.clone()));
+ let mut batch_b = Vec::new(&env);
+ batch_b.push_back((claimant.clone(), id.clone()));
+
+ // First batch succeeds.
+ let result_a = client.batch_claim(&batch_a);
+ assert_eq!(result_a, 999);
+
+ // Second batch (same id) is rejected.
+ let result_b = client.try_batch_claim(&batch_b);
+ assert_eq!(result_b, Err(Ok(BatchClaimError::ClaimIdAlreadyUsed)));
+
+ // Consumed flag is permanently set.
+ assert_eq!(client.claim_id_consumed(&id), true);
}
+ // -----------------------------------------------------------------------
+ // Unauthorized callers in batch_claim
+ // -----------------------------------------------------------------------
+
+ /// A claimant whose auth is absent (or wrong) cannot claim someone else's funds.
#[test]
- fn test_batch_claim_not_found() {
+ fn test_batch_claim_requires_claimant_auth() {
let env = Env::default();
- let (_, _, client) = setup(&env);
- let stranger = Address::generate(&env);
- let mut cv = Vec::new(&env);
- cv.push_back(stranger);
- let res = client.try_batch_claim(&cv);
+ env.mock_all_auths(); // still mock — but we test with a claimant who
+ // has no record, simulating the unauthorized path.
+ let admin = Address::generate(&env);
+ let contract_id = env.register(CalloraBatchClaim, ());
+ let client = CalloraBatchClaimClient::new(&env, &contract_id);
+ client.init(&admin);
+
+ let claimant = Address::generate(&env);
+ let id = make_id(&env, 20);
+ client.add_claim(&admin, &claimant, &400, &id);
+
+ // An unrelated address tries to claim using the real claimant's record
+ // but its own identity → ClaimNotFound (no record for intruder).
+ let intruder = Address::generate(&env);
+ let mut batch = Vec::new(&env);
+ batch.push_back((intruder.clone(), id.clone()));
+ let res = client.try_batch_claim(&batch);
assert_eq!(res, Err(Ok(BatchClaimError::ClaimNotFound)));
+
+ // The real claimant's id is still unconsumed.
+ assert_eq!(client.claim_id_consumed(&id), false);
}
// -----------------------------------------------------------------------
- // cancel_claim
+ // cancel_claim authorization
// -----------------------------------------------------------------------
#[test]
- fn test_cancel_claim_success() {
+ fn test_cancel_claim_non_admin_rejected() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &250);
- client.cancel_claim(&admin, &claimant);
- let res = client.try_get_claim(&claimant);
+ let id = make_id(&env, 30);
+ client.add_claim(&admin, &claimant, &100, &id);
+
+ let intruder = Address::generate(&env);
+ let res = client.try_cancel_claim(&intruder, &claimant);
+ assert_eq!(res, Err(Ok(BatchClaimError::Unauthorized)));
+ }
+
+ #[test]
+ fn test_cancel_claim_not_found() {
+ let env = Env::default();
+ let (admin, _claimant, client) = setup(&env);
+ let missing = Address::generate(&env);
+ let res = client.try_cancel_claim(&admin, &missing);
assert_eq!(res, Err(Ok(BatchClaimError::ClaimNotFound)));
}
#[test]
- fn test_cancel_already_settled() {
+ fn test_cancel_claim_already_settled_rejected() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &100);
- let mut cv = Vec::new(&env);
- cv.push_back(claimant.clone());
- client.batch_claim(&cv);
+ let id = make_id(&env, 31);
+ client.add_claim(&admin, &claimant, &100, &id);
+
+ let mut batch = Vec::new(&env);
+ batch.push_back((claimant.clone(), id.clone()));
+ client.batch_claim(&batch);
let res = client.try_cancel_claim(&admin, &claimant);
assert_eq!(res, Err(Ok(BatchClaimError::AlreadySettled)));
}
+ #[test]
+ fn test_cancel_claim_happy_path() {
+ let env = Env::default();
+ let (admin, claimant, client) = setup(&env);
+ let id = make_id(&env, 32);
+ client.add_claim(&admin, &claimant, &100, &id);
+ client.cancel_claim(&admin, &claimant);
+ assert_eq!(client.has_claim(&claimant), false);
+ }
+
// -----------------------------------------------------------------------
- // has_claim
+ // Overflow protection
// -----------------------------------------------------------------------
#[test]
- fn test_has_claim_true_and_false() {
+ fn test_add_claim_accumulation_overflow_rejected() {
let env = Env::default();
let (admin, claimant, client) = setup(&env);
- assert!(!client.has_claim(&claimant));
- client.add_claim(&admin, &claimant, &50);
- assert!(client.has_claim(&claimant));
+ let id1 = make_id(&env, 40);
+ let id2 = make_id(&env, 41);
- let mut cv = Vec::new(&env);
- cv.push_back(claimant.clone());
- client.batch_claim(&cv);
- assert!(!client.has_claim(&claimant));
+ client.add_claim(&admin, &claimant, &i128::MAX, &id1);
+ // Adding any positive value would overflow.
+ let res = client.try_add_claim(&admin, &claimant, &1, &id2);
+ assert_eq!(res, Err(Ok(BatchClaimError::Overflow)));
}
// -----------------------------------------------------------------------
- // total_claims
+ // total_claims and get_admin views
// -----------------------------------------------------------------------
#[test]
fn test_total_claims_increments() {
let env = Env::default();
- let (admin, _, client) = setup(&env);
+ let (admin, claimant, client) = setup(&env);
assert_eq!(client.total_claims(), 0);
- let c1 = Address::generate(&env);
- let c2 = Address::generate(&env);
- client.add_claim(&admin, &c1, &1);
+ client.add_claim(&admin, &claimant, &10, &make_id(&env, 50));
assert_eq!(client.total_claims(), 1);
- client.add_claim(&admin, &c2, &1);
- assert_eq!(client.total_claims(), 2);
- // Adding more to existing claimant does NOT increment total_claims.
- client.add_claim(&admin, &c1, &1);
+ let c2 = Address::generate(&env);
+ client.add_claim(&admin, &c2, &10, &make_id(&env, 51));
assert_eq!(client.total_claims(), 2);
}
- // -----------------------------------------------------------------------
- // TTL bump verification
- // -----------------------------------------------------------------------
-
- /// Verify that calling `get_claim` twice completes without error,
- /// demonstrating that the TTL bump on the hot-read path does not panic
- /// or cause state inconsistencies.
#[test]
- fn test_get_claim_ttl_bump_idempotent() {
+ fn test_views_before_init_return_not_initialized() {
let env = Env::default();
- let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &999);
-
- let rec1 = client.get_claim(&claimant);
- let rec2 = client.get_claim(&claimant);
+ let id = env.register(CalloraBatchClaim, ());
+ let client = CalloraBatchClaimClient::new(&env, &id);
+ assert_eq!(
+ client.try_get_admin(),
+ Err(Ok(BatchClaimError::NotInitialized))
+ );
assert_eq!(
- rec1, rec2,
- "repeated get_claim must return identical records"
+ client.try_total_claims(),
+ Err(Ok(BatchClaimError::NotInitialized))
);
}
- /// Verify that `has_claim` can be called repeatedly (TTL bump is safe and
- /// idempotent).
+ // -----------------------------------------------------------------------
+ // Multi-claimant batch with independent ids
+ // -----------------------------------------------------------------------
+
#[test]
- fn test_has_claim_ttl_bump_idempotent() {
+ fn test_batch_claim_multiple_claimants_different_ids() {
let env = Env::default();
- let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &10);
+ let (admin, c1, client) = setup(&env);
+ let c2 = Address::generate(&env);
+ let id1 = make_id(&env, 60);
+ let id2 = make_id(&env, 61);
- assert!(client.has_claim(&claimant));
- assert!(client.has_claim(&claimant));
- assert!(client.has_claim(&claimant));
+ client.add_claim(&admin, &c1, &300, &id1);
+ client.add_claim(&admin, &c2, &700, &id2);
+
+ let mut batch = Vec::new(&env);
+ batch.push_back((c1.clone(), id1.clone()));
+ batch.push_back((c2.clone(), id2.clone()));
+
+ let total = client.batch_claim(&batch);
+ assert_eq!(total, 1_000);
+
+ assert_eq!(client.claim_id_consumed(&id1), true);
+ assert_eq!(client.claim_id_consumed(&id2), true);
}
- /// Verify that the TTL bump inside `batch_claim` is applied before the
- /// settled-check, so even double-claim errors don't leave the entry
- /// without a freshly-bumped TTL.
+ /// A batch where the second entry fails must not consume the first entry's id
+ /// if the call rolls back — but in Soroban, panics/errors inside a contract
+ /// invocation revert the whole transaction. This test verifies the
+ /// error is surfaced and the overall state is consistent after the revert.
#[test]
- fn test_batch_claim_ttl_bump_on_already_settled_error() {
+ fn test_batch_partial_failure_is_atomic() {
let env = Env::default();
- let (admin, claimant, client) = setup(&env);
- client.add_claim(&admin, &claimant, &100);
+ let (admin, c1, client) = setup(&env);
+ let id1 = make_id(&env, 70);
+ let id_bad = make_id(&env, 71); // no claim registered for c2
- let mut cv = Vec::new(&env);
- cv.push_back(claimant.clone());
- client.batch_claim(&cv);
+ let c2 = Address::generate(&env);
+ client.add_claim(&admin, &c1, &100, &id1);
- // Second batch_claim returns AlreadySettled — but the entry still exists.
- let _ = client.try_batch_claim(&cv);
- // The record should still be retrievable (TTL bump keeps it alive).
- let rec = client.get_claim(&claimant);
- assert!(rec.settled);
+ let mut batch = Vec::new(&env);
+ batch.push_back((c1.clone(), id1.clone()));
+ batch.push_back((c2.clone(), id_bad.clone()));
+
+ // The whole batch fails because c2 has no claim.
+ let res = client.try_batch_claim(&batch);
+ assert_eq!(res, Err(Ok(BatchClaimError::ClaimNotFound)));
+
+ // c1's id should be unconsumed because the transaction reverted.
+ assert_eq!(client.claim_id_consumed(&id1), false);
+ assert!(client.has_claim(&c1));
}
}