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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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()` |
Expand Down Expand Up @@ -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) |
82 changes: 82 additions & 0 deletions PR_415_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions PR_SWEEP_IDLE_BALANCE.md
Original file line number Diff line number Diff line change
@@ -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<i128, VaultError>` 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
2 changes: 2 additions & 0 deletions contracts/vault/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions contracts/vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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"));
}
}
Loading
Loading