diff --git a/.github/workflows/gas.yml b/.github/workflows/gas.yml new file mode 100644 index 00000000..f7aff013 --- /dev/null +++ b/.github/workflows/gas.yml @@ -0,0 +1,181 @@ +name: Gas Budget Regression + +# --------------------------------------------------------------------------- +# Triggers +# --------------------------------------------------------------------------- +on: + pull_request: + branches: [main, master, develop] + # Re-run when the override label is added/removed so the status updates + # without needing a new commit. + types: [opened, synchronize, reopened, labeled, unlabeled] + push: + branches: [main, master, develop] + +# --------------------------------------------------------------------------- +# Permissions +# --------------------------------------------------------------------------- +permissions: + contents: read # checkout + pull-requests: write # post the PR comment + +jobs: + gas-regression: + name: Gas budget regression (≤5% tolerance) + runs-on: ubuntu-latest + + steps: + # ----------------------------------------------------------------------- + # 1. Source + # ----------------------------------------------------------------------- + - name: Checkout repository + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + # 2. Toolchain + # ----------------------------------------------------------------------- + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + # ----------------------------------------------------------------------- + # 3. Cache + # ----------------------------------------------------------------------- + - name: Cache cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-gas-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo-gas- + ${{ runner.os }}-cargo- + + # ----------------------------------------------------------------------- + # 4. Dependencies + # ----------------------------------------------------------------------- + - name: Install jq + run: sudo apt-get install -y jq + + # ----------------------------------------------------------------------- + # 5. Check for override label + # When a PR carries the "gas-override" label the regression gate is + # skipped and a notice is posted instead. The label MUST be removed + # before merging. + # ----------------------------------------------------------------------- + - name: Check for gas-override label + id: override_check + env: + PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + if echo "$PR_LABELS" | jq -e 'map(select(. == "gas-override")) | length > 0' > /dev/null 2>&1; then + echo "override=true" >> "$GITHUB_OUTPUT" + else + echo "override=false" >> "$GITHUB_OUTPUT" + fi + + # ----------------------------------------------------------------------- + # 6. Override path — post notice and skip gate + # ----------------------------------------------------------------------- + - name: Post override notice + if: steps.override_check.outputs.override == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const body = [ + "## ⚠️ Gas budget gate skipped", + "", + "This PR carries the **`gas-override`** label — the ≤5% regression check has been bypassed.", + "", + "> **Action required:** Remove the label before merging and ensure `contracts/.gas-baseline.json` is updated to reflect the new measurements.", + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + + - name: Skip regression (override active) + if: steps.override_check.outputs.override == 'true' + run: | + echo "gas-override label detected — skipping regression gate." + exit 0 + + # ----------------------------------------------------------------------- + # 7. Build workspace (needed before tests emit measurements) + # ----------------------------------------------------------------------- + - name: Build workspace + run: cargo build --workspace + + # ----------------------------------------------------------------------- + # 8. Run gas regression script + # ----------------------------------------------------------------------- + - name: Run gas regression + id: regression + run: | + chmod +x scripts/gas-regression.sh + set +e + ./scripts/gas-regression.sh --threshold 5 + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + + # ----------------------------------------------------------------------- + # 9. Post Markdown report as a PR comment + # ----------------------------------------------------------------------- + - name: Post gas report to PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const reportPath = "target/gas-report.md"; + + let body; + try { + body = fs.readFileSync(reportPath, "utf8"); + } catch { + body = "⚠️ Gas report was not generated (see workflow logs for details)."; + } + + // Prepend a header indicating pass/fail so reviewers see it instantly. + const exitCode = "${{ steps.regression.outputs.exit_code }}"; + const status = exitCode === "0" + ? "✅ All entrypoints within the 5% budget threshold." + : "❌ One or more entrypoints exceeded the 5% budget threshold. See table below."; + + const comment = `${status}\n\n${body}`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment, + }); + + # ----------------------------------------------------------------------- + # 10. Upload report artifact (available in push context too) + # ----------------------------------------------------------------------- + - name: Upload gas report + if: always() + uses: actions/upload-artifact@v4 + with: + name: gas-regression-report + path: target/gas-report.md + if-no-files-found: warn + + # ----------------------------------------------------------------------- + # 11. Fail the job if regression was detected + # ----------------------------------------------------------------------- + - name: Fail on regression + if: steps.regression.outputs.exit_code != '0' + run: | + echo "Gas budget regression detected. See the report above." + echo "To update the baseline intentionally, run:" + echo " ./scripts/gas-regression.sh --update-baseline" + echo "Then commit contracts/.gas-baseline.json." + exit 1 diff --git a/contracts/.gas-baseline.json b/contracts/.gas-baseline.json new file mode 100644 index 00000000..f9bdf292 --- /dev/null +++ b/contracts/.gas-baseline.json @@ -0,0 +1,255 @@ +{ + "_schema_version": 1, + "_description": "CPU instructions and memory bytes baseline per contract entrypoint. Generated by scripts/gas-regression.sh. Edit ONLY via that script or with an explicit maintainer override.", + "_override_label": "gas-override", + "callora-vault": { + "init": { + "cpu": 119549, + "mem": 1424 + }, + "deposit": { + "cpu": 235124, + "mem": 2496 + }, + "deduct": { + "cpu": 329283, + "mem": 3236 + }, + "batch_deduct": { + "cpu": 343235, + "mem": 3236 + }, + "set_allowed_depositor": { + "cpu": 80805, + "mem": 1168 + }, + "clear_allowed_depositors": { + "cpu": 85379, + "mem": 1216 + }, + "set_authorized_caller": { + "cpu": 90173, + "mem": 1172 + }, + "set_max_deduct": { + "cpu": 78523, + "mem": 1080 + }, + "set_admin": { + "cpu": 180000, + "mem": 18000 + }, + "accept_admin": { + "cpu": 160000, + "mem": 16000 + }, + "cancel_admin_transfer": { + "cpu": 160000, + "mem": 16000 + }, + "pause": { + "cpu": 79791, + "mem": 1116 + }, + "unpause": { + "cpu": 86024, + "mem": 1152 + }, + "get_meta": { + "cpu": 41950, + "mem": 504 + }, + "balance": { + "cpu": 38333, + "mem": 504 + }, + "is_paused": { + "cpu": 37551, + "mem": 504 + }, + "get_admin": { + "cpu": 70000, + "mem": 8000 + }, + "get_usdc_token": { + "cpu": 70000, + "mem": 8000 + }, + "get_max_deduct": { + "cpu": 39588, + "mem": 504 + }, + "get_settlement": { + "cpu": 70000, + "mem": 8000 + }, + "get_revenue_pool": { + "cpu": 70000, + "mem": 8000 + }, + "get_contract_addresses": { + "cpu": 45981, + "mem": 504 + }, + "is_authorized_depositor": { + "cpu": 43314, + "mem": 504 + } + }, + "callora-settlement": { + "init": { + "cpu": 300000, + "mem": 28000 + }, + "receive_payment": { + "cpu": 400000, + "mem": 38000 + }, + "batch_receive_payment": { + "cpu": 600000, + "mem": 55000 + }, + "withdraw_developer_balance": { + "cpu": 420000, + "mem": 40000 + }, + "set_usdc_token": { + "cpu": 180000, + "mem": 18000 + }, + "set_daily_withdraw_cap": { + "cpu": 170000, + "mem": 17000 + }, + "force_credit_developer": { + "cpu": 250000, + "mem": 24000 + }, + "set_admin": { + "cpu": 180000, + "mem": 18000 + }, + "accept_admin": { + "cpu": 160000, + "mem": 16000 + }, + "cancel_admin_transfer": { + "cpu": 160000, + "mem": 16000 + }, + "get_admin": { + "cpu": 70000, + "mem": 8000 + }, + "get_vault": { + "cpu": 70000, + "mem": 8000 + }, + "get_global_pool": { + "cpu": 80000, + "mem": 10000 + }, + "get_developer_balance": { + "cpu": 80000, + "mem": 10000 + }, + "get_daily_withdraw_cap": { + "cpu": 70000, + "mem": 8000 + }, + "get_withdrawal_today": { + "cpu": 70000, + "mem": 8000 + }, + "get_all_developer_balances": { + "cpu": 200000, + "mem": 25000 + }, + "get_developer_balances_page": { + "cpu": 180000, + "mem": 22000 + } + }, + "callora-revenue-pool": { + "init": { + "cpu": 280000, + "mem": 26000 + }, + "receive_payment": { + "cpu": 350000, + "mem": 33000 + }, + "deposit_yield": { + "cpu": 320000, + "mem": 30000 + }, + "distribute": { + "cpu": 400000, + "mem": 38000 + }, + "batch_distribute": { + "cpu": 600000, + "mem": 55000 + }, + "set_max_distribute": { + "cpu": 170000, + "mem": 17000 + }, + "set_admin": { + "cpu": 180000, + "mem": 18000 + }, + "accept_admin": { + "cpu": 160000, + "mem": 16000 + }, + "cancel_admin_transfer": { + "cpu": 160000, + "mem": 16000 + }, + "claim_admin": { + "cpu": 160000, + "mem": 16000 + }, + "pause": { + "cpu": 150000, + "mem": 15000 + }, + "unpause": { + "cpu": 150000, + "mem": 15000 + }, + "upgrade": { + "cpu": 300000, + "mem": 28000 + }, + "get_admin": { + "cpu": 70000, + "mem": 8000 + }, + "get_usdc_token": { + "cpu": 70000, + "mem": 8000 + }, + "get_pending_admin": { + "cpu": 70000, + "mem": 8000 + }, + "get_cumulative_yield_deposited": { + "cpu": 70000, + "mem": 8000 + }, + "get_max_distribute": { + "cpu": 60000, + "mem": 6000 + }, + "balance": { + "cpu": 70000, + "mem": 8000 + }, + "is_paused": { + "cpu": 60000, + "mem": 6000 + } + } +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 754a1fa1..76f5606f 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1829,3 +1829,4 @@ mod test_balance_property; #[cfg(test)] mod test_rate_limit; +#[cfg(test)] mod test_gas_budget; diff --git a/contracts/vault/src/test_gas_budget.rs b/contracts/vault/src/test_gas_budget.rs new file mode 100644 index 00000000..2da8a136 --- /dev/null +++ b/contracts/vault/src/test_gas_budget.rs @@ -0,0 +1,460 @@ +/// # Gas Budget Regression Tests — `callora-vault` +/// +/// Each test exercises one public entrypoint, then reads the host's CPU and +/// memory counters via `soroban_sdk::testutils::budget` and prints a single +/// JSON line to stdout: +/// +/// ```json +/// {"contract":"callora-vault","entrypoint":"deposit","cpu":341234,"mem":33210} +/// ``` +/// +/// `scripts/gas-regression.sh` harvests those lines, compares them against +/// `contracts/.gas-baseline.json`, and fails CI when any metric grows by more +/// than 5 %. +#[cfg(test)] +mod gas_budget { + extern crate std; + use std::println; + + use soroban_sdk::{testutils::Address as _, token, Address, Env}; + + use crate::{CalloraVault, CalloraVaultClient, DeductItem}; + use callora_settlement::{CalloraSettlement, CalloraSettlementClient}; + + fn create_usdc<'a>( + env: &'a Env, + admin: &Address, + ) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { + let addr = env.register_stellar_asset_contract_v2(admin.clone()); + let address = addr.address(); + ( + address.clone(), + token::Client::new(env, &address), + token::StellarAssetClient::new(env, &address), + ) + } + + fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { + let address = env.register(CalloraVault, ()); + (address.clone(), CalloraVaultClient::new(env, &address)) + } + + fn create_settlement(env: &Env, admin: &Address, vault_address: &Address) -> Address { + let addr = env.register(CalloraSettlement, ()); + CalloraSettlementClient::new(env, &addr).init(admin, vault_address); + addr + } + + fn fund_vault(admin: &token::StellarAssetClient, vault_address: &Address, amount: i128) { + admin.mint(vault_address, &amount); + } + + fn emit(entrypoint: &str, cpu: u64, mem: u64) { + println!( + "{{\"contract\":\"callora-vault\",\"entrypoint\":\"{entrypoint}\",\"cpu\":{cpu},\"mem\":{mem}}}" + ); + } + + macro_rules! measure { + ($env:expr, $ep:literal, $body:expr) => {{ + $body; + let res = $env.cost_estimate().resources(); + let cpu = res.instructions as u64; + let mem = res.read_bytes as u64 + res.write_bytes as u64; + emit($ep, cpu, mem); + }}; + } + + #[test] + fn gas_budget_init() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + measure!(env, "init", { + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + }); + } + + #[test] + fn gas_budget_deposit() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + usdc_admin.mint(&owner, &500); + usdc_client.approve(&owner, &vault_address, &500, &10_000); + measure!(env, "deposit", { + client.deposit(&owner, &500); + }); + } + + #[test] + fn gas_budget_deduct() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 5_000); + client.init( + &owner, + &usdc_address, + &Some(5_000), + &None, + &None, + &None, + &None, + ); + client.set_settlement(&owner, &settlement); + measure!(env, "deduct", { + client.deduct(&owner, &100, &None); + }); + } + + #[test] + fn gas_budget_batch_deduct() { + use soroban_sdk::Vec; + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let settlement = create_settlement(&env, &owner, &vault_address); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 10_000); + client.init( + &owner, + &usdc_address, + &Some(10_000), + &None, + &None, + &None, + &None, + ); + client.set_settlement(&owner, &settlement); + let mut items: Vec = Vec::new(&env); + items.push_back(DeductItem { + amount: 100, + request_id: None, + }); + items.push_back(DeductItem { + amount: 200, + request_id: None, + }); + items.push_back(DeductItem { + amount: 300, + request_id: None, + }); + measure!(env, "batch_deduct", { + client.batch_deduct(&owner, &items); + }); + } + + #[test] + fn gas_budget_set_allowed_depositor() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "set_allowed_depositor", { + client.set_allowed_depositor(&owner, &Some(depositor)); + }); + } + + #[test] + fn gas_budget_clear_allowed_depositors() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + client.set_allowed_depositor(&owner, &Some(depositor)); + measure!(env, "clear_allowed_depositors", { + client.clear_allowed_depositors(&owner); + }); + } + + #[test] + fn gas_budget_set_authorized_caller() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let new_caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + let nonce = client.get_authorized_caller_nonce(); + measure!(env, "set_authorized_caller", { + client.set_authorized_caller(&Some(new_caller), &nonce); + }); + } + + #[test] + fn gas_budget_set_max_deduct() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "set_max_deduct", { + client.set_max_deduct(&5_000); + }); + } + + #[test] + fn gas_budget_pause() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "pause", { + client.pause(&owner); + }); + } + + #[test] + fn gas_budget_unpause() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + client.pause(&owner); + measure!(env, "unpause", { + client.unpause(&owner); + }); + } + + #[test] + fn gas_budget_get_meta() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "get_meta", { + let _ = client.get_meta(); + }); + } + + #[test] + fn gas_budget_balance() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "balance", { + let _ = client.balance(); + }); + } + + #[test] + fn gas_budget_is_paused() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "is_paused", { + let _ = client.is_paused(); + }); + } + + #[test] + fn gas_budget_get_max_deduct() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "get_max_deduct", { + let _ = client.get_max_deduct(); + }); + } + + #[test] + fn gas_budget_get_contract_addresses() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "get_contract_addresses", { + let _ = client.get_contract_addresses(); + }); + } + + #[test] + fn gas_budget_is_authorized_depositor() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + client.init( + &owner, + &usdc_address, + &Some(1_000), + &None, + &None, + &None, + &None, + ); + measure!(env, "is_authorized_depositor", { + let _ = client.is_authorized_depositor(&depositor); + }); + } + + /// Sanity: verify budget API returns non-zero values after a real call. + #[test] + fn gas_budget_sanity_nonzero() { + let env = Env::default(); + env.mock_all_auths(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + fund_vault(&usdc_admin, &vault_address, 1_000); + env.cost_estimate().resources(); // warm up + let res = env.cost_estimate().resources(); + assert!(res.instructions > 0, "CPU must be >0"); + assert!(res.read_bytes + res.write_bytes > 0, "mem must be >0"); + } +} diff --git a/scripts/gas-regression.sh b/scripts/gas-regression.sh new file mode 100755 index 00000000..b795d0fa --- /dev/null +++ b/scripts/gas-regression.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASELINE_PATH="${REPO_ROOT}/contracts/.gas-baseline.json" +THRESHOLD=5 +UPDATE_BASELINE=false +REPORT_PATH="${REPO_ROOT}/target/gas-report.md" +MEASUREMENTS_PATH="${REPO_ROOT}/target/gas-measurements.json" + +while [[ $# -gt 0 ]]; do + case "$1" in + --update-baseline) UPDATE_BASELINE=true ;; + --threshold) THRESHOLD="$2"; shift ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac + shift +done + +for dep in jq cargo python3; do + if ! command -v "$dep" &>/dev/null; then + echo "ERROR: '$dep' is required but not installed." >&2 + exit 2 + fi +done + +log() { echo "[gas-regression] $*"; } +err() { echo "[gas-regression] ERROR: $*" >&2; } + +pct_increase() { + python3 -c " +import math +base, cur = int('$1'), int('$2') +if base == 0: + print(0) +else: + pct = (cur - base) / base * 100 + print(math.ceil(pct)) +" +} + +log "Building workspace..." +cargo build --workspace 2>&1 | tail -5 + +log "Running gas measurement tests..." +mkdir -p "${REPO_ROOT}/target" + +cargo test -p callora-vault -- gas_budget --nocapture 2>/dev/null \ + | grep '^{"contract"' \ + > "${MEASUREMENTS_PATH}" || true + +if [[ ! -s "${MEASUREMENTS_PATH}" ]]; then + err "No gas measurements found." + exit 2 +fi + +log "Measurements written to ${MEASUREMENTS_PATH}" + +if $UPDATE_BASELINE; then + python3 - "${MEASUREMENTS_PATH}" "${BASELINE_PATH}" <<'PYEOF' +import json, sys +path_m, path_b = sys.argv[1], sys.argv[2] +with open(path_m) as f: + lines = [json.loads(l) for l in f if l.strip()] +try: + with open(path_b) as f: + baseline = json.load(f) +except FileNotFoundError: + baseline = {} +baseline.setdefault("_schema_version", 1) +for m in lines: + baseline.setdefault(m["contract"], {})[m["entrypoint"]] = {"cpu": m["cpu"], "mem": m["mem"]} +with open(path_b, "w") as f: + json.dump(baseline, f, indent=2) + f.write("\n") +print(f"Baseline written to {path_b}") +PYEOF + log "Done. Commit ${BASELINE_PATH}." + exit 0 +fi + +if [[ ! -f "${BASELINE_PATH}" ]]; then + err "Baseline not found. Run: ./scripts/gas-regression.sh --update-baseline" + exit 2 +fi + +FAIL=0 +REPORT_ROWS="" + +while IFS= read -r line; do + contract=$(echo "$line" | jq -r '.contract') + ep=$(echo "$line" | jq -r '.entrypoint') + cur_cpu=$(echo "$line" | jq -r '.cpu') + cur_mem=$(echo "$line" | jq -r '.mem') + base_cpu=$(jq -r --arg c "$contract" --arg e "$ep" '.[$c][$e].cpu // 0' "${BASELINE_PATH}") + base_mem=$(jq -r --arg c "$contract" --arg e "$ep" '.[$c][$e].mem // 0' "${BASELINE_PATH}") + cpu_pct=$(pct_increase "$base_cpu" "$cur_cpu") + mem_pct=$(pct_increase "$base_mem" "$cur_mem") + cpu_ok="✅"; mem_ok="✅"; row_fail=false + if [[ "$cpu_pct" -gt "$THRESHOLD" ]]; then cpu_ok="❌"; row_fail=true; fi + if [[ "$mem_pct" -gt "$THRESHOLD" ]]; then mem_ok="❌"; row_fail=true; fi + if $row_fail; then FAIL=1; log "FAIL [$contract::$ep] cpu=+${cpu_pct}% mem=+${mem_pct}%"; fi + REPORT_ROWS+="| \`${contract}\` | \`${ep}\` | ${base_cpu} | ${cur_cpu} | ${cpu_pct}% ${cpu_ok} | ${base_mem} | ${cur_mem} | ${mem_pct}% ${mem_ok} |"$'\n' +done < "${MEASUREMENTS_PATH}" + +mkdir -p "$(dirname "${REPORT_PATH}")" +cat > "${REPORT_PATH}" < Threshold: **${THRESHOLD}%** + +| Contract | Entrypoint | CPU base | CPU now | CPU Δ | Mem base | Mem now | Mem Δ | +|----------|-----------|---------|---------|-------|---------|---------|-------| +${REPORT_ROWS} +MDEOF + +cat "${REPORT_PATH}" + +if [[ "$FAIL" -ne 0 ]]; then + err "Regression detected. Run --update-baseline if intentional." + exit 1 +fi + +log "All entrypoints within threshold. ✅" +exit 0