diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs
index f49ca7e..3d82f10 100644
--- a/contracts/vault/src/lib.rs
+++ b/contracts/vault/src/lib.rs
@@ -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
,
/// Minimum amount required per deposit; deposits below this panic.
pub min_deposit: i128,
}
@@ -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,
+ authorized_caller: Option,
+ ) -> VaultMeta {
///
/// # Arguments
/// * `revenue_pool` – Optional address to receive USDC on each deduct (e.g. settlement contract). If None, USDC stays in vault.
@@ -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.
@@ -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.
@@ -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.
@@ -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;
@@ -320,6 +361,9 @@ 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) -> 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.
@@ -327,6 +371,17 @@ impl CalloraVault {
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");
diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs
index b445bc8..4c339fa 100644
--- a/contracts/vault/src/test.rs
+++ b/contracts/vault/src/test.rs
@@ -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(),
@@ -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));
@@ -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 {
@@ -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();
@@ -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);
@@ -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);
@@ -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();
@@ -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();
@@ -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();
@@ -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));
+}