diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 05eb7e065..4bf3fb4ec 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -40,4 +40,17 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test \ No newline at end of file + run: cargo test + + - name: Run performance benchmarks + run: | + mkdir -p "$GITHUB_WORKSPACE/benchmark-results" + QUICKEX_BENCH_ARTIFACT_DIR="$GITHUB_WORKSPACE/benchmark-results" cargo test bench_core_lifecycle_costs -- --nocapture --test-threads=1 + + - name: Upload performance benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: quickex-contract-performance-benchmarks + path: benchmark-results/ + if-no-files-found: error diff --git a/app/contract/contracts/quickex/src/bench_test.rs b/app/contract/contracts/quickex/src/bench_test.rs index 4bf543fb5..9bf6c4d25 100644 --- a/app/contract/contracts/quickex/src/bench_test.rs +++ b/app/contract/contracts/quickex/src/bench_test.rs @@ -19,12 +19,17 @@ extern crate std; use crate::{ + escrow_id, storage::{put_escrow, DataKey, PRIVACY_ENABLED_KEY}, EscrowEntry, EscrowStatus, QuickexContract, QuickexContractClient, }; use soroban_sdk::{ - testutils::Address as _, token, xdr::ToXdr, Address, Bytes, BytesN, Env, Symbol, Vec, + testutils::{Address as _, Ledger}, + token, + xdr::ToXdr, + Address, Bytes, BytesN, Env, Symbol, Vec, }; +use std::{format, string::String, vec::Vec as StdVec}; // --------------------------------------------------------------------------- // Shared helpers @@ -96,6 +101,173 @@ fn print_budget(env: &Env, label: &str) { std::println!("[bench] {label:<35} cpu={cpu:<12} mem={mem}"); } +#[derive(Clone, Copy)] +struct CoreBenchResult { + operation: &'static str, + cpu_instructions: u64, + memory_bytes: u64, + storage_fee_bytes: u64, + max_cpu_instructions: u64, + max_memory_bytes: u64, + max_storage_fee_bytes: u64, +} + +impl CoreBenchResult { + fn assert_within_threshold(self) { + assert!( + self.cpu_instructions <= self.max_cpu_instructions, + "{} CPU instruction regression: actual={} max={}", + self.operation, + self.cpu_instructions, + self.max_cpu_instructions + ); + assert!( + self.memory_bytes <= self.max_memory_bytes, + "{} memory regression: actual={} max={}", + self.operation, + self.memory_bytes, + self.max_memory_bytes + ); + assert!( + self.storage_fee_bytes <= self.max_storage_fee_bytes, + "{} storage fee regression: actual={} max={}", + self.operation, + self.storage_fee_bytes, + self.max_storage_fee_bytes + ); + } +} + +fn storage_bytes_for_pair(env: &Env, key: &K, value: &V) -> u64 { + key.to_xdr(env).len() as u64 + value.to_xdr(env).len() as u64 +} + +fn escrow_storage_fee_bytes(env: &Env, commitment: &BytesN<32>, entry: &EscrowEntry) -> u64 { + let commitment_bytes: Bytes = commitment.clone().into(); + storage_bytes_for_pair(env, &DataKey::Escrow(commitment_bytes), entry) +} + +fn escrow_id_storage_fee_bytes(env: &Env, escrow_id: &BytesN<32>, commitment: &BytesN<32>) -> u64 { + storage_bytes_for_pair(env, &DataKey::EscrowIdMap(escrow_id.clone()), commitment) +} + +fn measured_budget(env: &Env) -> (u64, u64) { + ( + env.cost_estimate().budget().cpu_instruction_cost(), + env.cost_estimate().budget().memory_bytes_cost(), + ) +} + +fn bench_core_op( + env: &Env, + operation: &'static str, + storage_fee_bytes: u64, + max_cpu_instructions: u64, + max_memory_bytes: u64, + max_storage_fee_bytes: u64, + run: F, +) -> CoreBenchResult +where + F: FnOnce(), +{ + env.cost_estimate().budget().reset_default(); + run(); + let (cpu_instructions, memory_bytes) = measured_budget(env); + let result = CoreBenchResult { + operation, + cpu_instructions, + memory_bytes, + storage_fee_bytes, + max_cpu_instructions, + max_memory_bytes, + max_storage_fee_bytes, + }; + std::println!( + "[bench-core] {:<8} cpu={} mem={} storage_fee_bytes={}", + operation, + cpu_instructions, + memory_bytes, + storage_fee_bytes + ); + result +} + +fn write_core_bench_artifacts(results: &[CoreBenchResult]) { + let artifact_dir = match std::env::var("QUICKEX_BENCH_ARTIFACT_DIR") { + Ok(path) => path, + Err(_) => return, + }; + + std::fs::create_dir_all(&artifact_dir).expect("create benchmark artifact directory"); + + let mut json = String::from("{\n \"suite\": \"quickex-core-flow-costs\",\n \"results\": [\n"); + for (idx, result) in results.iter().enumerate() { + let comma = if idx + 1 == results.len() { "" } else { "," }; + json.push_str(&format!( + " {{ \"operation\": \"{}\", \"cpu_instructions\": {}, \"memory_bytes\": {}, \"storage_fee_bytes\": {}, \"thresholds\": {{ \"cpu_instructions\": {}, \"memory_bytes\": {}, \"storage_fee_bytes\": {} }} }}{}\n", + result.operation, + result.cpu_instructions, + result.memory_bytes, + result.storage_fee_bytes, + result.max_cpu_instructions, + result.max_memory_bytes, + result.max_storage_fee_bytes, + comma + )); + } + json.push_str(" ]\n}\n"); + + let mut markdown = String::from( + "# QuickEx Core Flow Cost Benchmarks\n\n| Operation | CPU instructions | Memory bytes | Storage fee bytes | CPU max | Memory max | Storage max |\n| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n", + ); + for result in results { + markdown.push_str(&format!( + "| {} | {} | {} | {} | {} | {} | {} |\n", + result.operation, + result.cpu_instructions, + result.memory_bytes, + result.storage_fee_bytes, + result.max_cpu_instructions, + result.max_memory_bytes, + result.max_storage_fee_bytes + )); + } + + std::fs::write( + format!("{artifact_dir}/quickex-core-benchmarks.json"), + json.as_bytes(), + ) + .expect("write benchmark json artifact"); + std::fs::write( + format!("{artifact_dir}/quickex-core-benchmarks.md"), + markdown.as_bytes(), + ) + .expect("write benchmark markdown artifact"); +} + +fn expected_escrow_entry( + env: &Env, + token: &Address, + owner: &Address, + amount: i128, + status: EscrowStatus, + expires_at: u64, + arbiter: Option
, +) -> EscrowEntry { + EscrowEntry { + token: token.clone(), + amount_due: amount, + amount_paid: amount, + owner: owner.clone(), + status, + created_at: env.ledger().timestamp(), + expires_at, + arbiter, + arbiters: Vec::new(env), + arbiter_threshold: 0, + } +} + fn legacy_privacy_storage_key(env: &Env, owner: &Address) -> (Symbol, Address) { (Symbol::new(env, PRIVACY_ENABLED_KEY), owner.clone()) } @@ -104,6 +276,168 @@ fn legacy_privacy_storage_key(env: &Env, owner: &Address) -> (Symbol, Address) { // Hot-path benchmarks // --------------------------------------------------------------------------- +/// Benchmark: core lifecycle costs for create, fulfill, refund, and dispute. +/// Fails when a cost crosses its checked-in regression threshold. +#[test] +fn bench_core_lifecycle_costs() { + let mut results: StdVec = StdVec::new(); + + { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let salt = Bytes::from_slice(&env, b"bench_core_create"); + let amount: i128 = 1_000_000; + let timeout_secs = 600u64; + let arbiter = Some(Address::generate(&env)); + token::StellarAssetClient::new(&env, &token).mint(&owner, &amount); + let commitment = make_commitment(&env, &owner, amount, &salt); + let escrow_id = escrow_id::derive_escrow_id( + &env, + &token, + amount, + &owner, + &salt, + timeout_secs, + &arbiter, + ) + .expect("derive escrow id"); + let entry = expected_escrow_entry( + &env, + &token, + &owner, + amount, + EscrowStatus::Pending, + env.ledger().timestamp() + timeout_secs, + arbiter.clone(), + ); + let storage_fee_bytes = escrow_storage_fee_bytes(&env, &commitment, &entry) + + escrow_id_storage_fee_bytes(&env, &escrow_id, &commitment); + + results.push(bench_core_op( + &env, + "create", + storage_fee_bytes, + 500_000, + 100_000, + 1_000, + || { + client.deposit(&token, &amount, &owner, &salt, &timeout_secs, &arbiter); + }, + )); + } + + { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let salt = Bytes::from_slice(&env, b"bench_core_fulfill"); + let amount: i128 = 1_000_000; + let commitment = make_commitment(&env, &owner, amount, &salt); + seed_escrow( + &env, + &client.address, + &token, + &owner, + amount, + commitment.clone(), + ); + token::StellarAssetClient::new(&env, &token).mint(&client.address, &amount); + let entry = + expected_escrow_entry(&env, &token, &owner, amount, EscrowStatus::Spent, 0, None); + + results.push(bench_core_op( + &env, + "fulfill", + escrow_storage_fee_bytes(&env, &commitment, &entry), + 500_000, + 100_000, + 1_000, + || { + client.withdraw(&token, &amount, &commitment, &owner, &salt); + }, + )); + } + + { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let salt = Bytes::from_slice(&env, b"bench_core_refund"); + let amount: i128 = 1_000_000; + let timeout_secs = 10u64; + token::StellarAssetClient::new(&env, &token).mint(&owner, &amount); + let commitment = client.deposit(&token, &amount, &owner, &salt, &timeout_secs, &None); + env.ledger() + .set_timestamp(env.ledger().timestamp() + timeout_secs); + let entry = expected_escrow_entry( + &env, + &token, + &owner, + amount, + EscrowStatus::Refunded, + env.ledger().timestamp(), + None, + ); + + results.push(bench_core_op( + &env, + "refund", + escrow_storage_fee_bytes(&env, &commitment, &entry), + 500_000, + 100_000, + 1_000, + || { + client.refund(&commitment, &owner); + }, + )); + } + + { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let arbiter = Address::generate(&env); + let salt = Bytes::from_slice(&env, b"bench_core_dispute"); + let amount: i128 = 1_000_000; + token::StellarAssetClient::new(&env, &token).mint(&owner, &amount); + let commitment = client.deposit( + &token, + &amount, + &owner, + &salt, + &600u64, + &Some(arbiter.clone()), + ); + let entry = expected_escrow_entry( + &env, + &token, + &owner, + amount, + EscrowStatus::Disputed, + env.ledger().timestamp() + 600, + Some(arbiter), + ); + + results.push(bench_core_op( + &env, + "dispute", + escrow_storage_fee_bytes(&env, &commitment, &entry), + 500_000, + 100_000, + 1_000, + || { + client.dispute(&commitment); + }, + )); + } + + write_core_bench_artifacts(&results); + for result in results { + result.assert_within_threshold(); + } +} + /// Benchmark: create_amount_commitment /// Deepest hot path — called inside every deposit and withdraw. #[test]