Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,17 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings

- name: Run tests
run: cargo test
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
336 changes: 335 additions & 1 deletion app/contract/contracts/quickex/src/bench_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<K: ToXdr, V: ToXdr>(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<F>(
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<Address>,
) -> 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())
}
Expand All @@ -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<CoreBenchResult> = 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]
Expand Down
Loading