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
19 changes: 16 additions & 3 deletions SETTLEMENT_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

This document describes the implementation of revenue settlement functionality that allows the vault contract to automatically transfer USDC to a settlement contract when deductions occur. The settlement contract then credits either a global pool or specific developer balances.

## Reconciliation Contract (Vault ↔ Settlement)

The integration between the vault and settlement contracts ensures that tracked balances stay in sync across both systems. Here's how it works:

1. **Atomic Operations**: All operations (validation → token transfer → settlement contract call → state update) happen atomically. If any step fails, the entire transaction reverts with no partial state changes.
2. **Reconciliation Flow**:
- The vault contract first validates the deduct/batch-deduct request
- It transfers USDC tokens to the settlement contract
- It calls `settlement_client.receive_payment(..., to_pool=true, developer=None)` to notify the settlement contract to credit the global pool
- Only after the cross‑contract call succeeds does the vault update its own internal balance
3. **`to_pool` Semantics**: For all vault‑originated deducts and batch deducts, the deducted amount is always credited to the **global pool** in the settlement contract.

## Architecture

### Components
Expand All @@ -12,6 +24,7 @@ This document describes the implementation of revenue settlement functionality t
- Enhanced with settlement contract integration
- Automatically transfers USDC to settlement on `deduct()` and `batch_deduct()`
- Maintains settlement contract address configuration
- Uses cross‑contract calls to `settlement_client.receive_payment()` to ensure reconciliation

2. **Settlement Contract (`callora-settlement`)**
- Receives USDC payments from vault
Expand All @@ -29,13 +42,13 @@ sequenceDiagram

