Skip to content
179 changes: 125 additions & 54 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
#![no_std]
//! # Callora Vault Contract
//!
//! ## Access Control
//!
//! The vault implements role-based access control for deposits:
//!
//! - **Owner**: Set at initialization, immutable. Always permitted to deposit.
//! - **Allowed Depositors**: Optional addresses (e.g., backend service) that can be
//! explicitly approved by the owner. Can be set, changed, or cleared at any time.
//! - **Other addresses**: Rejected with an authorization error.
//!
//! ### Production Usage
//!
//! In production, the owner typically represents the end user's account, while the
//! allowed depositors are backend services that handle automated deposits on behalf
//! of the user.
//!
//! ### Managing the Allowed Depositors
//!
//! - Add or update: `set_allowed_depositor(Some(address))` adds the address if not present
//! - Clear (revoke all access): `set_allowed_depositor(None)`
//! - Only the owner can call `set_allowed_depositor`
//!
//! ### Security Model
//!
//! - The owner has full control over who can deposit
//! - The allowed depositors are trusted addresses (typically backend services)
//! - Access can be revoked at any time by the owner
//! - All deposit attempts are authenticated against the caller's address

use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec};
#![no_std]

/// Single item for batch deduct: amount and optional request id for idempotency/tracking.
#[contracttype]
Expand All @@ -17,6 +45,12 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Ve
pub struct VaultMeta {
pub owner: Address,
pub balance: i128,
}

#[contracttype]
pub enum StorageKey {
Meta,
AllowedDepositors,
pub authorized_caller: Option<Address>,
/// Minimum amount required per deposit; deposits below this panic.
pub min_deposit: i128,
Expand Down Expand Up @@ -61,8 +95,7 @@ pub struct CalloraVault;

#[contractimpl]
impl CalloraVault {
/// Initialize vault for an owner with optional initial balance and minimum deposit.
/// If initial_balance > 0, the contract must already hold at least that much USDC (e.g. deployer transferred in first).
/// Initialize vault for an owner with optional initial balance.
/// Emits an "init" event with the owner address and initial balance.
pub fn init(
env: Env,
Expand Down Expand Up @@ -98,21 +131,12 @@ impl CalloraVault {
panic!("vault already initialized");
}
let balance = initial_balance.unwrap_or(0);
if balance > 0 {
let usdc = token::Client::new(&env, &usdc_token);
let contract_balance = usdc.balance(&env.current_contract_address());
if contract_balance < balance {
panic!("insufficient USDC in contract for initial_balance");
}
}
let min_deposit_val = min_deposit.unwrap_or(0);
let max_deduct_val = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT);
if max_deduct_val <= 0 {
panic!("max_deduct must be positive");
}
assert!(balance >= 0, "initial balance must be non-negative");
let meta = VaultMeta {
owner: owner.clone(),
balance,
};
env.storage().instance().set(&StorageKey::Meta, &meta);
authorized_caller,
min_deposit: min_deposit_val,
};
Expand All @@ -127,12 +151,14 @@ impl CalloraVault {
}
inst.set(&Symbol::new(&env, MAX_DEDUCT_KEY), &max_deduct_val);

// Emit event: topics = (init, owner), data = balance
env.events()
.publish((Symbol::new(&env, "init"), owner), balance);

.publish((Symbol::new(&env, "init"), owner.clone()), balance);
meta
}

