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
6 changes: 6 additions & 0 deletions EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ Emitted on each deduction — once per `deduct()` call and once per item in `bat
- **Indexer rule**: treat `Symbol("")` as “no request_id provided”.
- **Ambiguity note**: `Some(Symbol(""))` is indistinguishable from `None` on-chain. Clients **SHOULD NOT** intentionally pass an empty symbol as a real request id.

**Precondition (Issue #263):** `deduct` / `batch_deduct` require a settlement
address to be configured via `set_settlement`. If the settlement address is
not set, the call panics with `"settlement address not set"` **before** any
`deduct` event is emitted — indexers will therefore never observe a `deduct`
event for a call that lacked a configured settlement destination.

---

### `withdraw`
Expand Down
14 changes: 12 additions & 2 deletions INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,24 @@ Helper and view functions such as `get_meta`, `get_max_deduct`, `get_revenue_poo
**Pre-conditions**
- Caller is authorized:
- `caller.require_auth()`
- Vault is initialized.
- Vault is initialized and not paused.
- Amount constraints:
- `amount > 0`
- `amount <= get_max_deduct(env)`
- Sufficient balance:
- `meta.balance >= amount`
- **Settlement configured (Issue #263)**:
- `StorageKey::Settlement` is present — i.e. `set_settlement` has been called.
- If absent, the call panics with `"settlement address not set"` before any
balance mutation, guaranteeing no partial state update.

**Post-conditions**
- `VaultMeta.balance' = balance - amount`
- Because of the `meta.balance >= amount` assertion and `amount > 0`, we have:
- `VaultMeta.balance' >= 0`
- The on-ledger USDC decrease at the vault equals the internal balance decrease
(both equal `amount`), because the deducted USDC is always transferred to the
settlement address.

---

Expand All @@ -100,7 +107,7 @@ Helper and view functions such as `get_meta`, `get_max_deduct`, `get_revenue_poo

**Pre-conditions**
- Caller is authorized: `caller.require_auth()`
- Vault is initialized.
- Vault is initialized and not paused.
- `1 <= items.len() <= MAX_BATCH_SIZE` (50)
- The explicit batch cap is a practical Soroban resource bound:
it limits looped validation work, transfer/event overhead, and invocation
Expand All @@ -109,6 +116,9 @@ Helper and view functions such as `get_meta`, `get_max_deduct`, `get_revenue_poo
- For every item: `item.amount > 0` and `item.amount <= get_max_deduct(env)`
- Cumulative deductions do not exceed balance:
- Validated in a single pass before any state is written.
- **Settlement configured (Issue #263)**: `StorageKey::Settlement` is present;
missing settlement causes `"settlement address not set"` panic before any
state write, so the batch is atomically reverted.

**Post-conditions**
- `VaultMeta.balance' = balance - sum_i(amount_i) >= 0`
Expand Down
26 changes: 12 additions & 14 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,19 @@ The vault performs USDC transfers to configurable counterpart addresses on every
`deduct` and `batch_deduct` call. These external transfers are justified as follows:

- **settlement address**: set and updated exclusively by the on-chain admin via
`set_settlement`. This function emits a `set_settlement` event to provide a
clear audit trail for address rotation. Transfers to this address implement
the documented `Vault → Settlement` revenue flow described in
`set_settlement`. This function emits a `set_settlement` event to provide a
clear audit trail for address rotation. Transfers to this address implement
the documented `Vault → Settlement` revenue flow described in
`SETTLEMENT_IMPLEMENTATION.md`.
- **revenue_pool address**: set and updated exclusively by the on-chain admin via
`set_revenue_pool`. Transfers to this address route product revenue to the
designated pool contract.
- **Priority rule**: when both are configured, `settlement` takes priority and
`revenue_pool` is not used in the same deduct. This prevents "half updated"
routing states where funds could be split unexpectedly across two recipients.
- **CRITICAL - Routing Required**: At least one routing address (settlement OR
revenue_pool) MUST be configured before any deduct operations can succeed.
If neither is configured, `deduct()` and `batch_deduct()` will panic with
`"routing not configured: set settlement or revenue_pool address"`. This
prevents silent fund retention and ensures explicit routing configuration.
- **revenue_pool address**: retained as an informational configuration slot via
`set_revenue_pool` / `get_revenue_pool`. It is **no longer consulted during
deducts** — `deduct` and `batch_deduct` always route to the settlement address.
- **CRITICAL — Settlement Required (Issue #263)**: `deduct` and `batch_deduct`
panic with `"settlement address not set"` when `set_settlement` has not been
called. The panic occurs before any balance mutation or event emission, so
the transaction reverts atomically with no observable state change. This
closes the silent-loss-of-accounting window where the internal `balance`
could previously decrement without a corresponding on-ledger USDC transfer.
- **Address Validation**: Both `set_settlement()` and `set_revenue_pool()` validate
that the provided address is NOT the vault's own address, preventing
self-referential routing loops.
Expand Down
22 changes: 8 additions & 14 deletions SETTLEMENT_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,18 @@ StorageKey::RevenuePool // Fallback routing address (used if Settlement not s

#### Routing Validation

**CRITICAL**: The vault enforces that at least one routing address MUST be configured before any deduct operations can succeed. This is validated via `require_routing_configured()` which is called at the beginning of both `deduct()` and `batch_deduct()`.
**CRITICAL**: The vault enforces that the settlement address MUST be configured before any deduct operation can succeed. This is validated via `require_settlement()` which is consulted by both `deduct()` and `batch_deduct()`.

- If neither `settlement` nor `revenue_pool` is configured: **PANIC** with `"routing not configured: set settlement or revenue_pool address"`
- This prevents silent fund retention and ensures explicit routing configuration
- Both addresses are validated to prevent self-referential routing (vault → vault)
- If `settlement` is not configured: **PANIC** with `"settlement address not set"` and the transaction reverts with no state change.
- This prevents silent loss-of-accounting where the vault's internal `balance` could drift from the on-ledger USDC balance.
- The settlement address is validated at configuration time to prevent self-referential routing (vault → vault).

#### Routing Priority
#### Routing

When deduct operations occur, funds are routed according to this priority:
Every `deduct` / `batch_deduct` call routes the deducted USDC to the configured settlement address. `revenue_pool` is **not** consulted during deducts; it is retained as an informational configuration slot only.

1. **If `settlement` is configured** → Route to settlement contract (highest priority)
2. **Else if `revenue_pool` is configured** → Route to revenue pool contract
3. **Else** → Deduct operation FAILS (routing not configured)

This priority system ensures:
- No "half-configured" states where funds could be split unexpectedly
- Deterministic routing behavior
- Clear audit trail for all fund movements
- **`settlement` set** → funds transferred to settlement contract.
- **`settlement` unset** → deduct panics with `"settlement address not set"`, no balance change, no event emitted.



Expand Down
86 changes: 50 additions & 36 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,23 @@ impl CalloraVault {
meta.balance
}

/// Deduct USDC from the vault and transfer it to the configured settlement address.
///
/// # Preconditions
/// - The settlement address must have been registered via `set_settlement`
/// before this function can succeed. Attempting to deduct without a
/// configured settlement address panics with `"settlement address not set"`
/// and reverts the transaction, leaving vault state unchanged.
/// - `amount` must be positive and less than or equal to `max_deduct`.
/// - `caller` must be either the owner or the `authorized_caller` (if set).
/// - The vault's internal balance must cover `amount`.
///
/// # Panics
/// - `"settlement address not set"` — `set_settlement` has not been called.
/// - `"amount must be positive"` — `amount <= 0`.
/// - `"deduct amount exceeds max_deduct"` — `amount > max_deduct`.
/// - `"unauthorized caller"` — caller is not owner or authorized caller.
/// - `"insufficient balance"` — vault balance below `amount`.
pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option<Symbol>) -> i128 {
Self::require_not_paused(env.clone());
caller.require_auth();
Expand All @@ -421,16 +438,12 @@ impl CalloraVault {
.checked_sub(amount)
.unwrap_or_else(|| panic!("balance underflow"));
env.storage().instance().set(&StorageKey::Meta, &meta);
let inst = env.storage().instance();
if let Some(s) = inst.get(&StorageKey::Settlement) {
let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap();
Self::transfer_funds(&env, &ut, &s, amount);
} else if inst
.get::<StorageKey, Address>(&StorageKey::RevenuePool)
.is_some()
{
Self::transfer_to_revenue_pool(env.clone(), amount);
}
let ut: Address = env
.storage()
.instance()
.get(&StorageKey::UsdcToken)
.unwrap();
Self::transfer_funds(&env, &ut, &settlement, amount);
let rid = request_id.unwrap_or(Symbol::new(&env, ""));
env.events().publish(
(Symbol::new(&env, "deduct"), caller, rid),
Expand Down Expand Up @@ -477,16 +490,12 @@ impl CalloraVault {

meta.balance = running;

let inst = env.storage().instance();
if let Some(s) = inst.get(&StorageKey::Settlement) {
let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap();
Self::transfer_funds(&env, &ut, &s, total);
} else if inst
.get::<StorageKey, Address>(&StorageKey::RevenuePool)
.is_some()
{
Self::transfer_to_revenue_pool(env.clone(), total);
}
let ut: Address = env
.storage()
.instance()
.get(&StorageKey::UsdcToken)
.unwrap();
Self::transfer_funds(&env, &ut, &settlement, total);

meta.balance = running;
env.storage().instance().set(&StorageKey::Meta, &meta);
Expand Down Expand Up @@ -618,8 +627,9 @@ impl CalloraVault {
/// Store the settlement contract address (admin only).
///
/// Once set, every `deduct` / `batch_deduct` call transfers the deducted USDC to
/// this address. Settlement takes priority over `revenue_pool` when both are
/// configured.
/// this address. `set_settlement` is a hard precondition: `deduct` and
/// `batch_deduct` panic with `"settlement address not set"` until this function
/// has been called. `revenue_pool` is no longer consulted during deductions.
///
/// # Panics
/// Panics if `caller` is not the current admin.
Expand Down Expand Up @@ -664,10 +674,10 @@ impl CalloraVault {
/// A tuple `(usdc_token, settlement, revenue_pool)`:
/// - `usdc_token` — always `Some` after `init`; the USDC token contract address.
/// - `settlement` — `Some` after `set_settlement` is called, otherwise `None`.
/// Must be `Some` before any `deduct` / `batch_deduct` call can succeed.
/// - `revenue_pool` — `Some` after `set_revenue_pool` is called, otherwise `None`.
///
/// When both `settlement` and `revenue_pool` are `Some`, **`settlement` takes
/// priority** and the revenue pool is not used in the same deduct call.
/// Informational only; `deduct` / `batch_deduct` always route to `settlement`
/// and never fall back to the revenue pool.
///
/// # Example — Stellar CLI
/// ```text
Expand All @@ -679,8 +689,9 @@ impl CalloraVault {
/// # Operator checklist
/// 1. `usdc_token` must be the canonical Stellar USDC issuer
/// (`GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` on mainnet).
/// 2. `settlement` should be `Some` before routing production traffic.
/// 3. `revenue_pool` is optional; only active when `settlement` is `None`.
/// 2. `settlement` must be `Some` before any deduct call; it is the sole
/// destination for deducted USDC.
/// 3. `revenue_pool` is informational only and is not consulted during deducts.
pub fn get_contract_addresses(env: Env) -> (Option<Address>, Option<Address>, Option<Address>) {
let inst = env.storage().instance();
let usdc: Option<Address> = inst.get(&StorageKey::UsdcToken);
Expand Down Expand Up @@ -756,15 +767,18 @@ impl CalloraVault {
token::Client::new(env, usdc_token).transfer(&env.current_contract_address(), to, &amount);
}

fn transfer_to_revenue_pool(env: Env, amount: i128) {
let inst = env.storage().instance();
let rp: Address = inst
.get(&StorageKey::RevenuePool)
.expect("revenue pool address not set");
let ua: Address = inst
.get(&StorageKey::UsdcToken)
.expect("vault not initialized");
token::Client::new(&env, &ua).transfer(&env.current_contract_address(), &rp, &amount);
fn require_settlement(env: &Env) -> Address {
env.storage()
.instance()
.get(&StorageKey::Settlement)
.unwrap_or_else(|| panic!("settlement address not set"))
}

fn get_max_deduct(env: Env) -> i128 {
env.storage()
.instance()
.get(&StorageKey::MaxDeduct)
.unwrap_or(DEFAULT_MAX_DEDUCT)
}

fn require_not_paused(env: Env) {
Expand Down
Loading
Loading