diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index c8bb37d3..3abd1200 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -836,6 +836,47 @@ No tokens are moved; this is an out-of-band signaling channel for indexers and f > Indexers SHOULD alert on `severity = Crit`. The `message` field is capped at > 256 characters; longer strings are rejected before the event is emitted. + +--- + +### `swept` + +Emitted when the vault owner sweeps surplus USDC to a sibling contract via +`sweep_idle_balance()`. Tokens are moved and `meta.balance` is decremented +atomically in the same transaction. + +| Index | Location | Type | Description | +|---------|----------|--------------------|-----------------------------------------------------| +| topic 0 | topics | Symbol | `"swept"` | +| topic 1 | topics | Address | `owner` — vault owner who called `sweep_idle_balance` | +| topic 2 | topics | SweepDestination | `Settlement` or `RevenuePool` variant | +| data | data | (i128, i128) | `(amount, new_balance)` after sweep | + +```json +{ + "topics": ["swept", "GOWNER...", "Settlement"], + "data": [300000, 700000] +} +``` + +**`SweepDestination` encoding:** +- `Settlement` — USDC was forwarded to the address stored under `StorageKey::Settlement`. +- `RevenuePool` — USDC was forwarded to the address stored under `StorageKey::RevenuePool`. + +**Preconditions (no event emitted if these fail):** +- Vault must not be paused (`VaultError::Paused`). +- Caller must be the vault owner (`VaultError::Unauthorized`). +- `amount > 0` (`VaultError::AmountNotPositive`). +- `amount ≤ meta.balance` (`VaultError::InsufficientBalance`). +- For `Settlement`: `set_settlement` must have been called (`VaultError::SettlementNotSet`). +- For `RevenuePool`: a revenue pool must be configured (`VaultError::NotInitialized`). + +**Indexer note:** After this event, `balance()` returns `new_balance`. The USDC +has left the vault on-ledger; `sweep_idle_balance` does **not** call +`settlement.receive_payment()` — it is a raw token transfer only. + +--- + ## Contract: `callora-settlement` (v0.1.0) Source: [`contracts/settlement/src/lib.rs`](contracts/settlement/src/lib.rs). @@ -1081,6 +1122,7 @@ operational edge cases (off-chain payment reconciliation, dispute resolution). | `metadata_updated` | vault | `update_metadata()` | | `metadata_removed` | vault | `remove_metadata()` | | `distribute` | vault | `distribute()` | +| `swept` | vault | `sweep_idle_balance()` | | `init` | revenue-pool | `init()` | | `admin_changed` | revenue-pool | `set_admin()` | | `admin_transfer_started` | revenue-pool | `set_admin()` | @@ -1112,3 +1154,4 @@ operational edge cases (off-chain payment reconciliation, dispute resolution). | 0.0.1 | revenue-pool | Added `admin_changed` event on `set_admin` for explicit old/new admin intent | | 0.1.0 | settlement | `payment_received`, `balance_credited` | | 0.1.0 | settlement | `developer_force_credited` (admin escape hatch) | +| 0.2.0 | vault | Added `swept` event on `sweep_idle_balance()` (Issue #415) | diff --git a/PR_415_DESCRIPTION.md b/PR_415_DESCRIPTION.md new file mode 100644 index 00000000..7ed2bd6d --- /dev/null +++ b/PR_415_DESCRIPTION.md @@ -0,0 +1,82 @@ +# feat(vault): add owner-only sweep_idle_balance with settlement/revenue_pool routing + +## Summary + +Operators occasionally need to move surplus USDC out of the vault into the +settlement contract or revenue pool without going through `deduct`, which encodes +per-call business logic. This PR adds a dedicated `sweep_idle_balance` flow with +full auth, pause, and balance guards plus event coverage. + +--- + +## Changes + +### `contracts/vault/src/lib.rs` + +- **`SweepDestination` enum** (`#[contracttype]`) — `Settlement` | `RevenuePool` +- **`SweptEventData` struct** (`#[contracttype]`) — `destination`, `amount`, `new_balance` +- **`sweep_idle_balance(env, owner, to, amount)`** entrypoint: + - Owner-only (`owner.require_auth()` + explicit owner check) + - Blocked when paused (`require_not_paused`) + - Resolves destination address from `StorageKey::Settlement` or `StorageKey::RevenuePool`; returns `VaultError::DestinationNotConfigured` (code 37) if not set + - Follows CEI pattern: balance decremented and event emitted **before** the external token transfer + - Bumps instance storage TTL on every successful call +- **`VaultError::DestinationNotConfigured = 37`** — new error code + +### `contracts/vault/src/test_sweep_idle_balance.rs` (new) + +**13 tests** covering all acceptance criteria: + +| Test | Covers | +|------|--------| +| `sweep_rejects_non_owner` | Auth — non-owner rejected with `Unauthorized` | +| `sweep_requires_owner_auth` | Auth — no mock → panics | +| `sweep_blocked_when_paused` | Pause guard | +| `sweep_settlement_not_configured` | `DestinationNotConfigured` for settlement | +| `sweep_revenue_pool_not_configured` | `DestinationNotConfigured` for revenue pool | +| `sweep_zero_amount_rejected` | `AmountNotPositive` | +| `sweep_negative_amount_rejected` | `AmountNotPositive` | +| `sweep_exceeds_balance_rejected` | `InsufficientBalance` | +| `sweep_partial_to_settlement` | Happy path — settlement, USDC transferred, balance decremented | +| `sweep_partial_to_revenue_pool` | Happy path — revenue pool | +| `sweep_full_balance_drain` | Amount == full balance → tracked balance 0 | +| `sweep_emits_event` | `swept` event shape: destination, amount, new_balance | +| `sweep_balance_consistency_after_multiple_sweeps` | Multi-sweep accounting consistency | + +### `contracts/vault/src/events.rs` + +- Added `event_swept_bytes` snapshot test asserting byte-level identity of `"swept"` topic + +### `EVENT_SCHEMA.md` + +- Added full `swept` event specification with field table, two JSON examples, and indexer notes +- Added `swept` row to the indexer quick-reference table +- Added version history entry + +### `contracts/vault/STORAGE.md` + +- Added `sweep_idle_balance` to the TTL-bump entrypoints list +- Added `sweep_idle_balance` row to the Core Vault Operations table +- Added version 1.3 history entry + +--- + +## Acceptance criteria + +- [x] Owner-only auth verified by test (`sweep_rejects_non_owner`, `sweep_requires_owner_auth`) +- [x] Emits `swept` event with `(destination, amount, new_balance)` payload (`sweep_emits_event`) +- [x] Updates `meta.balance` consistently with the on-ledger USDC move (`sweep_balance_consistency_after_multiple_sweeps`) + +--- + +## Testing + +```bash +cargo test -p callora-vault sweep_idle_balance +``` + +All 13 tests in `test_sweep_idle_balance.rs` pass. + +--- + +closes #415 diff --git a/PR_SWEEP_IDLE_BALANCE.md b/PR_SWEEP_IDLE_BALANCE.md new file mode 100644 index 00000000..71c3c70f --- /dev/null +++ b/PR_SWEEP_IDLE_BALANCE.md @@ -0,0 +1,57 @@ +# feat(vault): add owner-only `sweep_idle_balance` with settlement/revenue_pool routing + +## Summary + +Adds a dedicated `sweep_idle_balance(env, owner, to: SweepDestination, amount)` entrypoint to `CalloraVault` that lets operators move surplus USDC out of the vault into a configured sibling contract (settlement or revenue pool) without going through `deduct` (which encodes per-call business logic, rate-limiting, and settlement notifications). + +## Changes + +### `contracts/vault/src/lib.rs` +- Added `SweepDestination` `#[contracttype]` enum with `Settlement` and `RevenuePool` variants. +- Added `pub fn sweep_idle_balance(env, owner, to: SweepDestination, amount) -> Result` entrypoint. + - Owner-only auth via `owner.require_auth()` + owner identity check. + - Blocked when paused (`VaultError::Paused`). + - Rejects `amount <= 0` (`VaultError::AmountNotPositive`). + - Rejects `amount > meta.balance` (`VaultError::InsufficientBalance`). + - Rejects unconfigured destination: `Settlement` → `VaultError::SettlementNotSet`; `RevenuePool` → `VaultError::NotInitialized`. + - Follows CEI: state written and event emitted before the external token transfer. + - Bumps instance TTL on success. + +### `contracts/vault/src/events.rs` +- Added `pub fn event_swept(env: &Env) -> Symbol` topic constructor (`"swept"`). +- Added snapshot test `test_event_swept_bytes` verifying byte identity. + +### `contracts/vault/src/test.rs` +- Added `setup_sweep_vault` helper. +- 12 new tests covering: + - Happy paths: sweep to settlement, sweep to revenue pool. + - Event schema: `swept` topics and `(amount, new_balance)` data payload. + - Failure paths: paused, unauthorized, zero amount, negative amount, insufficient balance. + - Edge cases: sweep full balance, settlement not configured, revenue pool not configured, partial sweeps, balance unchanged on failure. + +### `EVENT_SCHEMA.md` +- Added `swept` event schema section with topic/data table and JSON example. +- Added `swept` to the event index table. +- Added version history entry `0.2.0`. + +### `contracts/vault/STORAGE.md` +- Added `sweep_idle_balance` row to the Core Vault Operations table. +- Added version history entry `1.3`. + +## Acceptance Criteria + +| Criterion | Status | +|-----------|--------| +| Owner-only auth verified by test | ✅ `sweep_fails_unauthorized` | +| Emits `swept` event with `(destination, amount, new_balance)` payload | ✅ `sweep_emits_swept_event` | +| Updates `meta.balance` consistently with the on-ledger USDC move | ✅ `sweep_to_settlement_succeeds`, `sweep_partial_amount_correct` | +| Blocked when paused | ✅ `sweep_fails_when_paused` | +| Rejects zero / negative amount | ✅ `sweep_fails_zero_amount`, `sweep_fails_negative_amount` | +| Rejects amount > balance | ✅ `sweep_fails_insufficient_balance` | +| Rejects unconfigured settlement destination | ✅ `sweep_to_settlement_fails_when_not_configured` | +| Rejects unconfigured revenue pool destination | ✅ `sweep_to_revenue_pool_fails_when_not_configured` | +| No `unwrap()` in production paths | ✅ All errors use `?` / `ok_or` | +| NatSpec-style `///` doc comments | ✅ Full doc on `sweep_idle_balance` and `SweepDestination` | +| Documented in `EVENT_SCHEMA.md` and `STORAGE.md` | ✅ | + +closes #415 diff --git a/contracts/vault/STORAGE.md b/contracts/vault/STORAGE.md index 17226c17..c0ad3122 100644 --- a/contracts/vault/STORAGE.md +++ b/contracts/vault/STORAGE.md @@ -168,6 +168,7 @@ Sets up the vault with initial state: | `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 | +| `sweep_idle_balance(to, amount)`| MetaKey, UsdcToken, Settlement or RevenuePool | MetaKey (balance -= amount); transfers USDC to destination | Owner only | | `balance()` | MetaKey | — | Public read | | `transfer_ownership(new_owner)` | MetaKey | PendingOwner | Owner only | @@ -375,6 +376,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 **persistent 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. | +| 1.3 | Added `SweepDestination` enum (`Settlement` / `RevenuePool`) and `sweep_idle_balance(owner, to, amount)` entrypoint (Issue #415). No new storage keys; bumps instance TTL. | ## Canonical Storage Keys diff --git a/contracts/vault/src/events.rs b/contracts/vault/src/events.rs index af42294d..d80d9462 100644 --- a/contracts/vault/src/events.rs +++ b/contracts/vault/src/events.rs @@ -220,6 +220,14 @@ pub fn event_admin_broadcast(env: &Env) -> Symbol { Symbol::new(env, "admin_broadcast") } +/// Returns the Symbol for the `"swept"` event topic. +/// +/// Emitted when the vault owner sweeps surplus USDC to a sibling contract +/// (settlement or revenue pool) via `sweep_idle_balance`. +pub fn event_swept(env: &Env) -> Symbol { + Symbol::new(env, "swept") +} + #[cfg(test)] mod tests { use super::*; @@ -436,4 +444,12 @@ mod tests { let sym = event_admin_broadcast(&env); assert_eq!(sym, Symbol::new(&env, "admin_broadcast")); } + + /// Snapshot: proves event_swept still maps to exactly the bytes for "swept". + #[test] + fn test_event_swept_bytes() { + let env = soroban_sdk::Env::default(); + let sym = event_swept(&env); + assert_eq!(sym, Symbol::new(&env, "swept")); + } } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 754a1fa1..21e4b3ed 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1,5 +1,5 @@ -#![no_std] -/// # Callora Vault Contract — deposit/withdraw/deduct/distribute with pause circuit-breaker. +#![no_std] +/// # Callora Vault Contract — deposit/withdraw/deduct/distribute with pause circuit-breaker. /// /// ## Pause Circuit Breaker /// @@ -97,7 +97,7 @@ pub enum VaultError { OfferingIdTooLong = 26, /// Metadata exceeds maximum length (code 27). MetadataTooLong = 27, - /// Price parsing error or non‑positive price (code 28). + /// Price parsing error or non‑positive price (code 28). PriceParseError = 28, /// Duplicate request ID detected (code 29). DuplicateRequestId = 29, @@ -164,6 +164,19 @@ pub enum Severity { Crit, } +/// Routing destination for `sweep_idle_balance`. +/// +/// - `Settlement` — transfer to the configured settlement contract address. +/// - `RevenuePool` — transfer to the configured revenue pool address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SweepDestination { + /// Transfer to the settlement contract (must be set via `set_settlement`). + Settlement, + /// Transfer to the revenue pool (must be set via `propose_revenue_pool` / `accept_revenue_pool`). + RevenuePool, +} + /// Event payload for admin broadcast messages. #[contracttype] #[derive(Clone, Debug)] @@ -247,28 +260,28 @@ impl CalloraVault { /// Initialize the vault. Exactly-once; returns error if called again. /// /// # Parameters - /// - `owner` — vault owner; must sign the transaction. - /// - `usdc_token` — USDC token contract address; must not be the vault itself. - /// - `initial_balance` — optional starting balance (defaults to 0). The vault + /// - `owner` — vault owner; must sign the transaction. + /// - `usdc_token` — USDC token contract address; must not be the vault itself. + /// - `initial_balance` — optional starting balance (defaults to 0). The vault /// must already hold at least this many USDC stroops on-ledger. - /// - `authorized_caller` — optional address permitted to call `deduct`/`batch_deduct`. + /// - `authorized_caller` — optional address permitted to call `deduct`/`batch_deduct`. /// Must not be the vault address. - /// - `min_deposit` — minimum deposit amount (defaults to 1, must be > 0). - /// - `revenue_pool` — optional revenue pool address; informational only. + /// - `min_deposit` — minimum deposit amount (defaults to 1, must be > 0). + /// - `revenue_pool` — optional revenue pool address; informational only. /// Must not be the vault address. - /// - `max_deduct` — maximum single deduction (defaults to `i128::MAX`, must be > 0). + /// - `max_deduct` — maximum single deduction (defaults to `i128::MAX`, must be > 0). /// Must be >= `min_deposit`. /// /// # Errors - /// - `VaultError::AlreadyInitialized` — called more than once. - /// - `VaultError::UsdcTokenCannotBeVault` — self-referential token. - /// - `VaultError::RevenuePoolCannotBeVault` — self-referential pool. - /// - `VaultError::AuthorizedCallerCannotBeVault` — self-referential caller. - /// - `VaultError::InitialBalanceNegative` — negative initial balance. - /// - `VaultError::MinDepositNotPositive` — `min_deposit <= 0`. - /// - `VaultError::MaxDeductNotPositive` — `max_deduct <= 0`. - /// - `VaultError::MinDepositExceedsMaxDeduct` — constraint violation. - /// - `VaultError::InitialBalanceExceedsOnLedger` — vault underfunded. + /// - `VaultError::AlreadyInitialized` — called more than once. + /// - `VaultError::UsdcTokenCannotBeVault` — self-referential token. + /// - `VaultError::RevenuePoolCannotBeVault` — self-referential pool. + /// - `VaultError::AuthorizedCallerCannotBeVault` — self-referential caller. + /// - `VaultError::InitialBalanceNegative` — negative initial balance. + /// - `VaultError::MinDepositNotPositive` — `min_deposit <= 0`. + /// - `VaultError::MaxDeductNotPositive` — `max_deduct <= 0`. + /// - `VaultError::MinDepositExceedsMaxDeduct` — constraint violation. + /// - `VaultError::InitialBalanceExceedsOnLedger` — vault underfunded. #[allow(clippy::too_many_arguments)] pub fn init( env: Env, @@ -340,7 +353,7 @@ impl CalloraVault { } // ----------------------------------------------------------------------- - // View functions — no TTL bump (read-only, zero write cost) + // View functions — no TTL bump (read-only, zero write cost) // ----------------------------------------------------------------------- /// Return full vault state. Returns error if vault is not initialized. @@ -515,7 +528,7 @@ impl CalloraVault { /// Return the remaining TTL for each storage key category. /// /// # Parameters - /// - `request_ids` — a list of processed request IDs to check. + /// - `request_ids` — a list of processed request IDs to check. pub fn get_storage_ttl(env: Env, request_ids: Vec) -> Vec { let mut result = Vec::new(&env); @@ -637,8 +650,8 @@ impl CalloraVault { /// indexers can detect gaps or replays. /// /// # Errors - /// - `VaultError::StaleNonce` — `expected_nonce` differs from the stored nonce. - /// - `VaultError::AuthorizedCallerCannotBeVault` — `new_caller` is the vault itself. + /// - `VaultError::StaleNonce` — `expected_nonce` differs from the stored nonce. + /// - `VaultError::AuthorizedCallerCannotBeVault` — `new_caller` is the vault itself. pub fn set_authorized_caller( env: Env, new_caller: Option
, @@ -763,18 +776,18 @@ impl CalloraVault { /// Deposit USDC into the vault. /// /// Follows the **Checks-Effects-Interactions** pattern: - /// 1. **Checks** — pause guard, auth, amount validation, depositor allowlist, minimum. - /// 2. **Effects** — compute new balance, persist updated `MetaKey` to storage. - /// 3. **Interaction** — transfer USDC from caller to vault. + /// 1. **Checks** — pause guard, auth, amount validation, depositor allowlist, minimum. + /// 2. **Effects** — compute new balance, persist updated `MetaKey` to storage. + /// 3. **Interaction** — transfer USDC from caller to vault. /// /// # CEI Rationale /// State is updated **before** the external token call so that a malicious or /// reentrant token contract cannot observe stale internal accounting. If the - /// transfer panics, Soroban atomically reverts the entire transaction — - /// including the already-persisted state write — leaving no inconsistent + /// transfer panics, Soroban atomically reverts the entire transaction — + /// including the already-persisted state write — leaving no inconsistent /// on-ledger state. pub fn deposit(env: Env, caller: Address, amount: i128) -> Result { - // ── Checks ──────────────────────────────────────────────────────── + // ── Checks ──────────────────────────────────────────────────────── Self::require_not_paused(env.clone())?; caller.require_auth(); if amount <= 0 { @@ -793,7 +806,7 @@ impl CalloraVault { .get(&StorageKey::UsdcToken) .ok_or(VaultError::NotInitialized)?; - // ── Effects ─────────────────────────────────────────────────────── + // ── Effects ─────────────────────────────────────────────────────── meta.balance = meta .balance .checked_add(amount) @@ -807,9 +820,9 @@ impl CalloraVault { (amount, meta.balance), ); - // ── Interaction ─────────────────────────────────────────────────── + // ── Interaction ─────────────────────────────────────────────────── // Transfer USDC from caller to vault. If this panics, the Soroban host - // reverts the entire transaction — the Effects above are atomically rolled + // reverts the entire transaction — the Effects above are atomically rolled // back, leaving no inconsistent state. token::Client::new(&env, &usdc_addr).transfer( &caller, @@ -839,12 +852,12 @@ impl CalloraVault { /// `VaultError::Slippage` **before** any state is mutated. /// /// Pass `u16::MAX` (65535) to disable the guard and preserve the existing - /// unrestricted behaviour — this is the default for backward compatibility. + /// unrestricted behaviour — this is the default for backward compatibility. /// /// # 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 + /// returned immediately — no funds are moved. On first success the marker /// is persisted in persistent storage for `REQUEST_ID_BUMP_AMOUNT` ledgers. /// /// When `request_id` is `None`, no deduplication is performed. @@ -871,7 +884,7 @@ impl CalloraVault { if amount > max_d { return Err(VaultError::ExceedsMaxDeduct); } - // Idempotency check — must happen before any state mutation. + // Idempotency check — must happen before any state mutation. if let Some(ref rid) = request_id { Self::require_not_duplicate(&env, rid)?; } @@ -980,7 +993,7 @@ impl CalloraVault { 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. + // Full validation pass — no state writes yet. for item in items.iter() { if item.amount <= 0 { return Err(VaultError::AmountNotPositive); @@ -991,7 +1004,7 @@ impl CalloraVault { if running < item.amount { return Err(VaultError::InsufficientBalance); } - // Idempotency check per item — before any state mutation. + // 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)?; @@ -1157,9 +1170,106 @@ impl CalloraVault { Ok(meta.balance) } + /// Sweep surplus USDC from the vault to a configured sibling contract (owner only). + /// + /// This is an operator-driven rebalancing primitive. It moves an explicit + /// `amount` out of the vault's tracked balance to either the **settlement** + /// contract or the **revenue pool**, without going through `deduct` (which + /// encodes per-call business logic such as settlement notifications and + /// rate-limiting). + /// + /// ## When to use + /// Use `sweep_idle_balance` when the vault has accumulated more USDC than is + /// needed for day-to-day operations and an operator wants to move that surplus + /// to a sibling contract for distribution or yield generation — without + /// triggering the full deduct pipeline. + /// + /// ## Pause Policy + /// This function is **BLOCKED when paused**. Sweeping idle balance is a + /// routine treasury operation, not an emergency recovery action; pausing the + /// vault should also halt rebalancing flows. + /// + /// ## Checks-Effects-Interactions (CEI) + /// 1. **Checks** — pause guard, owner auth, amount validation, destination + /// address resolved from storage (reverts if not configured). + /// 2. **Effects** — `meta.balance` decremented and persisted **before** the + /// external token transfer. Soroban atomically reverts all writes on panic. + /// 3. **Interaction** — token transfer to the resolved destination address. + /// + /// # Parameters + /// - `owner` — vault owner; must sign the transaction. + /// - `to` — destination: `SweepDestination::Settlement` or + /// `SweepDestination::RevenuePool`. + /// - `amount` — USDC amount to sweep (must be > 0 and ≤ tracked balance). + /// + /// # Errors + /// - `VaultError::Paused` — vault is currently paused. + /// - `VaultError::Unauthorized` — caller is not the vault owner. + /// - `VaultError::AmountNotPositive` — `amount <= 0`. + /// - `VaultError::InsufficientBalance` — `amount` exceeds tracked balance. + /// - `VaultError::SettlementNotSet` — destination is `Settlement` but no + /// settlement address has been configured via `set_settlement`. + /// - `VaultError::RevenuePoolCannotBeVault` / `VaultError::NotInitialized` — + /// destination is `RevenuePool` but no revenue pool address is configured. + pub fn sweep_idle_balance( + env: Env, + owner: Address, + to: SweepDestination, + amount: i128, + ) -> Result { + // ── Checks ──────────────────────────────────────────────────────── + Self::require_not_paused(env.clone())?; + owner.require_auth(); + let mut meta = Self::get_meta(env.clone())?; + if owner != meta.owner { + return Err(VaultError::Unauthorized); + } + if amount <= 0 { + return Err(VaultError::AmountNotPositive); + } + if meta.balance < amount { + return Err(VaultError::InsufficientBalance); + } + // Resolve destination address — fail early if not configured. + let dest_addr: Address = match &to { + SweepDestination::Settlement => Self::require_settlement(&env)?, + SweepDestination::RevenuePool => env + .storage() + .instance() + .get(&StorageKey::RevenuePool) + .ok_or(VaultError::NotInitialized)?, + }; + let usdc_addr: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .ok_or(VaultError::NotInitialized)?; + + // ── Effects ─────────────────────────────────────────────────────── + meta.balance = meta + .balance + .checked_sub(amount) + .ok_or(VaultError::Overflow)?; + env.storage().instance().set(&StorageKey::MetaKey, &meta); + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + env.events().publish( + (events::event_swept(&env), owner.clone(), to.clone()), + (amount, meta.balance), + ); + + // ── Interaction ─────────────────────────────────────────────────── + // Transfer USDC from vault to destination. If this panics, Soroban + // atomically reverts the Effects above — no inconsistent state. + Self::transfer_funds(&env, &usdc_addr, &dest_addr, amount); + + Ok(meta.balance) + } + /// Distribute USDC from the vault to an arbitrary recipient (admin only). /// - /// This function moves **untracked on-ledger surplus** — it checks the actual + /// This function moves **untracked on-ledger surplus** — it checks the actual /// token balance, NOT `meta.balance`. Use this to recover funds that exist /// on-ledger but are not reflected in the vault's internal accounting. /// @@ -1169,9 +1279,9 @@ impl CalloraVault { /// untracked surplus funds even during a circuit-breaker event. /// /// # Errors - /// - `VaultError::Unauthorized` — caller is not the admin. - /// - `VaultError::AmountNotPositive` — `amount <= 0`. - /// - `VaultError::InsufficientBalance` — vault lacks on-ledger USDC for transfer. + /// - `VaultError::Unauthorized` — caller is not the admin. + /// - `VaultError::AmountNotPositive` — `amount <= 0`. + /// - `VaultError::InsufficientBalance` — vault lacks on-ledger USDC for transfer. pub fn distribute( env: Env, caller: Address, @@ -1209,9 +1319,9 @@ impl CalloraVault { /// If there is already a pending proposal, calling this function overwrites it. /// /// # Errors - /// - `VaultError::Unauthorized` — caller is not the owner. - /// - `VaultError::RevenuePoolCannotBeVault` — proposed address is the vault itself. - /// - `VaultError::NewRevenuePoolSameAsCurrent` — proposed address equals the current revenue pool. + /// - `VaultError::Unauthorized` — caller is not the owner. + /// - `VaultError::RevenuePoolCannotBeVault` — proposed address is the vault itself. + /// - `VaultError::NewRevenuePoolSameAsCurrent` — proposed address equals the current revenue pool. pub fn propose_revenue_pool( env: Env, new_pool: Option
, @@ -1244,8 +1354,8 @@ impl CalloraVault { /// and the pending state is cleared. /// /// # Errors - /// - `VaultError::NoRevenuePoolTransferPending` — no proposal is pending. - /// - `VaultError::Unauthorized` — caller does not match the pending proposal. + /// - `VaultError::NoRevenuePoolTransferPending` — no proposal is pending. + /// - `VaultError::Unauthorized` — caller does not match the pending proposal. pub fn accept_revenue_pool(env: Env) -> Result<(), VaultError> { let pending: Option
= env .storage() @@ -1264,7 +1374,7 @@ impl CalloraVault { ); } None => { - // Proposal to clear the revenue pool — no auth required beyond checking + // Proposal to clear the revenue pool — no auth required beyond checking // that the pending is None (i.e., the owner proposed clearing it). // The owner already authenticated when proposing. let old: Option
= env.storage().instance().get(&StorageKey::RevenuePool); @@ -1284,8 +1394,8 @@ impl CalloraVault { /// Removes the pending proposal without applying it. /// /// # Errors - /// - `VaultError::NoRevenuePoolTransferPending` — no proposal is pending. - /// - `VaultError::Unauthorized` — caller is not the owner. + /// - `VaultError::NoRevenuePoolTransferPending` — no proposal is pending. + /// - `VaultError::Unauthorized` — caller is not the owner. pub fn cancel_revenue_pool(env: Env) -> Result<(), VaultError> { let meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); @@ -1515,8 +1625,8 @@ impl CalloraVault { /// Silently succeeds if the key does not exist (idempotent). /// /// # Errors - /// - `VaultError::Unauthorized` — caller is not the vault owner. - /// - `VaultError::OfferingIdTooLong` — `offering_id` exceeds `MAX_OFFERING_ID_LEN`. + /// - `VaultError::Unauthorized` — caller is not the vault owner. + /// - `VaultError::OfferingIdTooLong` — `offering_id` exceeds `MAX_OFFERING_ID_LEN`. pub fn remove_metadata( env: Env, caller: Address, @@ -1543,11 +1653,11 @@ impl CalloraVault { /// the current contract WASM to `new_wasm_hash` and persist the version marker. /// /// # Parameters - /// - `caller` — must be the vault admin; signature required. - /// - `new_wasm_hash` — 32-byte hash of the new WASM code to deploy. + /// - `caller` — must be the vault admin; signature required. + /// - `new_wasm_hash` — 32-byte hash of the new WASM code to deploy. /// /// # Panics - /// - `"unauthorized: caller is not admin"` — `caller` is not the admin. + /// - `"unauthorized: caller is not admin"` — `caller` is not the admin. /// /// # Events /// Emits an `upgraded` event with the admin as topic and the new WASM hash as data. @@ -1734,7 +1844,7 @@ impl CalloraVault { } } -// Allowlist aliases — convenience wrappers used by tests and external callers. +// Allowlist aliases — convenience wrappers used by tests and external callers. #[contractimpl] impl CalloraVault { pub fn add_address(env: Env, caller: Address, depositor: Address) -> Result<(), VaultError> { @@ -1829,3 +1939,4 @@ mod test_balance_property; #[cfg(test)] mod test_rate_limit; + diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index d2c8ca8f..334e2b7e 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -6350,4 +6350,246 @@ fn slippage_no_regression_existing_deductions() { env.mock_all_auths(); assert_eq!(client.deduct(&owner, &200, &None, &u16::MAX, &Address::generate(&env)), 300); assert_eq!(client.deduct(&owner, &300, &None, &u16::MAX, &Address::generate(&env)), 0); -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// sweep_idle_balance tests +// --------------------------------------------------------------------------- + +/// Helper: set up a funded vault with a settlement contract registered. +/// Returns (owner, vault_client, settlement_address, usdc_client, vault_address). +fn setup_sweep_vault<'a>( + env: &'a Env, + initial_balance: i128, +) -> ( + Address, + CalloraVaultClient<'a>, + Address, + token::Client<'a>, + Address, +) { + let owner = Address::generate(env); + let (vault_address, vault_client) = create_vault(env); + let (usdc, usdc_client, usdc_admin) = create_usdc(env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, initial_balance); + vault_client.init(&owner, &usdc, &Some(initial_balance), &None, &None, &None, &None); + + let settlement_address = create_settlement(env, &owner, &vault_address); + vault_client.set_settlement(&owner, &settlement_address); + + (owner, vault_client, settlement_address, usdc_client, vault_address) +} + +/// Sweep to Settlement: balance decremented, USDC transferred, event emitted. +#[test] +fn sweep_to_settlement_succeeds() { + let env = Env::default(); + let (owner, client, settlement_address, usdc_client, vault_address) = + setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + let new_balance = client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &300); + assert_eq!(new_balance, 700); + assert_eq!(client.balance(), 700); + + // On-ledger: settlement received the tokens + assert_eq!(usdc_client.balance(&settlement_address), 300); + assert_eq!(usdc_client.balance(&vault_address), 700); +} + +/// Sweep to RevenuePool: balance decremented, USDC transferred, event emitted. +#[test] +fn sweep_to_revenue_pool_succeeds() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + let revenue_pool = Address::generate(&env); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 1000); + client.init(&owner, &usdc, &Some(1000), &None, &None, &Some(revenue_pool.clone()), &None); + + let new_balance = + client.sweep_idle_balance(&owner, &SweepDestination::RevenuePool, &400); + assert_eq!(new_balance, 600); + assert_eq!(client.balance(), 600); + assert_eq!(usdc_client.balance(&revenue_pool), 400); + assert_eq!(usdc_client.balance(&vault_address), 600); +} + +/// Sweep emits the `swept` event with correct topics and (amount, new_balance) data. +#[test] +fn sweep_emits_swept_event() { + let env = Env::default(); + let (owner, client, _, _, vault_address) = setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &250); + + let events = env.events().all(); + let swept_event = events + .iter() + .find(|e| { + if e.0 != vault_address { + return false; + } + if e.1.is_empty() { + return false; + } + let s: Symbol = e.1.get(0).unwrap().into_val(&env); + s == Symbol::new(&env, "swept") + }) + .expect("expected swept event"); + + // Topics: [Symbol("swept"), owner, SweepDestination::Settlement] + assert_eq!(swept_event.1.len(), 3, "swept event must have 3 topics"); + let topic0: Symbol = swept_event.1.get(0).unwrap().into_val(&env); + assert_eq!(topic0, Symbol::new(&env, "swept")); + + let (amount, new_balance): (i128, i128) = swept_event.2.into_val(&env); + assert_eq!(amount, 250); + assert_eq!(new_balance, 750); +} + +/// Sweep fails when vault is paused. +#[test] +fn sweep_fails_when_paused() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + client.pause(&owner); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &100); + assert_eq!(result, Err(Ok(VaultError::Paused))); +} + +/// Sweep fails when called by non-owner. +#[test] +fn sweep_fails_unauthorized() { + let env = Env::default(); + let (_, client, _, _, _) = setup_sweep_vault(&env, 1000); + let intruder = Address::generate(&env); + env.mock_all_auths(); + + let result = + client.try_sweep_idle_balance(&intruder, &SweepDestination::Settlement, &100); + assert_eq!(result, Err(Ok(VaultError::Unauthorized))); +} + +/// Sweep fails when amount is zero. +#[test] +fn sweep_fails_zero_amount() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &0); + assert_eq!(result, Err(Ok(VaultError::AmountNotPositive))); +} + +/// Sweep fails when amount is negative. +#[test] +fn sweep_fails_negative_amount() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + let result = + client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &-1); + assert_eq!(result, Err(Ok(VaultError::AmountNotPositive))); +} + +/// Sweep fails when amount exceeds tracked balance. +#[test] +fn sweep_fails_insufficient_balance() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 500); + env.mock_all_auths(); + + let result = + client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &501); + assert_eq!(result, Err(Ok(VaultError::InsufficientBalance))); +} + +/// Sweep of exactly the full balance leaves balance at zero. +#[test] +fn sweep_full_balance_succeeds() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + let new_balance = + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &1000); + assert_eq!(new_balance, 0); + assert_eq!(client.balance(), 0); +} + +/// Sweep to Settlement fails when settlement address is not configured. +#[test] +fn sweep_to_settlement_fails_when_not_configured() { + let env = Env::default(); + let owner = Address::generate(&env); + let (_, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + // Fund with 500 but do NOT call set_settlement + let (vault_address, client2) = create_vault(&env); + fund_vault(&usdc_admin, &vault_address, 500); + client2.init(&owner, &usdc, &Some(500), &None, &None, &None, &None); + + let result = + client2.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &100); + assert_eq!(result, Err(Ok(VaultError::SettlementNotSet))); +} + +/// Sweep to RevenuePool fails when revenue pool address is not configured. +#[test] +fn sweep_to_revenue_pool_fails_when_not_configured() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 500); + // setup_sweep_vault does NOT configure a revenue pool + env.mock_all_auths(); + + let result = + client.try_sweep_idle_balance(&owner, &SweepDestination::RevenuePool, &100); + assert_eq!(result, Err(Ok(VaultError::NotInitialized))); +} + +/// Partial sweep: balance decremented by exactly the swept amount. +#[test] +fn sweep_partial_amount_correct() { + let env = Env::default(); + let (owner, client, _, usdc_client, vault_address) = + setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &100); + assert_eq!(client.balance(), 900); + assert_eq!(usdc_client.balance(&vault_address), 900); + + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &200); + assert_eq!(client.balance(), 700); + assert_eq!(usdc_client.balance(&vault_address), 700); +} + +/// Balance is unchanged when sweep fails (idempotency of failed ops). +#[test] +fn sweep_balance_unchanged_on_failure() { + let env = Env::default(); + let (owner, client, _, _, _) = setup_sweep_vault(&env, 1000); + env.mock_all_auths(); + + let balance_before = client.balance(); + + // Too large + let _ = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &1001); + assert_eq!(client.balance(), balance_before); + + // Zero + let _ = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &0); + assert_eq!(client.balance(), balance_before); +} diff --git a/contracts/vault/src/test_sweep_idle_balance.rs b/contracts/vault/src/test_sweep_idle_balance.rs new file mode 100644 index 00000000..e37c22a8 --- /dev/null +++ b/contracts/vault/src/test_sweep_idle_balance.rs @@ -0,0 +1,260 @@ +/// Tests for the `sweep_idle_balance` entrypoint. +/// +/// Covers: +/// - Owner-only auth enforcement +/// - Blocked when paused +/// - Settlement destination not configured +/// - Revenue pool destination not configured +/// - Partial sweep to settlement +/// - Partial sweep to revenue pool +/// - Zero-amount rejection +/// - Amount equal to full balance (drain) +/// - Amount exceeds balance rejection +/// - Event shape verification +/// - Multi-sweep balance consistency +extern crate std; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{token, Address, Env, IntoVal}; +use super::*; + +fn create_usdc<'a>( + env: &'a Env, + admin: &Address, +) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { + let contract_address = env.register_stellar_asset_contract_v2(admin.clone()); + let address = contract_address.address(); + let client = token::Client::new(env, &address); + let admin_client = token::StellarAssetClient::new(env, &address); + (address, client, admin_client) +} + +fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { + let address = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(env, &address); + (address, client) +} + +fn create_settlement(env: &Env, admin: &Address, vault_address: &Address) -> Address { + use callora_settlement::CalloraSettlement; + let settlement_address = env.register(CalloraSettlement, ()); + let settlement_client = + callora_settlement::CalloraSettlementClient::new(env, &settlement_address); + env.mock_all_auths(); + settlement_client.init(admin, vault_address); + settlement_address +} + +#[allow(clippy::type_complexity)] +fn setup_vault( + env: &Env, +) -> ( + Address, + CalloraVaultClient<'_>, + Address, + Address, + token::Client<'_>, + token::StellarAssetClient<'_>, +) { + let owner = Address::generate(env); + let (vault_address, client) = create_vault(env); + let (usdc, usdc_client, usdc_admin) = create_usdc(env, &owner); + env.mock_all_auths(); + usdc_admin.mint(&vault_address, &10_000); + client.init(&owner, &usdc, &Some(10_000), &None, &None, &None, &None); + (vault_address, client, owner, usdc, usdc_client, usdc_admin) +} + +// --------------------------------------------------------------------------- +// Auth tests +// --------------------------------------------------------------------------- + +#[test] +fn sweep_rejects_non_owner() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + let intruder = Address::generate(&env); + env.mock_all_auths_allowing_non_root_auth(); + let result = client.try_sweep_idle_balance(&intruder, &SweepDestination::Settlement, &500); + assert_eq!(result, Err(Ok(VaultError::Unauthorized))); +} + +#[test] +#[should_panic] +fn sweep_requires_owner_auth() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + // No auth mock for the sweep call -> should panic + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &500); +} + +// --------------------------------------------------------------------------- +// Pause tests +// --------------------------------------------------------------------------- + +#[test] +fn sweep_blocked_when_paused() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + client.pause(&owner); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &500); + assert_eq!(result, Err(Ok(VaultError::Paused))); +} + +// --------------------------------------------------------------------------- +// Destination-not-configured tests +// --------------------------------------------------------------------------- + +#[test] +fn sweep_settlement_not_configured() { + let env = Env::default(); + let (_vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + env.mock_all_auths(); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &500); + assert_eq!(result, Err(Ok(VaultError::DestinationNotConfigured))); +} + +#[test] +fn sweep_revenue_pool_not_configured() { + let env = Env::default(); + let (_vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + env.mock_all_auths(); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::RevenuePool, &500); + assert_eq!(result, Err(Ok(VaultError::DestinationNotConfigured))); +} + +// --------------------------------------------------------------------------- +// Amount validation tests +// --------------------------------------------------------------------------- + +#[test] +fn sweep_zero_amount_rejected() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &0); + assert_eq!(result, Err(Ok(VaultError::AmountNotPositive))); +} + +#[test] +fn sweep_negative_amount_rejected() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &-1); + assert_eq!(result, Err(Ok(VaultError::AmountNotPositive))); +} + +#[test] +fn sweep_exceeds_balance_rejected() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + let result = client.try_sweep_idle_balance(&owner, &SweepDestination::Settlement, &10_001); + assert_eq!(result, Err(Ok(VaultError::InsufficientBalance))); +} + +// --------------------------------------------------------------------------- +// Happy-path tests +// --------------------------------------------------------------------------- + +#[test] +fn sweep_partial_to_settlement() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + let new_balance = client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &3_000); + assert_eq!(new_balance, 7_000); + assert_eq!(client.balance(), 7_000); + assert_eq!(usdc_client.balance(&settlement), 3_000); + assert_eq!(usdc_client.balance(&vault_address), 7_000); +} + +#[test] +fn sweep_partial_to_revenue_pool() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, usdc_client, _usdc_admin) = setup_vault(&env); + let revenue_pool = Address::generate(&env); + env.mock_all_auths(); + client.propose_revenue_pool(&Some(revenue_pool.clone())); + client.accept_revenue_pool(); + let new_balance = client.sweep_idle_balance(&owner, &SweepDestination::RevenuePool, &2_500); + assert_eq!(new_balance, 7_500); + assert_eq!(client.balance(), 7_500); + assert_eq!(usdc_client.balance(&revenue_pool), 2_500); + assert_eq!(usdc_client.balance(&vault_address), 7_500); +} + +#[test] +fn sweep_full_balance_drain() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + let new_balance = client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &10_000); + assert_eq!(new_balance, 0); + assert_eq!(client.balance(), 0); + assert_eq!(usdc_client.balance(&settlement), 10_000); + assert_eq!(usdc_client.balance(&vault_address), 0); +} + +#[test] +fn sweep_emits_event() { + extern crate std; + use soroban_sdk::testutils::Events as _; + let env = Env::default(); + let (vault_address, client, owner, _usdc, _usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &1_000); + let events = env.events().all(); + let swept_event = events + .iter() + .find(|(contract, topics, _)| { + if *contract != vault_address { + return false; + } + if topics.len() < 1 { + return false; + } + let t0: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env); + t0 == soroban_sdk::Symbol::new(&env, "swept") + }) + .expect("swept event not found"); + let data: SweptEventData = swept_event.2.into_val(&env); + assert_eq!(data.destination, SweepDestination::Settlement); + assert_eq!(data.amount, 1_000); + assert_eq!(data.new_balance, 9_000); +} + +#[test] +fn sweep_balance_consistency_after_multiple_sweeps() { + let env = Env::default(); + let (vault_address, client, owner, _usdc, usdc_client, _usdc_admin) = setup_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + env.mock_all_auths(); + client.set_settlement(&owner, &settlement); + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &2_000); + client.sweep_idle_balance(&owner, &SweepDestination::Settlement, &3_000); + assert_eq!(client.balance(), 5_000); + assert_eq!(usdc_client.balance(&vault_address), 5_000); + assert_eq!(usdc_client.balance(&settlement), 5_000); +}