From 2bcb79847a807fdb711e5565ab9fcd94772594e4 Mon Sep 17 00:00:00 2001 From: David Ojo Date: Fri, 27 Mar 2026 17:25:34 +0100 Subject: [PATCH 1/4] feat(vault): authorized caller for metering --- contracts/vault/ACCESS_CONTROL.md | 26 ++++ contracts/vault/src/lib.rs | 48 +++++-- contracts/vault/src/test.rs | 230 ++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 11 deletions(-) diff --git a/contracts/vault/ACCESS_CONTROL.md b/contracts/vault/ACCESS_CONTROL.md index db340d7..7f20dd2 100644 --- a/contracts/vault/ACCESS_CONTROL.md +++ b/contracts/vault/ACCESS_CONTROL.md @@ -75,3 +75,29 @@ The `transfer_ownership` function allows the current owner to hand over full con ### Admin Transition The `set_admin` function allows the current admin (typically the owner initially) to delegate operational control (like settlement and distribution) to a dedicated service account. + +--- + +## Migration: Owner-Only Metering → Backend-Signed Metering + +### Background + +In the initial deployment model, only the vault owner could invoke `deduct` and `batch_deduct`. This required the owner's key to be present in the metering path, which is impractical for automated, high-frequency backend services. + +### Current Model + +The `set_authorized_caller` function allows the owner to designate a single backend address (e.g., a matching engine or metering service) that may call deduct flows alongside the owner. Both the owner and the authorized caller are permitted; all other addresses are rejected. + +### Migration Steps + +1. Deploy or upgrade the vault contract containing `set_authorized_caller`. +2. The owner calls `set_authorized_caller(owner, backend_address)` to register the backend signing key. +3. The backend service signs and submits `deduct` / `batch_deduct` transactions using its own key. +4. The owner's key is no longer required in the hot metering path. + +### Operational Notes + +- Only the owner may call `set_authorized_caller`; the backend cannot self-register. +- Rotating the backend key requires calling `set_authorized_caller` again with the new address. The previous address is replaced atomically. +- Every change emits a `set_auth_caller` event (topics: `("set_auth_caller", owner)`, data: `new_caller`) for audit purposes. +- Passing the vault contract's own address or the currently stored address as `new_caller` is rejected. diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 579ab7b..efc83bf 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -325,19 +325,45 @@ impl CalloraVault { } } - /// Sets the authorized caller permitted to trigger deductions. - /// Can only be called by the Owner. - pub fn set_authorized_caller(env: Env, caller: Address) { + /// Sets the address permitted to invoke deduct flows alongside the owner. + /// + /// Only the vault owner may call this function. The new authorized caller must + /// differ from the address already stored; passing the same address is rejected + /// as a meaningless no-op. Passing the vault contract's own address is also + /// rejected. + /// + /// # Arguments + /// * `env` – The environment running the contract. + /// * `owner` – Must be the current vault owner; must authorize this call. + /// * `new_caller` – Address to grant deduction rights. + /// + /// # Panics + /// * `"unauthorized: owner only"` – if `owner` is not the vault owner. + /// * `"new_caller must differ from current authorized caller"` – if `new_caller` + /// is already the stored authorized caller (meaningless update). + /// * `"new_caller must not be the vault contract itself"` – if `new_caller` is + /// the vault's own contract address. + /// + /// # Events + /// Emits topic `("set_auth_caller", owner)` with data `new_caller` on success. + pub fn set_authorized_caller(env: Env, owner: Address, new_caller: Address) { + owner.require_auth(); + Self::require_owner(env.clone(), owner.clone()); + assert!( + new_caller != env.current_contract_address(), + "new_caller must not be the vault contract itself" + ); let mut meta = Self::get_meta(env.clone()); - meta.owner.require_auth(); - - meta.authorized_caller = Some(caller.clone()); + if let Some(ref current) = meta.authorized_caller { + assert!( + new_caller != *current, + "new_caller must differ from current authorized caller" + ); + } + meta.authorized_caller = Some(new_caller.clone()); env.storage().instance().set(&StorageKey::Meta, &meta); - - env.events().publish( - (Symbol::new(&env, "set_auth_caller"), meta.owner.clone()), - caller, - ); + env.events() + .publish((Symbol::new(&env, "set_auth_caller"), owner), new_caller); } /// Emergency pause — blocks `deposit`, `deduct`, and `batch_deduct`. diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 964b9c0..ee28368 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -1729,6 +1729,7 @@ fn get_settlement_before_set_panics() { client.get_settlement(); } + #[test] fn test_clear_allowed_depositors() { let env = Env::default(); @@ -1861,4 +1862,233 @@ fn withdraw_to_zero_succeeds() { client.init(&owner, &usdc, &Some(300), &None, &None, &None, &None); assert_eq!(client.withdraw(&300), 0); +// --------------------------------------------------------------------------- +// set_authorized_caller + deduct authorization matrix tests +// --------------------------------------------------------------------------- + +/// Helper: init a vault with `balance` and no pre-set authorized caller. +/// Caller must have already called `env.mock_all_auths()`. +fn init_vault_no_auth_caller<'a>( + env: &'a Env, + owner: &Address, + balance: i128, +) -> (Address, CalloraVaultClient<'a>) { + let (vault_address, client) = create_vault(env); + let (usdc, _, usdc_admin) = create_usdc(env, owner); + fund_vault(&usdc_admin, &vault_address, balance); + client.init(owner, &usdc, &Some(balance), &None, &None, &None, &None); + (vault_address, client) +} + +#[test] +fn set_authorized_caller_stores_address_and_emits_event() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + env.mock_all_auths(); + let (vault_address, client) = init_vault_no_auth_caller(&env, &owner, 100); + + client.set_authorized_caller(&owner, &backend); + + // Event emitted: topic ("set_auth_caller", owner), data = backend + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "set_auth_caller") + } + }) + .expect("expected set_auth_caller event"); + + assert_eq!(ev.0, vault_address); + let topic_owner: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic_owner, owner); + let data: Address = ev.2.into_val(&env); + assert_eq!(data, backend); + + // Stored correctly (checked after event assertion to avoid resetting event log) + let meta = client.get_meta(); + assert_eq!(meta.authorized_caller, Some(backend)); +} + +#[test] +fn set_authorized_caller_can_be_updated_to_different_address() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend_v1 = Address::generate(&env); + let backend_v2 = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 100); + client.set_authorized_caller(&owner, &backend_v1); + client.set_authorized_caller(&owner, &backend_v2); + + let meta = client.get_meta(); + assert_eq!(meta.authorized_caller, Some(backend_v2)); +} + +#[test] +#[should_panic(expected = "unauthorized: owner only")] +fn set_authorized_caller_non_owner_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let backend = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 100); + + client.set_authorized_caller(&attacker, &backend); +} + +#[test] +#[should_panic(expected = "new_caller must differ from current authorized caller")] +fn set_authorized_caller_same_address_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 100); + + client.set_authorized_caller(&owner, &backend); + // Setting the same address again must be rejected + client.set_authorized_caller(&owner, &backend); +} + +#[test] +#[should_panic(expected = "new_caller must not be the vault contract itself")] +fn set_authorized_caller_vault_address_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + env.mock_all_auths(); + let (vault_address, client) = init_vault_no_auth_caller(&env, &owner, 100); + + client.set_authorized_caller(&owner, &vault_address); +} + +// --- Deduct authorization matrix --- + +#[test] +fn deduct_matrix_owner_is_allowed() { + let env = Env::default(); + let owner = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + // Owner can deduct even without an authorized_caller set + let remaining = client.deduct(&owner, &100, &None); + assert_eq!(remaining, 400); +} + +#[test] +fn deduct_matrix_authorized_caller_is_allowed() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + client.set_authorized_caller(&owner, &backend); + + let remaining = client.deduct(&backend, &150, &None); + assert_eq!(remaining, 350); +} + +#[test] +fn deduct_matrix_other_address_is_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + let stranger = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + client.set_authorized_caller(&owner, &backend); + + let result = client.try_deduct(&stranger, &50, &None); + assert!(result.is_err(), "stranger must be rejected from deduct"); +} + +#[test] +fn deduct_matrix_no_authorized_caller_set_non_owner_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let stranger = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + // No authorized_caller configured — only owner may deduct + let result = client.try_deduct(&stranger, &50, &None); + assert!( + result.is_err(), + "non-owner must be rejected when no authorized_caller is set" + ); +} + +#[test] +fn batch_deduct_matrix_owner_is_allowed() { + let env = Env::default(); + let owner = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: None + }, + DeductItem { + amount: 50, + request_id: None + }, + ]; + let remaining = client.batch_deduct(&owner, &items); + assert_eq!(remaining, 350); +} + +#[test] +fn batch_deduct_matrix_authorized_caller_is_allowed() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + client.set_authorized_caller(&owner, &backend); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 200, + request_id: None + }, + ]; + let remaining = client.batch_deduct(&backend, &items); + assert_eq!(remaining, 300); +} + +#[test] +fn batch_deduct_matrix_other_address_is_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + let stranger = Address::generate(&env); + env.mock_all_auths(); + let (_, client) = init_vault_no_auth_caller(&env, &owner, 500); + + client.set_authorized_caller(&owner, &backend); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 50, + request_id: None + }, + ]; + let result = client.try_batch_deduct(&stranger, &items); + assert!( + result.is_err(), + "stranger must be rejected from batch_deduct" + ); } From 52a922a64ec9ad70b271a42fe05a7fe8b21abb52 Mon Sep 17 00:00:00 2001 From: David Ojo Date: Sat, 28 Mar 2026 23:00:00 +0100 Subject: [PATCH 2/4] fix(vault): resolve build errors after rebase with upstream/main --- contracts/vault/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index efc83bf..da698dd 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -77,9 +77,14 @@ pub enum StorageKey { Metadata(String), PendingOwner, PendingAdmin, + Paused, } -// Replaced by StorageKey enum variants +/// Storage key string for the allowed depositors list. +pub const ALLOWED_KEY: &str = "AllowedDepositors"; + +/// Maximum number of items permitted in a single `batch_deduct` call. +pub const MAX_BATCH_SIZE: u32 = 100; /// Default maximum single deduct amount when not set at init (no cap). pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; @@ -183,7 +188,7 @@ impl CalloraVault { let allowed: Vec
= env .storage() .instance() - .get(&Symbol::new(&env, ALLOWED_KEY)) + .get(&StorageKey::AllowedDepositors) .unwrap_or(Vec::new(&env)); allowed.contains(&caller) } @@ -433,7 +438,7 @@ impl CalloraVault { "unauthorized: only owner or allowed depositor can deposit" ); - let meta = Self::get_meta(env.clone()); + let mut meta = Self::get_meta(env.clone()); assert!( amount >= meta.min_deposit, "deposit below minimum: {} < {}", From 594bd0d02de5e51a83fdfa57eaf904b3f90ab3b8 Mon Sep 17 00:00:00 2001 From: David Ojo Date: Sat, 28 Mar 2026 23:18:50 +0100 Subject: [PATCH 3/4] fix(vault): update test.rs after rebase --- contracts/vault/src/test.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index ee28368..f773f38 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -428,7 +428,7 @@ fn set_authorized_caller_sets_and_emits_event() { env.mock_all_auths(); client.init(&owner, &usdc, &Some(200), &None, &None, &None, &None); - client.set_authorized_caller(&new_caller); + client.set_authorized_caller(&owner, &new_caller); let events = env.events().all(); let ev = events.last().expect("expected set_auth_caller event"); @@ -1419,6 +1419,7 @@ fn metadata_remains_after_ownership_transfer() { client.set_metadata(&owner, &offering_id, &metadata); client.transfer_ownership(&new_owner); + client.accept_ownership(); // Metadata should still be accessible assert_eq!(client.get_metadata(&offering_id), Some(metadata.clone())); @@ -1541,7 +1542,6 @@ fn init_with_revenue_pool_stores_address() { &usdc, &Some(500), &None, &None, - &None, &Some(revenue_pool.clone()), &None, ); @@ -1761,7 +1761,7 @@ fn test_set_authorized_caller() { env.mock_all_auths(); client.init(&owner, &usdc, &None, &None, &None, &None, &None); - client.set_authorized_caller(&auth_caller); + client.set_authorized_caller(&owner, &auth_caller); let meta = client.get_meta(); assert_eq!(meta.authorized_caller, Some(auth_caller)); } @@ -1862,6 +1862,7 @@ fn withdraw_to_zero_succeeds() { client.init(&owner, &usdc, &Some(300), &None, &None, &None, &None); assert_eq!(client.withdraw(&300), 0); +} // --------------------------------------------------------------------------- // set_authorized_caller + deduct authorization matrix tests // --------------------------------------------------------------------------- From 9b87dd7f2d6b407df9e4a70d2e883a98c3b7672f Mon Sep 17 00:00:00 2001 From: David Ojo Date: Sat, 28 Mar 2026 23:50:58 +0100 Subject: [PATCH 4/4] fix(vault): apply cargo fmt and fix syntax errors --- contracts/revenue_pool/src/test.rs | 9 ---- contracts/settlement/src/lib.rs | 6 +-- contracts/settlement/src/test.rs | 16 ------- contracts/vault/src/test.rs | 4 +- contracts/vault/src/test_init_hardening.rs | 54 ++++++++++++++-------- 5 files changed, 39 insertions(+), 50 deletions(-) diff --git a/contracts/revenue_pool/src/test.rs b/contracts/revenue_pool/src/test.rs index da202d2..52c2a7e 100644 --- a/contracts/revenue_pool/src/test.rs +++ b/contracts/revenue_pool/src/test.rs @@ -211,12 +211,3 @@ fn batch_distribute_success_events() { } } } -if let Ok(event_name) = Symbol::try_from_val(&env, &topic_0) { - if event_name == Symbol::new(&env, "batch_distribute") { - let value: i128 = i128::try_from_val(&env, &data).unwrap(); - assert!(value == 300 || value == 200); - } - } - } -} -// <--- Add one empty line right here diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 0bb187b..b6a9ab0 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -234,10 +234,8 @@ impl CalloraSettlement { inst.set(&Symbol::new(&env, ADMIN_KEY), &pending); inst.remove(&Symbol::new(&env, PENDING_ADMIN_KEY)); - env.events().publish( - (Symbol::new(&env, "admin_accepted"), current, pending), - (), - ); + env.events() + .publish((Symbol::new(&env, "admin_accepted"), current, pending), ()); } /// Update vault address (admin only) diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index ebf3716..7029ac3 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -134,22 +134,6 @@ mod settlement_tests { assert_eq!(all.len(), 0); } - #[test] - #[should_panic(expected = "unauthorized: caller must be vault or admin")] - fn test_receive_payment_unauthorized() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let vault = Address::generate(&env); - let addr = env.register(CalloraSettlement, ()); - let client = CalloraSettlementClient::new(&env, &addr); - client.init(&admin, &vault); - - client.receive_payment(&admin, &500i128, &true, &None); - - assert_eq!(client.get_global_pool().total_balance, 500i128); - } - #[test] fn test_admin_can_receive_payment_to_developer() { // Admin routing a payment directly to a developer (not via vault) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index f773f38..b6457b7 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -1540,7 +1540,8 @@ fn init_with_revenue_pool_stores_address() { client.init( &owner, &usdc, - &Some(500), &None, + &Some(500), + &None, &None, &Some(revenue_pool.clone()), &None, @@ -1729,7 +1730,6 @@ fn get_settlement_before_set_panics() { client.get_settlement(); } - #[test] fn test_clear_allowed_depositors() { let env = Env::default(); diff --git a/contracts/vault/src/test_init_hardening.rs b/contracts/vault/src/test_init_hardening.rs index 42a8df1..420fbc1 100644 --- a/contracts/vault/src/test_init_hardening.rs +++ b/contracts/vault/src/test_init_hardening.rs @@ -10,10 +10,10 @@ fn test_double_initialization_fails() { let owner = Address::generate(&env); let usdc = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + // First init client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); // Second init should panic @@ -26,10 +26,10 @@ fn test_init_usdc_self_address_fails() { let env = Env::default(); let owner = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + // Passing vault's own address as USDC token client.init(&owner, &addr, &Some(0), &None, &None, &None, &None); } @@ -41,12 +41,20 @@ fn test_init_revenue_pool_self_address_fails() { let owner = Address::generate(&env); let usdc = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + // Passing vault's own address as Revenue Pool - client.init(&owner, &usdc, &Some(0), &None, &None, &Some(addr.clone()), &None); + client.init( + &owner, + &usdc, + &Some(0), + &None, + &None, + &Some(addr.clone()), + &None, + ); } #[test] @@ -56,10 +64,10 @@ fn test_init_negative_min_deposit_fails() { let owner = Address::generate(&env); let usdc = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + client.init(&owner, &usdc, &Some(0), &None, &Some(-10), &None, &None); } @@ -70,10 +78,10 @@ fn test_init_zero_max_deduct_fails() { let owner = Address::generate(&env); let usdc = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + client.init(&owner, &usdc, &Some(0), &None, &None, &None, &Some(0)); } @@ -84,10 +92,10 @@ fn test_init_negative_max_deduct_fails() { let owner = Address::generate(&env); let usdc = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + client.init(&owner, &usdc, &Some(0), &None, &None, &None, &Some(-50)); } @@ -98,10 +106,10 @@ fn test_init_min_deposit_exceeds_max_deduct_fails() { let owner = Address::generate(&env); let usdc = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + client.init(&owner, &usdc, &Some(0), &None, &Some(100), &None, &Some(50)); } @@ -112,12 +120,20 @@ fn test_init_validates_successfully() { let usdc = Address::generate(&env); let pool = Address::generate(&env); env.mock_all_auths(); - + let addr = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &addr); - + // Test valid initialization with valid parameters - client.init(&owner, &usdc, &Some(100), &None, &Some(10), &Some(pool.clone()), &Some(50)); - + client.init( + &owner, + &usdc, + &Some(100), + &None, + &Some(10), + &Some(pool.clone()), + &Some(50), + ); + assert_eq!(client.get_admin(), owner); }