Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 23 additions & 46 deletions contracts/batch_claim/docs/storage.md
Original file line number Diff line number Diff line change
@@ -1,78 +1,55 @@
# BatchClaim Contract Storage Documentation

This document outlines the storage keys used by the BatchClaim smart contract, detailing their Soroban storage tiers and the rationale behind their selection in accordance with Soroban best practices.
This document outlines the storage keys used by the `CalloraBatchClaim` smart contract, detailing their Soroban storage tiers and the rationale behind their selection in accordance with Soroban best practices.

## Storage Tiers Overview

- **Instance Storage**: Stored in the contract instance ledger entry. Automatically extended when the contract instance is invoked. Ideal for small, frequently accessed configuration data and global contract state.
- **Persistent Storage**: Stored as independent ledger entries with their own Time-To-Live (TTL). Ideal for user-specific or data-heavy entries that require explicit TTL management.
- **Temporary Storage**: Short-lived entries intended for transient data (e.g., operation locks, nonce tracking).

---

## Storage Keys

### `DataKey::Admin`
### `StorageKey::Admin`
- **Tier**: **Instance**
- **Data Type**: `Address`
- **Rationale**: The administrator address is a core global configuration parameter required for all admin-restricted actions (pause, upgrade, configuration). It must be accessible efficiently across all contract invocations and shares the lifecycle of the contract instance. Using Instance storage guarantees the admin address is always available and cannot be pruned independently of the contract.
- **Rationale**: The administrator address is a core global configuration parameter required for all admin-restricted actions (`add_claim`, `cancel_claim`, `set_reward_token`).

### `DataKey::PendingAdmin`
### `StorageKey::RewardToken`
- **Tier**: **Instance**
- **Data Type**: `Address` (Optional)
- **Rationale**: Used during a secure two-step admin transfer process to hold the nominated successor before they accept the role. Since this is a singleton configuration state tied directly to the contract's administration lifecycle, Instance storage is appropriate. The key is removed from storage once the transfer completes.
- **Data Type**: `Address`
- **Rationale**: Stores the reward token contract address configured at `init_with_token` or `set_reward_token`. Read during `batch_claim` to verify upfront contract balance and transfer each claimant's `pending_amount` after state updates.

### `DataKey::Paused`
### `StorageKey::TotalClaims`
- **Tier**: **Instance**
- **Data Type**: `bool`
- **Rationale**: The circuit-breaker flag guards all batch-claim operations. As a global boolean read on every guarded entrypoint, Instance storage ensures low latency and that the flag survives as long as the contract instance itself. This prevents a paused flag from being evicted while the contract is live.

### `DataKey::ClaimNonce(Address)`
- **Tier**: **Temporary**
- **Data Type**: `u64`
- **Rationale**: A per-address monotonic claim nonce that prevents replay attacks on signed claims. Each nonce is consumed once and then incremented. Temporary storage is chosen because nonces only need to remain available long enough to detect reuse; old nonces can safely be evicted by the network once their TTL expires, reducing state bloat. The contract bumps the TTL on each write to keep the current nonce live.
- **Data Type**: `u32`
- **Rationale**: Tracks the count of active/created claims in instance storage alongside contract configuration.

### `DataKey::ClaimRoot`
### `StorageKey::Claim(Address)`
- **Tier**: **Persistent**
- **Data Type**: `BytesN<32>` (Merkle root hash)
- **Rationale**: The Merkle root represents the canonical set of all currently-valid claims. It is updated infrequently (on admin rotation) but must remain available for the entire validation lifecycle of in-flight claims. Persistent storage ensures the root cannot be dropped without explicit admin action, maintaining the integrity of the claim verification system.
- **Data Type**: `ClaimRecord`
- **Rationale**: Per-claimant pending reward record (`claimant`, `pending_amount`, `settled`, `claim_id`). Bumped by `PERSISTENT_BUMP` on every read and write.

