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/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..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 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."