From 13f601efa98c6d35731e43e35c829bd0f14bf0ea Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Mon, 30 Mar 2026 16:10:17 +0100 Subject: [PATCH 1/3] fix(settlement): safe i128 balance updates and rename --- Cargo.toml | 2 +- SECURITY.md | 20 +++++++++++++++++++ .../{revenue_pool => settlement}/Cargo.toml | 2 +- .../{revenue_pool => settlement}/src/lib.rs | 6 +++--- .../{revenue_pool => settlement}/src/test.rs | 6 +++--- contracts/vault/src/lib.rs | 4 ++-- contracts/vault/src/test.rs | 18 ++++++++++++++--- 7 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 SECURITY.md rename contracts/{revenue_pool => settlement}/Cargo.toml (89%) rename contracts/{revenue_pool => settlement}/src/lib.rs (97%) rename contracts/{revenue_pool => settlement}/src/test.rs (97%) diff --git a/Cargo.toml b/Cargo.toml index b597b45..e530cf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["contracts/vault", "contracts/revenue_pool"] +members = ["contracts/vault", "contracts/settlement"] [workspace.dependencies] soroban-sdk = "22" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7da46df --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Checked Arithmetic + +All smart contracts in this repository are developed with a "no silent wrap" policy for balance updates. + +### Implementation + +- We use `checked_add` and `checked_sub` for all `i128` balance mutations. +- In the event of an overflow or underflow, the contract will immediately panic with a descriptive message (e.g., `"balance overflow"`, `"total amount overflow"`), causing the transaction to revert. +- The `overflow-checks = true` setting is enabled in `Cargo.toml` for both `dev` and `release` profiles as an additional safety layer, though explicit checked arithmetic is preferred for clarity and deterministic error messages. + +### Affected Contracts + +- **`callora-vault`**: `balance` increases during `deposit` and decreases during `deduct`. +- **`callora-settlement`**: `total_amount` is calculated during `batch_distribute` to ensure the contract holds sufficient funds. + +## Reporting a Vulnerability + +If you find a security issue, please do not open a public issue. Instead, contact the maintainers at security@callora.org. diff --git a/contracts/revenue_pool/Cargo.toml b/contracts/settlement/Cargo.toml similarity index 89% rename from contracts/revenue_pool/Cargo.toml rename to contracts/settlement/Cargo.toml index ef26b4f..4ae7344 100644 --- a/contracts/revenue_pool/Cargo.toml +++ b/contracts/settlement/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "callora-revenue-pool" +name = "callora-settlement" version = "0.0.1" edition = "2021" publish = false diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/settlement/src/lib.rs similarity index 97% rename from contracts/revenue_pool/src/lib.rs rename to contracts/settlement/src/lib.rs index 2e840cd..b509edd 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -9,10 +9,10 @@ const ADMIN_KEY: &str = "admin"; const USDC_KEY: &str = "usdc"; #[contract] -pub struct RevenuePool; +pub struct Settlement; #[contractimpl] -impl RevenuePool { +impl Settlement { /// Initialize the revenue pool with an admin and the USDC token address. /// /// # Arguments @@ -131,7 +131,7 @@ impl RevenuePool { if amount <= 0 { panic!("amount must be positive"); } - total_amount += amount; + total_amount = total_amount.checked_add(amount).expect("total amount overflow"); } let usdc_address: Address = env diff --git a/contracts/revenue_pool/src/test.rs b/contracts/settlement/src/test.rs similarity index 97% rename from contracts/revenue_pool/src/test.rs rename to contracts/settlement/src/test.rs index c1b9444..2648b6f 100644 --- a/contracts/revenue_pool/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -15,9 +15,9 @@ fn create_usdc<'a>( (address, client, admin_client) } -fn create_pool(env: &Env) -> (Address, RevenuePoolClient<'_>) { - let address = env.register(RevenuePool, ()); - let client = RevenuePoolClient::new(env, &address); +fn create_pool(env: &Env) -> (Address, SettlementClient<'_>) { + let address = env.register(Settlement, ()); + let client = SettlementClient::new(env, &address); (address, client) } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f0beba6..bf54ab6 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -155,7 +155,7 @@ impl CalloraVault { ); let mut meta = Self::get_meta(env.clone()); - meta.balance += amount; + meta.balance = meta.balance.checked_add(amount).expect("balance overflow"); env.storage().instance().set(&StorageKey::Meta, &meta); env.events() @@ -195,7 +195,7 @@ impl CalloraVault { let mut meta = Self::get_meta(env.clone()); assert!(amount > 0, "amount must be positive"); assert!(meta.balance >= amount, "insufficient balance"); - meta.balance -= amount; + meta.balance = meta.balance.checked_sub(amount).expect("balance underflow"); env.storage().instance().set(&StorageKey::Meta, &meta); meta.balance } diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 3332271..ec27680 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -505,11 +505,11 @@ fn fuzz_deposit_and_deduct() { if rng.gen_bool(0.5) { let amount = rng.gen_range(1..=500); client.deposit(&owner, &amount); - expected += amount; + expected = expected.checked_add(amount).expect("local expected overflow"); } else if expected > 0 { let amount = rng.gen_range(1..=expected.min(500)); client.deduct(&owner, &amount); - expected -= amount; + expected = expected.checked_sub(amount).expect("local expected underflow"); } let balance = client.balance(); @@ -618,7 +618,19 @@ fn init_unauthorized_owner_panics() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - // 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] +#[should_panic(expected = "balance overflow")] +fn deposit_overflow_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(i128::MAX)); + client.deposit(&owner, &1); +} From b10a65d4f387e3972b87b7d46839ffb59168a247 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Mon, 30 Mar 2026 16:44:18 +0100 Subject: [PATCH 2/3] fix: Merge fixes for conflicts and address balance checks --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca4b9be..a52d815 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Callora Contracts +# Callora Contracts Soroban smart contracts for the Callora API marketplace: prepaid vault (USDC) and balance deduction for pay-per-call. From 5aa8c7cfccebe26f73746c686da8eb2aca012c72 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Mon, 30 Mar 2026 18:49:14 +0100 Subject: [PATCH 3/3] fix(ci): format merge follow-up and raise coverage --- contracts/revenue_pool/src/test.rs | 52 ++++++++++++++++++++++++++++++ contracts/settlement/src/test.rs | 2 +- contracts/vault/src/lib.rs | 10 ++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/contracts/revenue_pool/src/test.rs b/contracts/revenue_pool/src/test.rs index 7d86bb1..47f09f5 100644 --- a/contracts/revenue_pool/src/test.rs +++ b/contracts/revenue_pool/src/test.rs @@ -177,6 +177,37 @@ fn set_admin_two_step_transfers_control() { assert_eq!(usdc_client.balance(&developer), 100); } +#[test] +#[should_panic(expected = "unauthorized: caller is not admin")] +fn set_admin_unauthorized_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + + client.init(&admin, &usdc); + client.set_admin(&attacker, &new_admin); +} + +#[test] +#[should_panic(expected = "unauthorized: caller is not pending admin")] +fn claim_admin_wrong_address_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + let attacker = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + + client.init(&admin, &usdc); + client.set_admin(&admin, &new_admin); + client.claim_admin(&attacker); +} + #[test] fn admin_transfer_emits_events() { let env = Env::default(); @@ -211,6 +242,27 @@ fn admin_transfer_emits_events() { ); } +#[test] +fn receive_payment_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + + client.init(&admin, &usdc); + client.receive_payment(&admin, &250, &true); + + let events = env.events().all(); + let receive_payment_event = events.last().unwrap(); + let event_name = Symbol::try_from_val(&env, &receive_payment_event.1.get(0).unwrap()).unwrap(); + assert_eq!(event_name, Symbol::new(&env, "receive_payment")); + + let amount_and_source: (i128, bool) = + <(i128, bool)>::try_from_val(&env, &receive_payment_event.2).unwrap(); + assert_eq!(amount_and_source, (250, true)); +} + #[test] fn batch_distribute_success() { let env = Env::default(); diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 67134c9..a410cea 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -153,7 +153,7 @@ mod settlement_tests { env.mock_all_auths(); let admin = Address::generate(&env); let vault = Address::generate(&env); - let addr = env.register(CalloraSettlement, ()); + let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); client.receive_payment(&admin, &100i128, &true, &None); diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f27769b..1a04516 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -325,7 +325,10 @@ impl CalloraVault { if let Some(s) = inst.get::(&StorageKey::Settlement) { let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); Self::transfer_funds(&env, &ut, &s, amount); - } else if inst.get::(&StorageKey::RevenuePool).is_some() { + } else if inst + .get::(&StorageKey::RevenuePool) + .is_some() + { Self::transfer_to_revenue_pool(env.clone(), amount); } let rid = request_id.unwrap_or(Symbol::new(&env, "")); @@ -372,7 +375,10 @@ impl CalloraVault { if let Some(s) = inst.get::(&StorageKey::Settlement) { let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); Self::transfer_funds(&env, &ut, &s, total); - } else if inst.get::(&StorageKey::RevenuePool).is_some() { + } else if inst + .get::(&StorageKey::RevenuePool) + .is_some() + { Self::transfer_to_revenue_pool(env.clone(), total); } meta.balance