### `DataKey::Claimed(Address, BytesN<32>)`
### `StorageKey::ClaimConsumed(BytesN<32>)`
- **Tier**: **Persistent**
- **Data Type**: `bool`
- **Rationale**: Tracks whether a specific leaf (account + claim ID) has already been redeemed. This prevents double-claiming and must survive as long as the claim window is open. Persistent storage guarantees that the claimed status cannot be arbitrarily evicted, which would otherwise allow replayed claims to succeed.
- **Rationale**: Long-lived replay-protection tombstone indicating whether a `claim_id` has been settled or cancelled. Bumped by `CONSUMED_TOMBSTONE_BUMP` (~6 months) on every touch and via `extend_claim_consumed_ttl`.

### `DataKey::BatchWindow`
- **Tier**: **Instance**
- **Data Type**: `u64` (ledger timestamp)
- **Rationale**: Defines the time window during which a batch of claims may be submitted. As a global configuration parameter checked on every batch operation, Instance storage provides fast access and lifecycle alignment with the contract instance.

### `DataKey::WhitelistMerkleRoot`
### `StorageKey::ClaimIdOwner(BytesN<32>)`
- **Tier**: **Persistent**
- **Data Type**: `BytesN<32>` (Merkle root hash)
- **Rationale**: An optional Merkle root constraining which accounts may participate in a given batch claim round. Updated per round by the admin. Persistent storage is required because the whitelist root must remain available for the duration of the claim round, independent of contract instance activity.
- **Data Type**: `Address`
- **Rationale**: Permanent reservation binding a `claim_id` to the first claimant it was issued to, preventing cross-claimant identifier reuse.

---

## TTL Management Strategy

| Key | Tier | TTL Strategy |
|-----|------|-------------|
| `Admin` | Instance | Managed by contract instance lifecycle |
| `PendingAdmin` | Instance | Managed by contract instance lifecycle |
| `Paused` | Instance | Managed by contract instance lifecycle |
| `BatchWindow` | Instance | Managed by contract instance lifecycle |
| `ClaimRoot` | Persistent | Bumped on every `set_claim_root` call |
| `WhitelistMerkleRoot` | Persistent | Bumped on every rotation |
| `Claimed(Address, BytesN<32>)` | Persistent | Bumped on every claim |
| `ClaimNonce(Address)` | Temporary | Bumped on every write, moderate TTL (e.g., 7 days) |

---

## Design Rationale

The tier selection balances three constraints:

1. **Correctness** — Claimed status and claim roots *must* survive until the claim window closes; using Temporary would risk premature eviction and double-claim vulnerabilities. Hence Instance and Persistent.
2. **Cost efficiency** — Transient data like per-address nonces that are consumed and rotated use Temporary storage to reduce state fees.
3. **Access frequency** — Admin config and the pause flag are read on nearly every entrypoint, so Instance storage minimizes read latency and avoids TTL management overhead.
| `Admin` | Instance | Extended by `BUMP_AMOUNT` on mutating calls |
| `RewardToken` | Instance | Extended by `BUMP_AMOUNT` on mutating calls |
| `TotalClaims` | Instance | Extended by `BUMP_AMOUNT` on mutating calls |
| `Claim(Address)` | Persistent | Extended by `PERSISTENT_BUMP` on reads and writes |
| `ClaimConsumed(BytesN<32>)` | Persistent | Extended by `CONSUMED_TOMBSTONE_BUMP` on reads, writes, and keep-alive calls |
| `ClaimIdOwner(BytesN<32>)` | Persistent | Extended by `PERSISTENT_BUMP` on reads and writes |
3 changes: 3 additions & 0 deletions contracts/batch_claim/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use soroban_sdk::contracterror;
/// | 10 | InvalidClaimId | Identifier is malformed (all-zero) |
/// | 11 | BatchTooLarge | Batch exceeds `MAX_PENDING_AMOUNTS` entries |
/// | 12 | BatchEmpty | Batch input must not be empty |
/// | 13 | InsufficientBalance | Contract token balance is below the batch total |
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
Expand Down Expand Up @@ -61,4 +62,6 @@ pub enum BatchClaimError {
/// so client bugs surface as errors instead of wasting fees on a no-op
/// settlement.
BatchEmpty = 12,
/// Contract reward-token balance is below the requested batch settlement total (code 13).
InsufficientBalance = 13,
}
203 changes: 183 additions & 20 deletions contracts/batch_claim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ pub mod events;

