diff --git a/contracts/vault/STORAGE.md b/contracts/vault/STORAGE.md
index 3f14196..918e0cc 100644
--- a/contracts/vault/STORAGE.md
+++ b/contracts/vault/STORAGE.md
@@ -15,7 +15,38 @@ Ledger rate assumption: **17 280 ledgers/day** (5-second close time on Stellar m
Entrypoints that bump TTL: `init`, `deposit`, `deduct`, `batch_deduct`, `withdraw`, `withdraw_to`.
-Pure view functions (`get_meta`, `balance`, `get_admin`, `get_usdc_token`, `get_settlement`, `get_revenue_pool`, `get_contract_addresses`, `is_paused`, `is_authorized_depositor`, `get_metadata`, `get_max_deduct`, `get_allowed_depositors`) do **not** bump the TTL — they are read-only and incur no write cost.
+Pure view functions (`get_meta`, `balance`, `get_admin`, `get_usdc_token`, `get_settlement`, `get_revenue_pool`, `get_contract_addresses`, `is_paused`, `is_authorized_depositor`, `get_metadata`, `get_max_deduct`, `get_allowed_depositors`, `is_request_processed`) do **not** bump the TTL — they are read-only and incur no write cost.
+
+## Processed-Request Idempotency Storage (Temporary)
+
+Idempotency markers for `deduct` and `batch_deduct` live in **temporary storage** — a separate Soroban storage tier that is automatically archived when its TTL expires, without requiring explicit deletion.
+
+| Constant | Value | Rationale |
+|---|---|---|
+| `REQUEST_ID_BUMP_THRESHOLD` | `17_280 * 7` (~7 days) | Bump is triggered when fewer than 7 days of TTL remain |
+| `REQUEST_ID_BUMP_AMOUNT` | `17_280 * 30` (~30 days) | Each bump extends the TTL to 30 days from the current ledger |
+
+### Key: `StorageKey::ProcessedRequest(Symbol)`
+
+- **Storage tier:** Temporary (auto-archived after TTL expires)
+- **Value type:** `bool` (`true`); presence of the key is the authoritative signal
+- **Written by:** `deduct` and `batch_deduct` on every **successful** deduction where `request_id` is `Some(id)`
+- **Read by:** `deduct`, `batch_deduct` (duplicate check), `is_request_processed` (view)
+- **TTL:** Set to `REQUEST_ID_BUMP_AMOUNT` (~30 days) on write; bumped on every successful re-use within the threshold window
+
+### Retention Policy
+
+| Scenario | Behaviour |
+|----------|-----------|
+| First deduct with `Some(id)` | Marker written; TTL set to ~30 days |
+| Retry within retention window | `DuplicateRequestId` error returned; no state change |
+| Retry after TTL expires | Marker archived; deduct treated as new (succeeds) |
+| Deduct with `None` | No marker written; no deduplication |
+| Failed deduct (any error) | No marker written; id remains reusable |
+
+> **Caller guidance:** Backends should treat `VaultError::DuplicateRequestId` as a successful no-op — the original deduction already went through. Do not retry with a new `request_id` for the same logical operation.
+
+> **Retention window:** The 30-day window is a best-effort guarantee. After expiry the marker is archived and the `request_id` can be reused. Callers requiring longer deduplication windows must implement their own off-chain tracking.
## Storage Overview
@@ -28,29 +59,39 @@ The contract defines the following storage keys:
```rust
#[contracttype]
pub enum StorageKey {
- Meta, // VaultMeta
- AllowedDepositors, // Vec
+ MetaKey, // VaultMeta
Admin, // Address
UsdcToken, // Address
- Settlement, // Option
+ Settlement, // Address
RevenuePool, // Option
MaxDeduct, // i128
+ Paused, // bool
Metadata(String), // String (offering metadata by offering_id)
+ PendingOwner, // Address
+ PendingAdmin, // Address
+ DepositorList, // Vec
+ ContractVersion, // BytesN<32>
+ ProcessedRequest(Symbol), // bool — temporary storage, idempotency marker
}
```
### Storage Keys Table
-| Key Variant | Value Type | Description | Usage | Access |
-|-------------|-----------|-------------|-------|--------|
-| `Meta` | `VaultMeta` | Primary vault metadata including owner, balance, authorized_caller, and min_deposit | Core vault state | `get_meta()`, updated by deposit/deduct/withdraw operations |
-| `AllowedDepositors` | `Vec` | List of addresses allowed to deposit into the vault | Access control for deposits | `set_allowed_depositor()`, readable via `is_authorized_depositor()` |
-| `Admin` | `Address` | Administrator address authorized to call `distribute()` and `set_admin()` | Access control for distributions | `get_admin()`, `set_admin()` (admin-only) |
-| `UsdcToken` | `Address` | USDC token contract address | Token transfers for deposits, deducts, distributions | Set during `init()`, used by token operations |
-| `Settlement` | `Option` | Settlement contract address; receives USDC on deduct operations | Deduct routing (priority over RevenuePool) | `set_settlement()`, `get_settlement()` (admin-only write, public read) |
-| `RevenuePool` | `Option` | Revenue pool contract address; receives USDC on deduct if Settlement is not set | Deduct routing (fallback) | `set_revenue_pool()`, `get_revenue_pool()` (admin-only write, public read) |
-| `MaxDeduct` | `i128` | Maximum USDC amount per single deduct operation | Deduct limit enforcement | Set during `init()`, read by `deduct()` and `batch_deduct()` |
-| `Metadata(offering_id)` | `String` | Off-chain metadata reference (IPFS CID or URI) for a specific offering | Offering metadata | `set_metadata()`, `get_metadata()`, `update_metadata()` (owner-only) |
+| Key Variant | Storage Tier | Value Type | Description | Access |
+|-------------|-------------|-----------|-------------|--------|
+| `MetaKey` | Instance | `VaultMeta` | Owner, balance, authorized_caller, min_deposit | `get_meta()`, updated by deposit/deduct/withdraw |
+| `Admin` | Instance | `Address` | Administrator address | `get_admin()`, `set_admin()` |
+| `UsdcToken` | Instance | `Address` | USDC token contract address | Set during `init()` |
+| `Settlement` | Instance | `Address` | Settlement contract; receives USDC on deduct | `set_settlement()`, `get_settlement()` |
+| `RevenuePool` | Instance | `Option` | Revenue pool address (informational) | `set_revenue_pool()`, `get_revenue_pool()` |
+| `MaxDeduct` | Instance | `i128` | Maximum USDC per single deduct | Set during `init()`, read by `deduct()` / `batch_deduct()` |
+| `Paused` | Instance | `bool` | Circuit-breaker flag | `pause()`, `unpause()`, `is_paused()` |
+| `Metadata(String)` | Instance | `String` | Per-offering metadata (IPFS CID / URI) | `set_metadata()`, `get_metadata()`, `update_metadata()` |
+| `PendingOwner` | Instance | `Address` | Two-step ownership transfer nominee | `transfer_ownership()`, `accept_ownership()` |
+| `PendingAdmin` | Instance | `Address` | Two-step admin transfer nominee | `set_admin()`, `accept_admin()` |
+| `DepositorList` | Instance | `Vec` | Allowed depositor addresses | `set_allowed_depositor()`, `get_allowed_depositors()` |
+| `ContractVersion` | Instance | `BytesN<32>` | WASM hash set by `upgrade()` | `upgrade()`, `version()` |
+| `ProcessedRequest(Symbol)` | **Temporary** | `bool` | Idempotency marker for a processed deduct `request_id` | Written by `deduct()` / `batch_deduct()`; read by `is_request_processed()` |
## Data Structures
@@ -103,13 +144,13 @@ Sets up the vault with initial state:
| Operation | Reads | Writes | Authorization |
|-----------|-------|--------|-----------------|
-| `deposit(amount)` | Meta, AllowedDepositors | Meta (balance += amount) | Owner or AllowedDepositor |
-| `deduct(amount, request_id)` | Meta, MaxDeduct, Settlement/RevenuePool | Meta (balance -= amount); transfers USDC | Owner or authorized_caller |
-| `batch_deduct(items)` | Meta, MaxDeduct, Settlement/RevenuePool | Meta (balance -= total); transfers USDC | Owner or authorized_caller |
-| `withdraw(amount)` | Meta, UsdcToken | Meta (balance -= amount); transfers USDC to owner | Owner only |
-| `withdraw_to(to, amount)` | Meta, UsdcToken | Meta (balance -= amount); transfers USDC to `to` | Owner only |
-| `balance()` | Meta | — | Public read |
-| `transfer_ownership(new_owner)` | Meta | Meta (owner = new_owner) | Owner only |
+| `deposit(amount)` | MetaKey, DepositorList | MetaKey (balance += amount) | Owner or AllowedDepositor |
+| `deduct(amount, request_id)` | MetaKey, MaxDeduct, Settlement, ProcessedRequest(id)? | MetaKey (balance -= amount); ProcessedRequest(id) if Some; transfers USDC | Owner or authorized_caller |
+| `batch_deduct(items)` | MetaKey, MaxDeduct, Settlement, ProcessedRequest(id)? per item | MetaKey (balance -= total); ProcessedRequest(id) per Some item; transfers USDC | Owner or authorized_caller |
+| `withdraw(amount)` | MetaKey, UsdcToken | MetaKey (balance -= amount); transfers USDC to owner | Owner only |
+| `withdraw_to(to, amount)` | MetaKey, UsdcToken | MetaKey (balance -= amount); transfers USDC to `to` | Owner only |
+| `balance()` | MetaKey | — | Public read |
+| `transfer_ownership(new_owner)` | MetaKey | PendingOwner | Owner only |
### Admin Operations
@@ -301,6 +342,7 @@ Monitor storage-related events:
|---------|--------|
| 1.0 | Initial `StorageKey` enum with `Meta`, `AllowedDepositors`, `Admin`, `UsdcToken`, `Settlement`, `RevenuePool`, `MaxDeduct`, `Metadata(String)` |
| 1.1 | Renamed `StorageKey` → `DataKey`; added doc comments to all variants; removed stale `// Replaced by StorageKey enum variants` comment; updated STORAGE.md |
+| 1.2 | Added `StorageKey::ProcessedRequest(Symbol)` in **temporary storage** for `request_id` idempotency in `deduct` and `batch_deduct`. Added `VaultError::DuplicateRequestId` (code 28). Added `is_request_processed(request_id)` view. TTL: threshold ~7 days, bump to ~30 days. |
## Canonical Storage Keys
@@ -308,21 +350,24 @@ All storage is accessed via `StorageKey` enum.
### Keys
-| Key | Description |
-|-----|------------|
-| Meta | Vault metadata |
-| DepositorList | Authorized depositors |
-| Admin | Admin address |
-| UsdcToken | Token contract |
-| Settlement | Settlement contract |
-| RevenuePool | Revenue pool |
-| MaxDeduct | Deduct cap |
-| Paused | Circuit breaker |
-| Metadata(String) | Offering metadata |
-| PendingOwner | Ownership transfer |
-| PendingAdmin | Admin transfer |
+| Key | Storage Tier | Description |
+|-----|-------------|------------|
+| `MetaKey` | Instance | Vault metadata (owner, balance, authorized_caller, min_deposit) |
+| `DepositorList` | Instance | Authorized depositors |
+| `Admin` | Instance | Admin address |
+| `UsdcToken` | Instance | Token contract |
+| `Settlement` | Instance | Settlement contract |
+| `RevenuePool` | Instance | Revenue pool |
+| `MaxDeduct` | Instance | Deduct cap |
+| `Paused` | Instance | Circuit breaker |
+| `Metadata(String)` | Instance | Offering metadata |
+| `PendingOwner` | Instance | Ownership transfer nominee |
+| `PendingAdmin` | Instance | Admin transfer nominee |
+| `ContractVersion` | Instance | WASM hash (set by `upgrade()`) |
+| `ProcessedRequest(Symbol)` | **Temporary** | Idempotency marker; auto-expires after ~30 days |
### Migration
- Removes deprecated `AllowedDepositors`
-- Ensures Admin fallback from Meta.owner
\ No newline at end of file
+- Ensures Admin fallback from Meta.owner
+- `ProcessedRequest` uses temporary storage — no manual cleanup required; markers expire automatically
\ No newline at end of file
diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs
index bfbef2f..6e4b9f7 100644
--- a/contracts/vault/src/lib.rs
+++ b/contracts/vault/src/lib.rs
@@ -9,8 +9,30 @@
/// - Owner withdrawals are ALLOWED (emergency recovery)
/// - Admin distribute is ALLOWED (emergency recovery of untracked surplus)
/// - Admin/owner configuration functions remain available
+///
+/// ## Request-ID Idempotency
+///
+/// `deduct` and `batch_deduct` accept an optional `request_id: Option`.
+/// When `Some(id)` is supplied the contract persists a processed-request marker
+/// in **temporary storage** and rejects any subsequent call that carries the same
+/// `request_id`, returning `VaultError::DuplicateRequestId`.
+///
+/// This gives safe **at-least-once retry** semantics: a backend can replay a
+/// failed transaction with the same `request_id` and the contract will either
+/// succeed (first time) or return a deterministic error (duplicate).
+///
+/// When `request_id` is `None` no deduplication is performed; the call is
+/// treated as a fire-and-forget deduction with no idempotency guarantee.
+///
+/// ### Retention / TTL
+/// Processed-request markers live in temporary storage and are bumped to
+/// `REQUEST_ID_BUMP_AMOUNT` ledgers on every successful deduct. The threshold
+/// for triggering a bump is `REQUEST_ID_BUMP_THRESHOLD`. After the TTL expires
+/// the marker is archived and a previously-seen `request_id` can be reused —
+/// callers must not rely on deduplication beyond the retention window.
use soroban_sdk::{
- contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec,
+ contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, String,
+ Symbol, Vec,
};
/// Typed error codes for the Callora Vault contract.
@@ -75,6 +97,13 @@ pub enum VaultError {
OfferingIdTooLong = 26,
/// Metadata exceeds maximum length (code 27).
MetadataTooLong = 27,
+ /// A deduct with this request_id has already been processed (code 28).
+ ///
+ /// Returned when `request_id` is `Some(id)` and a successful deduct with
+ /// the same `id` was recorded within the retention window. The caller
+ /// should treat this as a successful no-op: the original deduction already
+ /// went through.
+ DuplicateRequestId = 28,
}
#[contracttype]
@@ -118,6 +147,12 @@ pub enum StorageKey {
DepositorList,
/// Contract version marker (WASM hash) set by `upgrade`.
ContractVersion,
+ /// Idempotency marker for a processed deduct request.
+ ///
+ /// Stored in **temporary storage** so it expires automatically after
+ /// `REQUEST_ID_BUMP_AMOUNT` ledgers. The value is `true` (a `bool`);
+ /// presence of the key is the authoritative signal.
+ ProcessedRequest(Symbol),
}
pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX;
@@ -131,6 +166,12 @@ pub const MAX_OFFERING_ID_LEN: u32 = 64;
pub const INSTANCE_BUMP_THRESHOLD: u32 = 17_280 * 30; // ~30 days
pub const INSTANCE_BUMP_AMOUNT: u32 = 17_280 * 60; // ~60 days
+// Processed-request idempotency markers live in temporary storage.
+// Bump when fewer than 7 days remain; extend to 30 days.
+// After the TTL expires the marker is archived and the request_id can be reused.
+pub const REQUEST_ID_BUMP_THRESHOLD: u32 = 17_280 * 7; // ~7 days
+pub const REQUEST_ID_BUMP_AMOUNT: u32 = 17_280 * 30; // ~30 days
+
#[contract]
pub struct CalloraVault;
@@ -533,6 +574,14 @@ impl CalloraVault {
/// - `amount` must be positive and <= `max_deduct`.
/// - `caller` must be the owner or `authorized_caller`.
/// - Vault balance must cover `amount`.
+ ///
+ /// # Idempotency
+ /// When `request_id` is `Some(id)`, the contract checks whether `id` has
+ /// already been processed. If so, `VaultError::DuplicateRequestId` is
+ /// returned immediately — no funds are moved. On first success the marker
+ /// is persisted in temporary storage for `REQUEST_ID_BUMP_AMOUNT` ledgers.
+ ///
+ /// When `request_id` is `None`, no deduplication is performed.
pub fn deduct(
env: Env,
caller: Address,
@@ -549,6 +598,10 @@ impl CalloraVault {
if amount > max_d {
return Err(VaultError::ExceedsMaxDeduct);
}
+ // Idempotency check — must happen before any state mutation.
+ if let Some(ref rid) = request_id {
+ Self::require_not_duplicate(&env, rid)?;
+ }
let mut meta = Self::get_meta(env.clone())?;
if meta.balance < amount {
return Err(VaultError::InsufficientBalance);
@@ -562,6 +615,10 @@ impl CalloraVault {
env.storage()
.instance()
.extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT);
+ // Mark request_id as processed after successful state update.
+ if let Some(ref rid) = request_id {
+ Self::mark_request_processed(&env, rid);
+ }
let ut: Address = env
.storage()
.instance()
@@ -580,6 +637,15 @@ impl CalloraVault {
///
/// Full-batch validation completes before any state write or transfer.
/// If any item fails validation, the entire batch reverts with no partial effects.
+ ///
+ /// # Idempotency
+ /// For each item where `request_id` is `Some(id)`, the contract checks for
+ /// duplicates before processing the batch. If any `id` in the batch has
+ /// already been processed, `VaultError::DuplicateRequestId` is returned and
+ /// the entire batch is rejected atomically. On success, all `Some` ids in
+ /// the batch are marked as processed.
+ ///
+ /// Items with `request_id = None` are not deduplicated.
pub fn batch_deduct(
env: Env,
caller: Address,
@@ -599,6 +665,9 @@ impl CalloraVault {
let mut meta = Self::get_meta(env.clone())?;
let mut running = meta.balance;
let mut total: i128 = 0;
+ // Collect ids seen within this batch to catch intra-batch duplicates.
+ let mut seen_in_batch: Vec = Vec::new(&env);
+ // Full validation pass — no state writes yet.
for item in items.iter() {
if item.amount <= 0 {
return Err(VaultError::AmountNotPositive);
@@ -609,6 +678,15 @@ impl CalloraVault {
if running < item.amount {
return Err(VaultError::InsufficientBalance);
}
+ // Idempotency check per item — before any state mutation.
+ // Also catches intra-batch duplicates (two items with the same new id).
+ if let Some(ref rid) = item.request_id {
+ Self::require_not_duplicate(&env, rid)?;
+ if seen_in_batch.contains(rid) {
+ return Err(VaultError::DuplicateRequestId);
+ }
+ seen_in_batch.push_back(rid.clone());
+ }
running = running.checked_sub(item.amount).ok_or(VaultError::Overflow)?;
total = total.checked_add(item.amount).ok_or(VaultError::Overflow)?;
}
@@ -618,6 +696,12 @@ impl CalloraVault {
env.storage()
.instance()
.extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT);
+ // Mark all request_ids as processed after successful state update.
+ for item in items.iter() {
+ if let Some(ref rid) = item.request_id {
+ Self::mark_request_processed(&env, rid);
+ }
+ }
let ut: Address = env
.storage()
.instance()
@@ -945,7 +1029,36 @@ impl CalloraVault {
Ok(())
}
- pub fn get_allowed_depositors(env: Env) -> Vec {
+ /// Return `true` if `request_id` has already been processed (marker present
+ /// in temporary storage and not yet expired).
+ pub fn is_request_processed(env: Env, request_id: Symbol) -> bool {
+ env.storage()
+ .temporary()
+ .has(&StorageKey::ProcessedRequest(request_id))
+ }
+
+ /// Check that `request_id` has NOT been processed yet.
+ /// Returns `VaultError::DuplicateRequestId` if the marker exists.
+ fn require_not_duplicate(env: &Env, request_id: &Symbol) -> Result<(), VaultError> {
+ if env
+ .storage()
+ .temporary()
+ .has(&StorageKey::ProcessedRequest(request_id.clone()))
+ {
+ return Err(VaultError::DuplicateRequestId);
+ }
+ Ok(())
+ }
+
+ /// Persist a processed-request marker in temporary storage and set its TTL.
+ fn mark_request_processed(env: &Env, request_id: &Symbol) {
+ let key = StorageKey::ProcessedRequest(request_id.clone());
+ env.storage().temporary().set(&key, &true);
+ env.storage()
+ .temporary()
+ .extend_ttl(&key, REQUEST_ID_BUMP_THRESHOLD, REQUEST_ID_BUMP_AMOUNT);
+ }
+
fn transfer_funds(env: &Env, usdc_token: &Address, to: &Address, amount: i128) {
token::Client::new(env, usdc_token).transfer(&env.current_contract_address(), to, &amount);
}
@@ -1018,3 +1131,25 @@ impl CalloraVault {
.unwrap_or(Vec::new(&env))
}
}
+
+// ---------------------------------------------------------------------------
+// Test modules
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod test;
+
+#[cfg(test)]
+mod test_init_hardening;
+
+#[cfg(test)]
+mod test_setter_validation;
+
+#[cfg(test)]
+mod test_settler_validation;
+
+#[cfg(test)]
+mod test_views;
+
+#[cfg(test)]
+mod test_idempotency;
diff --git a/contracts/vault/src/test_idempotency.rs b/contracts/vault/src/test_idempotency.rs
new file mode 100644
index 0000000..6427a23
--- /dev/null
+++ b/contracts/vault/src/test_idempotency.rs
@@ -0,0 +1,409 @@
+/// Tests for request_id idempotency in `deduct` and `batch_deduct`.
+///
+/// # Coverage
+/// - Duplicate `Some(request_id)` is rejected with `DuplicateRequestId`.
+/// - Distinct `request_id` values each succeed independently.
+/// - `None` request_id is never deduplicated (fire-and-forget).
+/// - `batch_deduct` rejects a batch containing a duplicate id atomically.
+/// - `batch_deduct` rejects a batch where two items share the same new id.
+/// - `is_request_processed` view reflects processed state correctly.
+/// - Failed deducts (insufficient balance, paused) do NOT mark the id.
+extern crate std;
+
+use soroban_sdk::testutils::Address as _;
+use soroban_sdk::{token, Address, Env, Symbol};
+
+use super::*;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn create_usdc<'a>(
+ env: &'a Env,
+ admin: &Address,
+) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) {
+ let ca = env.register_stellar_asset_contract_v2(admin.clone());
+ let addr = ca.address();
+ (
+ addr.clone(),
+ token::Client::new(env, &addr),
+ token::StellarAssetClient::new(env, &addr),
+ )
+}
+
+fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) {
+ let addr = env.register(CalloraVault, ());
+ let client = CalloraVaultClient::new(env, &addr);
+ (addr, client)
+}
+
+/// Set up a vault with `balance` USDC, a settlement address, and return
+/// `(vault_addr, client, settlement_addr, owner)`.
+fn setup_vault(env: &Env, balance: i128) -> (Address, CalloraVaultClient<'_>, Address, Address) {
+ env.mock_all_auths();
+ let owner = Address::generate(env);
+ let (vault_addr, client) = create_vault(env);
+ let (usdc, _, usdc_admin) = create_usdc(env, &owner);
+ usdc_admin.mint(&vault_addr, &balance);
+ client.init(&owner, &usdc, &Some(balance), &None, &None, &None, &None);
+ let settlement = Address::generate(env);
+ client.set_settlement(&owner, &settlement);
+ (vault_addr, client, settlement, owner)
+}
+
+// ---------------------------------------------------------------------------
+// deduct — single call idempotency
+// ---------------------------------------------------------------------------
+
+/// A `Some(request_id)` deduct succeeds on first call and is rejected on retry.
+#[test]
+fn deduct_duplicate_request_id_rejected() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid = Symbol::new(&env, "req_001");
+
+ // First call — must succeed.
+ let remaining = client.deduct(&owner, &100, &Some(rid.clone()));
+ assert_eq!(remaining, 900);
+
+ // Second call with same request_id — must be rejected.
+ let result = client.try_deduct(&owner, &100, &Some(rid.clone()));
+ assert!(
+ result.is_err(),
+ "duplicate request_id must be rejected"
+ );
+
+ // Balance must be unchanged after the rejected retry.
+ assert_eq!(client.balance(), 900, "balance must not change on duplicate");
+}
+
+/// Two distinct `request_id` values each succeed independently.
+#[test]
+fn deduct_distinct_request_ids_both_succeed() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid_a = Symbol::new(&env, "req_a");
+ let rid_b = Symbol::new(&env, "req_b");
+
+ let after_a = client.deduct(&owner, &100, &Some(rid_a.clone()));
+ assert_eq!(after_a, 900);
+
+ let after_b = client.deduct(&owner, &200, &Some(rid_b.clone()));
+ assert_eq!(after_b, 700);
+
+ assert_eq!(client.balance(), 700);
+}
+
+/// `None` request_id is never deduplicated — multiple calls all go through.
+#[test]
+fn deduct_none_request_id_not_deduplicated() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ // Three calls with None — all must succeed.
+ assert_eq!(client.deduct(&owner, &100, &None), 900);
+ assert_eq!(client.deduct(&owner, &100, &None), 800);
+ assert_eq!(client.deduct(&owner, &100, &None), 700);
+ assert_eq!(client.balance(), 700);
+}
+
+/// A failed deduct (insufficient balance) must NOT mark the request_id as processed.
+#[test]
+fn deduct_failed_due_to_insufficient_balance_does_not_mark_id() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 50);
+
+ let rid = Symbol::new(&env, "req_fail");
+
+ // Attempt to deduct more than the balance — must fail.
+ let result = client.try_deduct(&owner, &100, &Some(rid.clone()));
+ assert!(result.is_err(), "expected insufficient balance error");
+
+ // The id must NOT be marked — a retry with sufficient balance should succeed.
+ // Top up the vault first.
+ // (We can't deposit here without a depositor setup, so we verify via is_request_processed.)
+ assert!(
+ !client.is_request_processed(&rid),
+ "failed deduct must not mark request_id"
+ );
+}
+
+/// A failed deduct (vault paused) must NOT mark the request_id as processed.
+#[test]
+fn deduct_failed_due_to_paused_does_not_mark_id() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 500);
+
+ let rid = Symbol::new(&env, "req_paused");
+
+ client.pause(&owner);
+ let result = client.try_deduct(&owner, &100, &Some(rid.clone()));
+ assert!(result.is_err(), "expected paused error");
+
+ assert!(
+ !client.is_request_processed(&rid),
+ "paused deduct must not mark request_id"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// is_request_processed view
+// ---------------------------------------------------------------------------
+
+/// `is_request_processed` returns false before any deduct.
+#[test]
+fn is_request_processed_false_before_deduct() {
+ let env = Env::default();
+ let (_, client, _, _) = setup_vault(&env, 500);
+
+ let rid = Symbol::new(&env, "unseen");
+ assert!(!client.is_request_processed(&rid));
+}
+
+/// `is_request_processed` returns true after a successful deduct with that id.
+#[test]
+fn is_request_processed_true_after_successful_deduct() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 500);
+
+ let rid = Symbol::new(&env, "seen");
+ client.deduct(&owner, &50, &Some(rid.clone()));
+
+ assert!(
+ client.is_request_processed(&rid),
+ "is_request_processed must return true after successful deduct"
+ );
+}
+
+/// `is_request_processed` returns false for a different id even after another was processed.
+#[test]
+fn is_request_processed_false_for_different_id() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 500);
+
+ let rid_a = Symbol::new(&env, "id_a");
+ let rid_b = Symbol::new(&env, "id_b");
+
+ client.deduct(&owner, &50, &Some(rid_a.clone()));
+
+ assert!(client.is_request_processed(&rid_a));
+ assert!(!client.is_request_processed(&rid_b));
+}
+
+// ---------------------------------------------------------------------------
+// batch_deduct — idempotency
+// ---------------------------------------------------------------------------
+
+/// A batch containing a previously-processed `request_id` is rejected atomically.
+#[test]
+fn batch_deduct_duplicate_request_id_rejected_atomically() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid = Symbol::new(&env, "batch_dup");
+
+ // First single deduct marks the id.
+ client.deduct(&owner, &100, &Some(rid.clone()));
+ assert_eq!(client.balance(), 900);
+
+ // Batch that reuses the same id — must be rejected atomically.
+ let items = soroban_sdk::vec![
+ &env,
+ DeductItem {
+ amount: 50,
+ request_id: Some(rid.clone()),
+ },
+ DeductItem {
+ amount: 50,
+ request_id: None,
+ },
+ ];
+ let result = client.try_batch_deduct(&owner, &items);
+ assert!(result.is_err(), "batch with duplicate id must be rejected");
+
+ // Balance must be unchanged — full atomicity.
+ assert_eq!(client.balance(), 900, "balance must not change on duplicate batch");
+}
+
+/// A batch where two items share the same new `request_id` is rejected.
+#[test]
+fn batch_deduct_two_items_same_new_id_rejected() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid = Symbol::new(&env, "shared_id");
+
+ // Both items carry the same id — the second one is a duplicate of the first.
+ let items = soroban_sdk::vec![
+ &env,
+ DeductItem {
+ amount: 100,
+ request_id: Some(rid.clone()),
+ },
+ DeductItem {
+ amount: 100,
+ request_id: Some(rid.clone()),
+ },
+ ];
+ let result = client.try_batch_deduct(&owner, &items);
+ assert!(
+ result.is_err(),
+ "batch with two items sharing the same new id must be rejected"
+ );
+
+ // Balance must be unchanged.
+ assert_eq!(client.balance(), 1_000);
+ // The id must NOT have been marked (batch was rejected).
+ assert!(
+ !client.is_request_processed(&rid),
+ "rejected batch must not mark request_id"
+ );
+}
+
+/// A batch with all distinct `Some` ids succeeds and marks all of them.
+#[test]
+fn batch_deduct_distinct_ids_all_succeed_and_marked() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid_1 = Symbol::new(&env, "b_id_1");
+ let rid_2 = Symbol::new(&env, "b_id_2");
+ let rid_3 = Symbol::new(&env, "b_id_3");
+
+ let items = soroban_sdk::vec![
+ &env,
+ DeductItem {
+ amount: 100,
+ request_id: Some(rid_1.clone()),
+ },
+ DeductItem {
+ amount: 200,
+ request_id: Some(rid_2.clone()),
+ },
+ DeductItem {
+ amount: 50,
+ request_id: Some(rid_3.clone()),
+ },
+ ];
+ let remaining = client.batch_deduct(&owner, &items);
+ assert_eq!(remaining, 650);
+
+ // All three ids must now be marked.
+ assert!(client.is_request_processed(&rid_1));
+ assert!(client.is_request_processed(&rid_2));
+ assert!(client.is_request_processed(&rid_3));
+}
+
+/// A batch with `None` ids succeeds and does not mark anything.
+#[test]
+fn batch_deduct_none_ids_not_marked() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let items = soroban_sdk::vec![
+ &env,
+ DeductItem {
+ amount: 100,
+ request_id: None,
+ },
+ DeductItem {
+ amount: 200,
+ request_id: None,
+ },
+ ];
+ let remaining = client.batch_deduct(&owner, &items);
+ assert_eq!(remaining, 700);
+
+ // No ids were provided — nothing should be marked.
+ // We verify by checking a sentinel id is still unprocessed.
+ let sentinel = Symbol::new(&env, "sentinel");
+ assert!(!client.is_request_processed(&sentinel));
+}
+
+/// A batch that fails due to insufficient balance does NOT mark any ids.
+#[test]
+fn batch_deduct_failed_insufficient_balance_does_not_mark_ids() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 100);
+
+ let rid_a = Symbol::new(&env, "fail_a");
+ let rid_b = Symbol::new(&env, "fail_b");
+
+ let items = soroban_sdk::vec![
+ &env,
+ DeductItem {
+ amount: 60,
+ request_id: Some(rid_a.clone()),
+ },
+ DeductItem {
+ amount: 60, // cumulative 120 > 100
+ request_id: Some(rid_b.clone()),
+ },
+ ];
+ let result = client.try_batch_deduct(&owner, &items);
+ assert!(result.is_err(), "expected insufficient balance error");
+
+ // Neither id must be marked.
+ assert!(!client.is_request_processed(&rid_a));
+ assert!(!client.is_request_processed(&rid_b));
+ assert_eq!(client.balance(), 100);
+}
+
+/// After a successful deduct, retrying with the same id returns DuplicateRequestId
+/// regardless of the amount.
+#[test]
+fn deduct_retry_with_different_amount_still_rejected() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid = Symbol::new(&env, "retry_amt");
+
+ client.deduct(&owner, &100, &Some(rid.clone()));
+
+ // Retry with a different amount — still rejected.
+ let result = client.try_deduct(&owner, &50, &Some(rid.clone()));
+ assert!(result.is_err(), "retry with different amount must be rejected");
+ assert_eq!(client.balance(), 900);
+}
+
+/// Mixed batch: some items have `Some` ids, some have `None`.
+/// All `Some` ids are marked; `None` items are not.
+#[test]
+fn batch_deduct_mixed_ids_marks_only_some_ids() {
+ let env = Env::default();
+ let (_, client, _, owner) = setup_vault(&env, 1_000);
+
+ let rid_x = Symbol::new(&env, "mix_x");
+ let rid_z = Symbol::new(&env, "mix_z");
+
+ let items = soroban_sdk::vec![
+ &env,
+ DeductItem {
+ amount: 100,
+ request_id: Some(rid_x.clone()),
+ },
+ DeductItem {
+ amount: 50,
+ request_id: None,
+ },
+ DeductItem {
+ amount: 75,
+ request_id: Some(rid_z.clone()),
+ },
+ ];
+ let remaining = client.batch_deduct(&owner, &items);
+ assert_eq!(remaining, 775);
+
+ assert!(client.is_request_processed(&rid_x));
+ assert!(client.is_request_processed(&rid_z));
+
+ // Retrying either Some id must fail.
+ assert!(client.try_deduct(&owner, &10, &Some(rid_x)).is_err());
+ assert!(client.try_deduct(&owner, &10, &Some(rid_z)).is_err());
+
+ // None deducts still go through.
+ assert_eq!(client.deduct(&owner, &10, &None), 765);
+}