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); +}