pub use errors::BatchClaimError;

use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Vec};
use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, Vec};

// ---------------------------------------------------------------------------
// Constants
Expand Down Expand Up @@ -143,6 +143,8 @@ pub const MAX_PENDING_AMOUNTS: u32 = 50;
pub enum StorageKey {
/// Contract admin.
Admin,
/// Optional configured reward token contract address used to pay settled claims.
RewardToken,
/// Per-claimant claim record.
Claim(Address),
/// Monotonically-increasing count of total claims ever created.
Expand Down Expand Up @@ -220,6 +222,55 @@ impl CalloraBatchClaim {
Ok(())
}

/// Initialize the batch-claim contract with an admin and a reward token.
///
/// # Errors
/// Returns [`BatchClaimError::AlreadyInitialized`] if called more than once.
pub fn init_with_token(
env: Env,
admin: Address,
reward_token: Address,
) -> Result<(), BatchClaimError> {
admin.require_auth();
if env.storage().instance().has(&StorageKey::Admin) {
return Err(BatchClaimError::AlreadyInitialized);
}
let inst = env.storage().instance();
inst.set(&StorageKey::Admin, &admin);
inst.set(&StorageKey::RewardToken, &reward_token);
inst.set(&StorageKey::TotalClaims, &0u32);
Self::bump_instance(&env);
env.events()
.publish((events::event_init(&env),), (admin, reward_token));
Ok(())
}

/// Configure or update the reward token used to pay settled claims.
///
/// Only the admin may call.
pub fn set_reward_token(
env: Env,
caller: Address,
reward_token: Address,
) -> Result<(), BatchClaimError> {
caller.require_auth();
let admin = Self::require_admin(&env)?;
if caller != admin {
return Err(BatchClaimError::Unauthorized);
}
env.storage()
.instance()
.set(&StorageKey::RewardToken, &reward_token);
Self::bump_instance(&env);
Ok(())
}

/// Return the configured reward token address, if set.
pub fn get_reward_token(env: Env) -> Result<Option<Address>, BatchClaimError> {
Self::require_admin(&env)?;
Ok(env.storage().instance().get(&StorageKey::RewardToken))
}

// -----------------------------------------------------------------------
// Mutating entrypoints
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -442,6 +493,7 @@ impl CalloraBatchClaim {
return Err(BatchClaimError::BatchTooLarge);
}
let mut total_claimed: i128 = 0;
let mut validated: Vec<(Address, BytesN<32>, ClaimRecord)> = Vec::new(&env);

for (claimant, claim_id) in claimants.iter() {
claimant.require_auth();
Expand All @@ -455,24 +507,18 @@ impl CalloraBatchClaim {
.extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);
}

let mut record: ClaimRecord = env
let record: ClaimRecord = env
.storage()
.persistent()
.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,
Expand All @@ -490,22 +536,37 @@ impl CalloraBatchClaim {
}

// 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.
// Reject duplicate claim_ids within the same batch before any state change.
for (_, seen_id, _) in validated.iter() {
if seen_id == claim_id {
return Err(BatchClaimError::ClaimIdAlreadyUsed);
}
}

total_claimed = total_claimed
.checked_add(record.pending_amount)
.ok_or(BatchClaimError::Overflow)?;
validated.push_back((claimant, claim_id, record));
}

let reward_token: Option<Address> = env.storage().instance().get(&StorageKey::RewardToken);
let contract_addr = env.current_contract_address();
if let Some(ref token_addr) = reward_token {
let token_client = token::Client::new(&env, token_addr);
if token_client.balance(&contract_addr) < total_claimed {
return Err(BatchClaimError::InsufficientBalance);
}
}

