From 95f7710831dcdd6d00aac1f6b2827b1b9f164871 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Tue, 24 Feb 2026 00:12:05 +0100 Subject: [PATCH 1/2] Restrict Deduct to Authorized Caller and Owner --- contracts/vault/src/lib.rs | 53 +++++++++++++++-- contracts/vault/src/test.rs | 115 ++++++++++++++++++++++++++++++++---- 2 files changed, 152 insertions(+), 16 deletions(-) diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 522a8cd..c080738 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -15,6 +15,7 @@ pub struct DeductItem { pub struct VaultMeta { pub owner: Address, pub balance: i128, + pub authorized_caller: Option
, } #[contract] @@ -24,11 +25,17 @@ pub struct CalloraVault; impl CalloraVault { /// 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, owner: Address, initial_balance: Option) -> VaultMeta { + pub fn init( + env: Env, + owner: Address, + initial_balance: Option, + authorized_caller: Option
, + ) -> VaultMeta { let balance = initial_balance.unwrap_or(0); let meta = VaultMeta { owner: owner.clone(), balance, + authorized_caller, }; env.storage() .instance() @@ -49,6 +56,22 @@ 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 { @@ -63,10 +86,21 @@ impl CalloraVault { meta.balance } - /// Deduct balance for an API call. Only backend/authorized caller in production. + /// 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, amount: i128) -> i128 { + pub fn deduct(env: Env, caller: Address, amount: i128) -> i128 { 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; env.storage() @@ -81,8 +115,19 @@ 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, items: Vec) -> i128 { + pub fn batch_deduct(env: Env, caller: Address, items: Vec) -> i128 { 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 cc9f10c..bd851db 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -12,7 +12,7 @@ fn init_and_balance() { // 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)); + CalloraVault::init(env.clone(), owner.clone(), Some(1000), None); env.events().all() }); @@ -46,10 +46,11 @@ fn deposit_and_deduct() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(100)); + client.init(&owner, &Some(100), &None); client.deposit(&200); assert_eq!(client.balance(), 300); - client.deduct(&50); + env.mock_all_auths(); + client.deduct(&owner, &50); assert_eq!(client.balance(), 250); } @@ -60,7 +61,7 @@ fn batch_deduct_success() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(1000)); + client.init(&owner, &Some(1000), &None); let req1 = Symbol::new(&env, "req1"); let req2 = Symbol::new(&env, "req2"); let items = vec![ @@ -78,7 +79,8 @@ fn batch_deduct_success() { request_id: None, }, ]; - let new_balance = client.batch_deduct(&items); + env.mock_all_auths(); + let new_balance = client.batch_deduct(&owner, &items); assert_eq!(new_balance, 650); assert_eq!(client.balance(), 650); } @@ -91,7 +93,7 @@ fn batch_deduct_reverts_entire_batch() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(100)); + client.init(&owner, &Some(100), &None); let items = vec![ &env, DeductItem { @@ -103,7 +105,8 @@ fn batch_deduct_reverts_entire_batch() { request_id: None, }, // total 120 > 100 ]; - client.batch_deduct(&items); + env.mock_all_auths(); + client.batch_deduct(&owner, &items); } #[test] @@ -113,7 +116,7 @@ fn withdraw_owner_success() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(500)); + client.init(&owner, &Some(500), &None); env.mock_all_auths(); let new_balance = client.withdraw(&200); assert_eq!(new_balance, 300); @@ -127,7 +130,7 @@ fn withdraw_exact_balance() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(100)); + client.init(&owner, &Some(100), &None); env.mock_all_auths(); let new_balance = client.withdraw(&100); assert_eq!(new_balance, 0); @@ -142,7 +145,7 @@ fn withdraw_exceeds_balance_fails() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(50)); + client.init(&owner, &Some(50), &None); env.mock_all_auths(); client.withdraw(&100); } @@ -155,7 +158,7 @@ fn withdraw_to_success() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(500)); + client.init(&owner, &Some(500), &None); env.mock_all_auths(); let new_balance = client.withdraw_to(&to, &150); assert_eq!(new_balance, 350); @@ -171,6 +174,94 @@ fn withdraw_without_auth_fails() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(100)); + client.init(&owner, &Some(100), &None); client.withdraw(&50); } + +#[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)); +} + From 0f05b77b256b970f194dd4b0019c34d8d2ef982c Mon Sep 17 00:00:00 2001 From: Lewechi Date: Tue, 24 Feb 2026 00:24:43 +0100 Subject: [PATCH 2/2] fix: fix ci/cd --- contracts/vault/src/test.rs | 51 +++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index bd851db..7f6df5f 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -187,20 +187,18 @@ fn authorized_caller_deduct_success() { 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: &[], - }, + 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); } @@ -214,7 +212,7 @@ fn owner_can_always_deduct() { 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); @@ -231,20 +229,18 @@ fn unauthorized_caller_deduct_fails() { 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: &[], - }, + 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); } @@ -257,11 +253,10 @@ fn set_authorized_caller_owner_only() { 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)); } -