API->>Vault: deduct(env, caller, amount, request_id)
Vault->>Vault: Validate Auth & Balance
Vault->>Vault: Update internal balance
Vault->>USDC: transfer(vault, settlement, amount)
USDC-->>Vault: Transfer complete
Vault->>Settlement: receive_payment(env, vault, amount, ...)
Vault->>Settlement: receive_payment(vault, amount, to_pool=true, developer=None)
Settlement->>Settlement: Validate caller (vault)
Settlement->>Settlement: Update Global Pool or Dev Balance
Settlement->>Settlement: Update Global Pool
Settlement-->>Vault: Payment successful
Vault->>Vault: Update internal balance & mark request processed
Vault-->>API: Return new balance
```

Expand Down
57 changes: 37 additions & 20 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,36 @@ pub const MAX_BATCH_SIZE: u32 = 50;
/// Callers and indexers can match on the code rather than parsing raw panic strings,
/// and the WASM binary shrinks because no error string literals are embedded.
///
/// | Code | Variant | When |
/// |------|----------------------|---------------------------------------------------|
/// | 1 | NotInitialized | A function is called before `init` |
/// | 2 | AlreadyInitialized | `init` is called more than once |
/// | 3 | Unauthorized | Caller is not the vault or admin |
/// | 4 | AmountNotPositive | `amount` is zero or negative |
/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied |
/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given |
/// | 7 | PoolOverflow | Global pool `i128` addition would overflow |
/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow |
/// | Code | Variant | When |
/// |------|------------------------------|---------------------------------------------------|
/// | 1 | NotInitialized | A function is called before `init` |
/// | 2 | AlreadyInitialized | `init` is called more than once |
/// | 3 | Unauthorized | Caller is not the vault or admin |
/// | 4 | AmountNotPositive | `amount` is zero or negative |
/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied |
/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given |
/// | 7 | PoolOverflow | Global pool `i128` addition would overflow |
/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow |
/// | 9 | UsdcTokenNotConfigured | USDC token address not configured for withdrawals |
/// | 10 | InsufficientDeveloperBalance | Developer balance is less than withdrawal amount |
/// | 11 | DeveloperBalanceUnderflow | Developer balance subtraction would overflow |
/// | 12 | InsufficientContractBalance | Settlement contract lacks on-ledger USDC |
#[contracterror]
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u32)]
pub enum SettlementError {
NotInitialized = 1,
AlreadyInitialized = 2,
Unauthorized = 3,
AmountNotPositive = 4,
DeveloperRequired = 5,
DeveloperMustBeNone = 6,
PoolOverflow = 7,
DeveloperOverflow = 8,
NotInitialized = 1,
AlreadyInitialized = 2,
Unauthorized = 3,
AmountNotPositive = 4,
DeveloperRequired = 5,
DeveloperMustBeNone = 6,
PoolOverflow = 7,
DeveloperOverflow = 8,
UsdcTokenNotConfigured = 9,
InsufficientDeveloperBalance = 10,
DeveloperBalanceUnderflow = 11,
InsufficientContractBalance = 12,
}

/// Maximum number of items accepted by `batch_receive_payment`.
Expand Down Expand Up @@ -111,6 +119,15 @@ pub struct VaultAcceptedEvent {
pub accepted_by: Address,
}

/// Emitted when a developer withdraws their balance.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct DeveloperWithdrawEvent {
pub developer: Address,
pub amount: i128,
pub remaining_balance: i128,
}


#[contract]
pub struct CalloraSettlement;
Expand Down Expand Up @@ -662,12 +679,12 @@ impl CalloraSettlement {

let inst = env.storage().instance();
let old_vault = Self::get_vault(env.clone());
inst.set(&StorageKey::Vault, &new_vault);
inst.set(&StorageKey::PendingVault, &new_vault);

env.events().publish(
(Symbol::new(&env, "vault_proposed"), caller),
VaultProposedEvent {
current_vault,
current_vault: old_vault,
proposed_vault: new_vault,
},
);
Expand Down
79 changes: 65 additions & 14 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,8 @@ impl CalloraVault {
Ok(meta.balance)
}

/// Deduct USDC from the vault and transfer it to the configured settlement address.
/// Deduct USDC from the vault and transfer it to the configured settlement address,
/// then notify the settlement contract to credit the global pool.
///
/// # Preconditions
/// - `set_settlement` must have been called; returns error otherwise.
Expand All @@ -578,6 +579,11 @@ impl CalloraVault {
/// is persisted in temporary storage for `REQUEST_ID_BUMP_AMOUNT` ledgers.
///
/// When `request_id` is `None`, no deduplication is performed.
///
/// # `to_pool` Semantics (Vault-Originated Deducts)
/// For deducts initiated via this vault contract, the deducted amount is always
/// credited to the **global pool** in the settlement contract. This is done
/// by calling `settlement_client.receive_payment(..., to_pool=true, developer=None)`.
pub fn deduct(
env: Env,
caller: Address,
Expand All @@ -598,11 +604,36 @@ impl CalloraVault {
if let Some(ref rid) = request_id {
Self::require_not_duplicate(&env, rid)?;
}
let mut meta = Self::get_meta(env.clone())?;
let meta = Self::get_meta(env.clone())?;
if meta.balance < amount {
return Err(VaultError::InsufficientBalance);
}
let settlement = Self::require_settlement(&env)?;
let ut: Address = env
.storage()
.instance()
.get(&StorageKey::UsdcToken)
.ok_or(VaultError::NotInitialized)?;

// Perform all external operations FIRST, so that if any fail,
// the entire transaction reverts with no partial state changes.
Self::transfer_funds(&env, &ut, &settlement, amount);

// Create a settlement client and call receive_payment to credit the global pool
#[contractclient(name = "SettlementClient")]
trait Settlement {
fn receive_payment(env: Env, caller: Address, amount: i128, to_pool: bool, developer: Option<Address>);
}
let settlement_client = SettlementClient::new(&env, &settlement);
settlement_client.receive_payment(
env.current_contract_address(),
amount,
true, // to_pool = true: credit global pool
None, // no specific developer
);

// Now that external operations succeeded, update internal state
let mut meta = Self::get_meta(env.clone())?;
meta.balance = meta
.balance
.checked_sub(amount)
Expand All @@ -615,12 +646,7 @@ impl CalloraVault {
if let Some(ref rid) = request_id {
Self::mark_request_processed(&env, rid);
}
let ut: Address = env
.storage()
.instance()
.get(&StorageKey::UsdcToken)
.ok_or(VaultError::NotInitialized)?;
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 All @@ -642,6 +668,11 @@ impl CalloraVault {
/// the batch are marked as processed.
///
/// Items with `request_id = None` are not deduplicated.
///
/// # `to_pool` Semantics (Vault-Originated Batch Deducts)
/// For batch deducts initiated via this vault contract, the total deducted amount
/// is always credited to the **global pool** in the settlement contract.
/// This is done by calling `settlement_client.receive_payment(..., to_pool=true, developer=None)`.
pub fn batch_deduct(
env: Env,
caller: Address,
Expand Down Expand Up @@ -687,6 +718,31 @@ impl CalloraVault {
total = total.checked_add(item.amount).ok_or(VaultError::Overflow)?;
}
let settlement = Self::require_settlement(&env)?;
let ut: Address = env
.storage()
.instance()
.get(&StorageKey::UsdcToken)
.ok_or(VaultError::NotInitialized)?;

// Perform all external operations FIRST, so that if any fail,
// the entire transaction reverts with no partial state changes.
Self::transfer_funds(&env, &ut, &settlement, total);

// Create a settlement client and call receive_payment to credit the global pool
#[contractclient(name = "SettlementClient")]
trait Settlement {
fn receive_payment(env: Env, caller: Address, amount: i128, to_pool: bool, developer: Option<Address>);
}
let settlement_client = SettlementClient::new(&env, &settlement);
settlement_client.receive_payment(
env.current_contract_address(),
total,
true, // to_pool = true: credit global pool
None, // no specific developer
);

// Now that external operations succeeded, update internal state
let mut meta = Self::get_meta(env.clone())?;
meta.balance = running;
env.storage().instance().set(&StorageKey::MetaKey, &meta);
env.storage()
Expand All @@ -698,12 +754,7 @@ impl CalloraVault {
Self::mark_request_processed(&env, rid);
}
}
let ut: Address = env
.storage()
.instance()
.get(&StorageKey::UsdcToken)
.ok_or(VaultError::NotInitialized)?;
Self::transfer_funds(&env, &ut, &settlement, total);

for item in items.iter() {
let rid = item.request_id.unwrap_or(Symbol::new(&env, ""));
env.events().publish(
Expand Down
Loading