for (claimant, claim_id, mut record) in validated.iter() {
let key = StorageKey::Claim(claimant.clone());
let consumed_key = StorageKey::ClaimConsumed(claim_id.clone());

// 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,
Expand All @@ -520,15 +581,33 @@ impl CalloraBatchClaim {
.persistent()
.extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP);

if let Some(ref token_addr) = reward_token {
token::Client::new(&env, token_addr).transfer(
&contract_addr,
&claimant,
&record.pending_amount,
);
}

// 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),
);
// Emit settled event with claimant + amount (and reward token as topic when configured).
match reward_token {
Some(ref token_addr) => {
env.events().publish(
(events::event_claims_settled(&env), token_addr.clone()),
(claimant.clone(), record.pending_amount),
);
}
None => {
env.events().publish(
(events::event_claims_settled(&env),),
(claimant.clone(), record.pending_amount),
);
}
}
}

Self::bump_instance(&env);
Expand Down Expand Up @@ -1804,4 +1883,88 @@ mod tests {
assert_eq!(client.claim_id_consumed(&id), true);
}
}

#[test]
fn test_batch_claim_transfers_reward_tokens_to_each_claimant() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let token_addr = env
.register_stellar_asset_contract_v2(admin.clone())
.address();
let token_client = token::Client::new(&env, &token_addr);
let token_admin = token::StellarAssetClient::new(&env, &token_addr);

let contract_id = env.register(CalloraBatchClaim, ());
let client = CalloraBatchClaimClient::new(&env, &contract_id);
client.init_with_token(&admin, &token_addr);
assert_eq!(client.get_reward_token(), Some(token_addr.clone()));

let c1 = Address::generate(&env);
let c2 = Address::generate(&env);
let id1 = make_id(&env, 91);
let id2 = make_id(&env, 92);

client.add_claim(&admin, &c1, &350, &id1);
client.add_claim(&admin, &c2, &650, &id2);

token_admin.mint(&contract_id, &1_500);

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!(token_client.balance(&c1), 350);
assert_eq!(token_client.balance(&c2), 650);
assert_eq!(token_client.balance(&contract_id), 500);
assert!(client.get_claim(&c1).settled);
assert!(client.get_claim(&c2).settled);
}

#[test]
fn test_batch_claim_insufficient_contract_balance_fails_without_state_change() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let token_addr = env
.register_stellar_asset_contract_v2(admin.clone())
.address();
let token_client = token::Client::new(&env, &token_addr);
let token_admin = token::StellarAssetClient::new(&env, &token_addr);

let contract_id = env.register(CalloraBatchClaim, ());
let client = CalloraBatchClaimClient::new(&env, &contract_id);
client.init_with_token(&admin, &token_addr);

let c1 = Address::generate(&env);
let c2 = Address::generate(&env);
let id1 = make_id(&env, 93);
let id2 = make_id(&env, 94);

client.add_claim(&admin, &c1, &400, &id1);
client.add_claim(&admin, &c2, &700, &id2);

// Fund less than total_claimed (1_100)
token_admin.mint(&contract_id, &1_000);

let mut batch = Vec::new(&env);
batch.push_back((c1.clone(), id1.clone()));
batch.push_back((c2.clone(), id2.clone()));

let res = client.try_batch_claim(&batch);
assert_eq!(res, Err(Ok(BatchClaimError::InsufficientBalance)));

// Neither claim is consumed or settled, and no tokens moved.
assert!(!client.claim_id_consumed(&id1));
assert!(!client.claim_id_consumed(&id2));
assert!(!client.get_claim(&c1).settled);
assert!(!client.get_claim(&c2).settled);
assert_eq!(token_client.balance(&c1), 0);
assert_eq!(token_client.balance(&c2), 0);
assert_eq!(token_client.balance(&contract_id), 1_000);
}
}