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
55 changes: 55 additions & 0 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Ve
pub struct VaultMeta {
pub owner: Address,
pub balance: i128,
pub authorized_caller: Option<Address>,
/// Minimum amount required per deposit; deposits below this panic.
pub min_deposit: i128,
}
Expand Down Expand Up @@ -62,6 +63,12 @@ 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).
/// Emits an "init" event with the owner address and initial balance.
pub fn init(
env: Env,
owner: Address,
initial_balance: Option<i128>,
authorized_caller: Option<Address>,
) -> VaultMeta {
///
/// # Arguments
/// * `revenue_pool` – Optional address to receive USDC on each deduct (e.g. settlement contract). If None, USDC stays in vault.
Expand Down Expand Up @@ -105,6 +112,7 @@ impl CalloraVault {
let meta = VaultMeta {
owner: owner.clone(),
balance,
authorized_caller,
min_deposit: min_deposit_val,
};
// Persist metadata under both the literal key and the constant for safety.
Expand Down Expand Up @@ -225,6 +233,25 @@ impl CalloraVault {
.unwrap_or_else(|| panic!("vault not initialized"))
}

/// 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());
meta.owner.require_auth();

meta.authorized_caller = Some(caller.clone());
env.storage()
.instance()
.set(&Symbol::new(&env, "meta"), &meta);

env.events().publish(
(Symbol::new(&env, "set_auth_caller"), meta.owner.clone()),
caller,
);
}

/// Deposit increases balance. Callable by owner or designated depositor.
/// Emits a "deposit" event with amount and new balance.
pub fn deposit(env: Env, amount: i128) -> i128 {
/// 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.
Expand Down Expand Up @@ -262,6 +289,9 @@ impl CalloraVault {
meta.balance
}

/// 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 {
/// Deduct balance for an API call. Callable by authorized caller (e.g. backend).
/// Amount must not exceed max single deduct (see init / get_max_deduct).
/// If revenue pool is set, USDC is transferred to it; otherwise it remains in the vault.
Expand Down Expand Up @@ -301,6 +331,17 @@ impl CalloraVault {
assert!(!Self::paused(env.clone()), "vault is paused");

let mut meta = Self::get_meta(env.clone());

// Ensure the caller corresponds to the address signing the transaction.
caller.require_auth();

// Check authorization: must be either the authorized_caller if set, or the owner.
let authorized = match &meta.authorized_caller {
Some(auth_caller) => caller == *auth_caller || caller == meta.owner,
None => caller == meta.owner,
};
assert!(authorized, "unauthorized caller");

assert!(meta.balance >= amount, "insufficient balance");

meta.balance -= amount;
Expand All @@ -320,13 +361,27 @@ impl CalloraVault {
}

/// Batch deduct: multiple (amount, optional request_id) in one transaction.
/// Reverts the entire batch if any single deduct would exceed balance.
/// Emits one "deduct" event per item (same shape as single deduct).
pub fn batch_deduct(env: Env, caller: Address, items: Vec<DeductItem>) -> i128 {
/// Each amount must not exceed max_deduct. Reverts entire batch if any check fails.
/// If revenue pool is set, total deducted USDC is transferred to it once.
/// Emits one "deduct" event per item.
pub fn batch_deduct(env: Env, caller: Address, items: Vec<DeductItem>) -> i128 {
caller.require_auth();
let max_deduct = Self::get_max_deduct(env.clone());
let mut meta = Self::get_meta(env.clone());

// Ensure the caller corresponds to the address signing the transaction.
caller.require_auth();

// Check authorization: must be either the authorized_caller if set, or the owner.
let authorized = match &meta.authorized_caller {
Some(auth_caller) => caller == *auth_caller || caller == meta.owner,
None => caller == meta.owner,
};
assert!(authorized, "unauthorized caller");

let n = items.len();
assert!(n > 0, "batch_deduct requires at least one item");

Expand Down
117 changes: 117 additions & 0 deletions contracts/vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ fn init_with_balance_emits_event() {
env.mock_all_auths();
// Call init directly inside as_contract so events are captured
let events = env.as_contract(&contract_id, || {
CalloraVault::init(env.clone(), owner.clone(), Some(1000), None);
CalloraVault::init(
env.clone(),
owner.clone(),
Expand Down Expand Up @@ -161,6 +162,11 @@ fn get_meta_returns_owner_and_balance() {
let client = CalloraVaultClient::new(&env, &contract_id);
let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner);

client.init(&owner, &Some(100), &None);
client.deposit(&200);
assert_eq!(client.balance(), 300);
env.mock_all_auths();
client.deduct(&owner, &50);
env.mock_all_auths();
client.init(&owner, &Some(100));

Expand Down Expand Up @@ -374,6 +380,10 @@ fn batch_deduct_events_contain_request_ids() {
let rid_a = Symbol::new(&env, "batch_a");
let rid_b = Symbol::new(&env, "batch_b");

client.init(&owner, &Some(1000), &None);
let req1 = Symbol::new(&env, "req1");
let req2 = Symbol::new(&env, "req2");
let items = vec![
let items = soroban_sdk::vec![
&env,
DeductItem {
Expand All @@ -385,6 +395,10 @@ fn batch_deduct_events_contain_request_ids() {
request_id: Some(rid_b.clone()),
},
];
env.mock_all_auths();
let new_balance = client.batch_deduct(&owner, &items);
assert_eq!(new_balance, 650);
assert_eq!(client.balance(), 650);
client.batch_deduct(&caller, &items);

let all_events = env.events().all();
Expand Down Expand Up @@ -412,6 +426,20 @@ fn get_admin_returns_correct_address() {
let client = CalloraVaultClient::new(&env, &contract_id);
let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner);

client.init(&owner, &Some(100), &None);
let items = vec![
&env,
DeductItem {
amount: 60,
request_id: None,
},
DeductItem {
amount: 60,
request_id: None,
}, // total 120 > 100
];
env.mock_all_auths();
client.batch_deduct(&owner, &items);
env.mock_all_auths();
fund_vault(&usdc_admin, &contract_id, 100);
client.init(&owner, &usdc_token, &Some(100), &None, &None, &None);
Expand All @@ -430,6 +458,7 @@ fn set_admin_updates_admin() {
let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner);
client.init(&owner, &Some(100));

client.init(&owner, &Some(500), &None);
env.mock_all_auths();
fund_vault(&usdc_admin, &contract_id, 100);
client.init(&owner, &usdc_token, &Some(100), &None, &None, &None);
Expand Down Expand Up @@ -519,6 +548,7 @@ fn distribute_insufficient_usdc_fails() {
);
}

client.init(&owner, &Some(100), &None);
#[test]
fn distribute_zero_amount_fails() {
let env = Env::default();
Expand Down Expand Up @@ -548,6 +578,7 @@ fn set_and_retrieve_metadata() {
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(50), &None);
client.init(&owner, &Some(100));

env.mock_all_auths();
Expand Down Expand Up @@ -623,6 +654,7 @@ fn update_metadata_and_verify() {
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(500), &None);
client.init(&owner, &Some(100));

env.mock_all_auths();
Expand Down Expand Up @@ -1837,7 +1869,92 @@ fn init_unauthorized_owner_panics() {
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(100), &None);
client.withdraw(&50);
// Call init without mocking authorization for `owner`.
// It should panic at `owner.require_auth()`, preventing unauthorized or zero-address initialization.
client.init(&owner, &Some(100));
}

#[test]
fn authorized_caller_deduct_success() {
let env = Env::default();
let owner = Address::generate(&env);
let authorized = Address::generate(&env);
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(1000), &Some(authorized.clone()));

// Auth as authorized caller
env.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &authorized,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &contract_id,
fn_name: "deduct",
args: (authorized.clone(), 100i128).into_val(&env),
sub_invokes: &[],
},
}]);

client.deduct(&authorized, &100);
assert_eq!(client.balance(), 900);
}

#[test]
fn owner_can_always_deduct() {
let env = Env::default();
let owner = Address::generate(&env);
let authorized = Address::generate(&env);
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(1000), &Some(authorized));

env.mock_all_auths();
client.deduct(&owner, &100);
assert_eq!(client.balance(), 900);
}

#[test]
#[should_panic]
fn unauthorized_caller_deduct_fails() {
let env = Env::default();
let owner = Address::generate(&env);
let authorized = Address::generate(&env);
let attacker = Address::generate(&env);
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(1000), &Some(authorized));

// Auth as attacker
env.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &attacker,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &contract_id,
fn_name: "deduct",
args: (attacker.clone(), 100i128).into_val(&env),
sub_invokes: &[],
},
}]);

client.deduct(&attacker, &100);
}

#[test]
fn set_authorized_caller_owner_only() {
let env = Env::default();
let owner = Address::generate(&env);
let new_auth = Address::generate(&env);
let contract_id = env.register(CalloraVault {}, ());
let client = CalloraVaultClient::new(&env, &contract_id);

client.init(&owner, &Some(1000), &None);

env.mock_all_auths();
client.set_authorized_caller(&new_auth);

let meta = client.get_meta();
assert_eq!(meta.authorized_caller, Some(new_auth));
}
Loading