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)); +}