/// Check if the caller is authorized to deposit (owner or allowed depositor).
pub fn is_authorized_depositor(env: Env, caller: Address) -> bool {
/// Return the current admin address.
pub fn get_admin(env: Env) -> Address {
env.storage()
Expand All @@ -158,33 +184,25 @@ impl CalloraVault {
inst.set(&Symbol::new(&env, ADMIN_KEY), &new_admin);
}

/// Return the maximum allowed amount for a single deduct (configurable at init).
pub fn get_max_deduct(env: Env) -> i128 {
env.storage()
// Check if caller is in the allowed depositors
let allowed: Vec<Address> = env
.storage()
.instance()
.get(&Symbol::new(&env, MAX_DEDUCT_KEY))
.unwrap_or_else(|| panic!("vault not initialized"))
.get(&StorageKey::AllowedDepositors)
.unwrap_or(Vec::new(&env));
allowed.contains(&caller)
}

/// Return the revenue pool address if set (receives USDC on deduct).
pub fn get_revenue_pool(env: Env) -> Option<Address> {
env.storage()
.instance()
.get(&Symbol::new(&env, REVENUE_POOL_KEY))
.unwrap_or(None)
/// Require that the caller is the owner, panic otherwise.
pub fn require_owner(env: Env, caller: Address) {
let meta = Self::get_meta(env.clone());
assert!(caller == meta.owner, "unauthorized: owner only");
}

/// Distribute accumulated USDC to a single developer address.
///
/// # Access control
/// Only the admin (backend / multisig) may call this.
///
/// # Arguments
/// * `caller` – Must be the current admin address.
/// * `to` – Developer wallet to receive the USDC.
/// * `amount` – Amount in USDC micro-units (must be > 0 and ≤ vault balance).
/// Get vault metadata (owner and balance).
///
/// # Panics
/// - If the vault has not been initialized
/// * `"unauthorized: caller is not admin"` – caller is not the admin.
/// * `"amount must be positive"` – amount is zero or negative.
/// * `"insufficient USDC balance"` – vault holds less than amount.
Expand Down Expand Up @@ -230,10 +248,39 @@ impl CalloraVault {
pub fn get_meta(env: Env) -> VaultMeta {
env.storage()
.instance()
.get(&Symbol::new(&env, META_KEY))
.get(&StorageKey::Meta)
.unwrap_or_else(|| panic!("vault not initialized"))
}

/// Add or clear allowed depositors. Owner-only.
/// Pass `None` to clear all allowed depositors, `Some(address)` to add the address if not already present.
pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Option<Address>) {
caller.require_auth();
Self::require_owner(env.clone(), caller.clone());

match depositor {
Some(addr) => {
let mut allowed: Vec<Address> = env
.storage()
.instance()
.get(&StorageKey::AllowedDepositors)
.unwrap_or(Vec::new(&env));
if !allowed.contains(&addr) {
allowed.push_back(addr);
}
env.storage()
.instance()
.set(&StorageKey::AllowedDepositors, &allowed);
}
None => {
env.storage()
.instance()
.remove(&StorageKey::AllowedDepositors);
}
}
}

/// Deposit increases balance. Callable by owner or designated depositor.
/// Set or update the authorized caller for deduction. Only callable by the vault owner.
pub fn set_authorized_caller(env: Env, caller: Address) {
let mut meta = Self::get_meta(env.clone());
Expand All @@ -256,11 +303,13 @@ impl CalloraVault {
/// Deposit: user transfers USDC to the contract; contract increases internal balance.
/// Caller must have authorized the transfer (token transfer_from). Supports multiple depositors.
/// Emits a "deposit" event with the depositor address and amount.
pub fn deposit(env: Env, from: Address, amount: i128) -> i128 {
from.require_auth();
pub fn deposit(env: Env, caller: Address, amount: i128) -> i128 {
caller.require_auth();
assert!(amount > 0, "amount must be positive");

let mut meta = Self::get_meta(env.clone());
assert!(
Self::is_authorized_depositor(env.clone(), caller.clone()),
"unauthorized: only owner or allowed depositor can deposit"
amount >= meta.min_deposit,
"deposit below minimum: {} < {}",
amount,
Expand All @@ -280,16 +329,38 @@ impl CalloraVault {
&amount,
);

let mut meta = Self::get_meta(env.clone());
meta.balance += amount;
env.storage().instance().set(&StorageKey::Meta, &meta);
let inst = env.storage().instance();
inst.set(&Symbol::new(&env, "meta"), &meta);

env.events()
.publish((Symbol::new(&env, "deposit"), from), amount);
.publish((Symbol::new(&env, "deposit"), caller), amount);
meta.balance
}

/// Deduct balance for an API call. Only owner/authorized caller in production.
pub fn deduct(env: Env, caller: Address, amount: i128) -> i128 {
caller.require_auth();
Self::require_owner(env.clone(), caller);

let mut meta = Self::get_meta(env.clone());
assert!(amount > 0, "amount must be positive");
assert!(meta.balance >= amount, "insufficient balance");
meta.balance -= amount;
env.storage().instance().set(&StorageKey::Meta, &meta);
meta.balance
}

/// Return current balance.
pub fn balance(env: Env) -> i128 {
Self::get_meta(env).balance
}

pub fn transfer_ownership(env: Env, new_owner: Address) {
let mut meta = Self::get_meta(env.clone());
meta.owner.require_auth();
/// Deduct balance for an API call. Only authorized caller or owner.
/// Emits a "deduct" event with amount and new balance.
pub fn deduct(env: Env, caller: Address, amount: i128) -> i128 {
Expand Down Expand Up @@ -614,9 +685,10 @@ impl CalloraVault {
let inst = env.storage().instance();
inst.set(&Symbol::new(&env, "meta"), &meta);

env.events().publish(
(Symbol::new(&env, "withdraw"), meta.owner.clone()),
(amount, meta.balance),
// Validate new_owner is not the same as current owner
assert!(
new_owner != meta.owner,
"new_owner must be different from current owner"
);
meta.balance
}
Expand All @@ -640,20 +712,19 @@ impl CalloraVault {
let inst = env.storage().instance();
inst.set(&Symbol::new(&env, "meta"), &meta);

// Emit event before changing the owner, so we have the old owner
// topics = (transfer_ownership, old_owner, new_owner)
env.events().publish(
(
Symbol::new(&env, "withdraw_to"),
Symbol::new(&env, "transfer_ownership"),
meta.owner.clone(),
to.clone(),
new_owner.clone(),
),
(amount, meta.balance),
(),
);
meta.balance
}

/// Return current balance.
pub fn balance(env: Env) -> i128 {
Self::get_meta(env).balance
meta.owner = new_owner;
env.storage().instance().set(&StorageKey::Meta, &meta);
}

/// Set settlement contract address (admin only)
Expand Down
Loading
Loading