diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f059198..da7131e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: ${{ runner.os }}-cargo- - name: Check WASM size + shell: bash run: | chmod +x scripts/check-wasm-size.sh ./scripts/check-wasm-size.sh diff --git a/INVARIANTS.md b/INVARIANTS.md index 014d9a3..f1277cb 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -35,13 +35,14 @@ Helper and view functions such as `get_meta`, `get_max_deduct`, `get_revenue_poo **Pre-conditions** - Vault is not already initialized: - `!env.storage().instance().has(META_KEY)` +- `initial_balance.unwrap_or(0) >= 0` - `max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT) > 0` -- If `initial_balance > 0`, the contract already holds at least that much USDC: - - `usdc.balance(current_contract_address) >= initial_balance` +- The on-ledger USDC balance already covers the requested internal starting balance: + - `usdc.balance(current_contract_address) >= initial_balance.unwrap_or(0)` **Post-conditions** - `VaultMeta.balance == initial_balance.unwrap_or(0)` -- `VaultMeta.balance >= 0` (since `initial_balance` is an `i128` and enforced via the token-balance check). +- `VaultMeta.balance >= 0` (because `initial_balance.unwrap_or(0)` is explicitly checked to be non-negative before storage is written). --- diff --git a/README.md b/README.md index cd8df3e..832d13a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Advanced settlement with individual developer balance tracking. 2. **Build and test:** ```bash + cargo fmt --all + cargo clippy --all-targets --all-features -- -D warnings cargo build cargo test ``` @@ -90,16 +92,16 @@ Advanced settlement with individual developer balance tracking. 3. **Build WASM:** ```bash - # Build all contracts - cargo build --target wasm32-unknown-unknown --release - - # Or use the convenience script + # Build all publishable contract crates and verify their release WASM sizes ./scripts/check-wasm-size.sh + + # Or build a specific contract manually + cargo build --target wasm32-unknown-unknown --release -p callora-vault ``` ## Development -Use one branch per issue or feature. Run `cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo test` before pushing. +Use one branch per issue or feature. Run `cargo fmt --all`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test`, and `./scripts/check-wasm-size.sh` before pushing so every publishable contract stays within Soroban's WASM size limit. ### Test coverage diff --git a/contracts/revenue_pool/src/test.rs b/contracts/revenue_pool/src/test.rs index 7d86bb1..0bdad5d 100644 --- a/contracts/revenue_pool/src/test.rs +++ b/contracts/revenue_pool/src/test.rs @@ -4,7 +4,7 @@ use super::*; use soroban_sdk::testutils::{Address as _, Events as _}; use soroban_sdk::token; use soroban_sdk::TryFromVal; -use soroban_sdk::{Address, Env, Symbol, Vec}; +use soroban_sdk::{Address, Env, IntoVal, Symbol, Vec}; fn create_usdc<'a>( env: &'a Env, @@ -266,3 +266,88 @@ fn batch_distribute_success_events() { } } } + +#[test] +fn receive_payment_emits_event_for_admin() { + 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_event = events.last().unwrap(); + let event_name = Symbol::try_from_val(&env, &receive_event.1.get(0).unwrap()).unwrap(); + assert_eq!(event_name, Symbol::new(&env, "receive_payment")); + + let caller: Address = Address::try_from_val(&env, &receive_event.1.get(1).unwrap()).unwrap(); + assert_eq!(caller, admin); + + let (amount, from_vault): (i128, bool) = receive_event.2.into_val(&env); + assert_eq!(amount, 250); + assert!(from_vault); +} + +#[test] +#[should_panic(expected = "no pending admin")] +fn claim_admin_without_pending_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let candidate = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + + client.init(&admin, &usdc); + client.claim_admin(&candidate); +} + +#[test] +#[should_panic(expected = "unauthorized: caller is not pending admin")] +fn claim_admin_wrong_caller_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let pending_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, &pending_admin); + client.claim_admin(&attacker); +} + +#[test] +#[should_panic(expected = "invalid recipient: cannot distribute to the contract itself")] +fn distribute_to_self_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &admin); + + client.init(&admin, &usdc_address); + fund_pool(&usdc_admin, &pool_addr, 100); + client.distribute(&admin, &pool_addr, &50); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn batch_distribute_zero_amount_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let dev = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc_address, _, _) = create_usdc(&env, &admin); + + client.init(&admin, &usdc_address); + + let mut payments: Vec<(Address, i128)> = Vec::new(&env); + payments.push_back((dev, 0)); + client.batch_distribute(&admin, &payments); +} diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 08f19c5..a410cea 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -13,7 +13,8 @@ mod settlement_tests { env.mock_all_auths(); let admin = Address::generate(&env); let vault = Address::generate(&env); - let addr = env.register(CalloraSettlement, ()); + let third_party = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); (env, addr, admin, vault, third_party) @@ -152,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..2917158 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -77,6 +77,14 @@ impl CalloraVault { let max_d = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT); assert!(max_d > 0, "max_deduct must be positive"); assert!(min_d <= max_d, "min_deposit cannot exceed max_deduct"); + if balance > 0 { + let onchain_usdc_balance = + token::Client::new(&env, &usdc_token).balance(&env.current_contract_address()); + assert!( + onchain_usdc_balance >= balance, + "initial_balance exceeds on-ledger USDC balance" + ); + } let meta = VaultMeta { owner: owner.clone(), balance, @@ -325,7 +333,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 +383,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 diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index e05f35a..28f11cc 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -96,6 +96,36 @@ fn init_sets_owner_and_min_deposit() { assert_eq!(client.get_admin(), owner); } +#[test] +fn init_succeeds_when_onchain_usdc_balance_covers_initial_balance() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 500); + + let meta = client.init(&owner, &usdc, &Some(400), &None, &None, &None, &None); + + assert_eq!(meta.balance, 400); + assert_eq!(client.balance(), 400); +} + +#[test] +#[should_panic(expected = "initial_balance exceeds on-ledger USDC balance")] +fn init_fails_when_initial_balance_exceeds_onchain_usdc_balance() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 99); + + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); +} + #[test] fn double_init_fails() { let env = Env::default(); @@ -539,10 +569,11 @@ fn set_authorized_caller_sets_and_emits_event() { let env = Env::default(); let owner = Address::generate(&env); let new_caller = Address::generate(&env); - let (_, client) = create_vault(&env); - let (usdc, _, _) = create_usdc(&env, &owner); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 200); client.init(&owner, &usdc, &Some(200), &None, &None, &None, &None); client.set_authorized_caller(&new_caller); diff --git a/contracts/vault/src/test_init_hardening.rs b/contracts/vault/src/test_init_hardening.rs index 3d74df8..a9652a3 100644 --- a/contracts/vault/src/test_init_hardening.rs +++ b/contracts/vault/src/test_init_hardening.rs @@ -13,9 +13,9 @@ fn test_double_initialization_fails() { let client = CalloraVaultClient::new(&env, &addr); // First init - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + client.init(&owner, &usdc, &Some(0), &None, &None, &None, &None); // Second init should panic - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + client.init(&owner, &usdc, &Some(0), &None, &None, &None, &None); } #[test] @@ -126,7 +126,7 @@ fn test_init_validates_successfully() { client.init( &owner, &usdc, - &Some(100), + &Some(0), &None, &Some(10), &Some(pool.clone()), diff --git a/scripts/check-wasm-size.sh b/scripts/check-wasm-size.sh index 657de31..0fb6a7a 100755 --- a/scripts/check-wasm-size.sh +++ b/scripts/check-wasm-size.sh @@ -1,52 +1,114 @@ -#!/bin/bash -# Check that all contract WASM binaries stay under the 64KB Soroban limit. +#!/usr/bin/env bash +# Check that all publishable contract WASM binaries stay under the 64 KiB Soroban limit. -set -e +set -euo pipefail -MAX_SIZE=$((64 * 1024)) -FAILED=0 +MAX_SIZE_BYTES=$((64 * 1024)) +TARGET_DIR="${CARGO_TARGET_DIR:-target}/wasm32-unknown-unknown/release" + +contract_manifests=() +contract_packages=() +failed=0 + +if command -v cargo >/dev/null 2>&1; then + CARGO_BIN=$(command -v cargo) +elif command -v cargo.exe >/dev/null 2>&1; then + CARGO_BIN=$(command -v cargo.exe) +elif [ -n "${HOME:-}" ] && [ -x "${HOME}/.cargo/bin/cargo" ]; then + CARGO_BIN="${HOME}/.cargo/bin/cargo" +elif [ -n "${HOME:-}" ] && [ -x "${HOME}/.cargo/bin/cargo.exe" ]; then + CARGO_BIN="${HOME}/.cargo/bin/cargo.exe" +elif [ -n "${USERPROFILE:-}" ] && [ -x "${USERPROFILE}/.cargo/bin/cargo.exe" ]; then + CARGO_BIN="${USERPROFILE}/.cargo/bin/cargo.exe" +elif [ -n "${USERNAME:-}" ] && [ -x "/c/Users/${USERNAME}/.cargo/bin/cargo.exe" ]; then + CARGO_BIN="/c/Users/${USERNAME}/.cargo/bin/cargo.exe" +else + echo "ERROR: cargo was not found on PATH and no fallback binary was detected" + exit 1 +fi + +while IFS= read -r -d '' manifest; do + contract_manifests+=("$manifest") +done < <(find contracts -mindepth 2 -maxdepth 2 -name Cargo.toml -print0 | sort -z) + +if [ "${#contract_manifests[@]}" -eq 0 ]; then + echo "ERROR: no contract manifests found under contracts/*/Cargo.toml" + exit 1 +fi + +discover_contract_packages() { + local manifest + local package_name + + for manifest in "${contract_manifests[@]}"; do + if ! grep -Eq 'crate-type\s*=\s*\[[^]]*"cdylib"' "$manifest"; then + continue + fi + + package_name=$(awk -F'"' '/^[[:space:]]*name[[:space:]]*=/{print $2; exit}' "$manifest") + if [ -z "$package_name" ]; then + echo "ERROR: unable to determine package name from $manifest" + exit 1 + fi + + contract_packages+=("$package_name") + done + + if [ "${#contract_packages[@]}" -eq 0 ]; then + echo 'ERROR: no publishable contract crates with crate-type = ["cdylib", ...] were found' + exit 1 + fi +} check_wasm() { local crate="$1" - local wasm_name="$2" - local wasm_file="target/wasm32-unknown-unknown/release/${wasm_name}.wasm" + local wasm_name="${crate//-/_}" + local wasm_file="$TARGET_DIR/${wasm_name}.wasm" + local size_bytes + local size_kib + local headroom_bytes + local headroom_kib if [ ! -f "$wasm_file" ]; then - echo "ERROR: $wasm_file not found — did the build run?" - FAILED=1 + echo "FAIL $crate: missing artifact at $wasm_file" + failed=1 return fi - local size - size=$(wc -c < "$wasm_file") - local size_kb=$((size / 1024)) + size_bytes=$(wc -c < "$wasm_file") + size_kib=$((size_bytes / 1024)) - if [ "$size" -gt "$MAX_SIZE" ]; then - echo "FAIL $crate: ${size_kb}KB — exceeds 64KB limit" - FAILED=1 - else - local headroom=$(( (MAX_SIZE - size) / 1024 )) - echo "OK $crate: ${size_kb}KB (${headroom}KB headroom)" + if [ "$size_bytes" -gt "$MAX_SIZE_BYTES" ]; then + echo "FAIL $crate: ${size_bytes} bytes (${size_kib} KiB) exceeds 65536-byte limit" + failed=1 + return fi + + headroom_bytes=$((MAX_SIZE_BYTES - size_bytes)) + headroom_kib=$((headroom_bytes / 1024)) + echo "OK $crate: ${size_bytes} bytes (${size_kib} KiB, ${headroom_bytes} bytes / ${headroom_kib} KiB headroom)" } -echo "Building all contracts for wasm32-unknown-unknown (release)..." -cargo build --target wasm32-unknown-unknown --release \ - -p callora-vault \ - -p callora-revenue-pool \ - -p callora-settlement +discover_contract_packages + +echo "Building publishable contracts for wasm32-unknown-unknown (release)..." +cargo_args=(build --target wasm32-unknown-unknown --release) +for crate in "${contract_packages[@]}"; do + cargo_args+=(-p "$crate") +done +"$CARGO_BIN" "${cargo_args[@]}" echo "" -echo "WASM size check (limit: 64KB)" -echo "------------------------------" -check_wasm "callora-vault" "callora_vault" -check_wasm "callora-revenue-pool" "callora_revenue_pool" -check_wasm "callora-settlement" "callora_settlement" +echo "WASM size check (limit: 65536 bytes / 64 KiB)" +echo "---------------------------------------------" +for crate in "${contract_packages[@]}"; do + check_wasm "$crate" +done echo "" -if [ $FAILED -ne 0 ]; then - echo "One or more contracts exceed the size limit." +if [ "$failed" -ne 0 ]; then + echo "One or more publishable contract WASM artifacts are missing or exceed the Soroban size limit." exit 1 fi -echo "All contracts within size limit." +echo "All publishable contract WASM artifacts are within the Soroban size limit."