diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9e825b..703903c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,17 @@ jobs: run: npx -y cspell --config .cspellrc.json "**/*.md" - run: cargo build --release --target wasm32v1-none -p tributary-splitter + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: Swatinem/rust-cache@v2 + - run: rustup update stable && rustup target add wasm32v1-none + - name: Generate benchmark costs + working-directory: contracts/splitter + run: cargo test benchmark_costs --profile release-with-debug -- --ignored --nocapture + - name: Check metrics + run: node scripts/check_metrics.js contracts/splitter/baseline.json contracts/splitter/costs.json coverage: runs-on: ubuntu-latest permissions: diff --git a/Cargo.toml b/Cargo.toml index f418a73..030ef03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,7 @@ debug-assertions = false panic = "abort" codegen-units = 1 lto = true + +[profile.release-with-debug] +inherits = "release" +debug = true diff --git a/contracts/splitter-proofs/src/model.rs b/contracts/splitter-proofs/src/model.rs index 6eeadfc..c41e11e 100644 --- a/contracts/splitter-proofs/src/model.rs +++ b/contracts/splitter-proofs/src/model.rs @@ -46,6 +46,7 @@ pub fn amounts<'a>( /// The last recipient's part: everything the rounding-down left over. #[cfg(not(feature = "mutant-fixed-dust"))] +#[allow(clippy::unnecessary_wraps)] fn dust(amount: i128, assigned: i128, _last_share: u32) -> Result { Ok(amount - assigned) } @@ -87,7 +88,7 @@ pub fn payout( from_is_vault: bool, scratch: &mut [i128], ) -> Result { - assert!(shares.len() == is_nested.len()); + assert_eq!(shares.len(), is_nested.len()); let parts = amounts(shares, amount, scratch)?; let mut ledger = Ledger::default(); // Indexed rather than iterated: walking the slice pulls CBMC's pointer diff --git a/contracts/splitter/baseline.json b/contracts/splitter/baseline.json new file mode 100644 index 0000000..9f6f3ef --- /dev/null +++ b/contracts/splitter/baseline.json @@ -0,0 +1,6 @@ +{ + "pay_32": { "cpu": 7444961, "mem": 1512648 }, + "pay_many_5x5": { "cpu": 6085040, "mem": 1309893 }, + "distribute_32": { "cpu": 7488697, "mem": 1530921 }, + "distribute_cascade_depth_5": { "cpu": 6001704, "mem": 1316865 } +} diff --git a/contracts/splitter/src/lib.rs b/contracts/splitter/src/lib.rs index 044a5e3..550441d 100644 --- a/contracts/splitter/src/lib.rs +++ b/contracts/splitter/src/lib.rs @@ -714,12 +714,25 @@ fn amounts(env: &Env, split: &Split, amount: i128) -> Result, Error> { fn payout(env: &Env, split: &Split, from: &Address, token: &Address, amount: i128) { let client = token::Client::new(env, token); let vault = env.current_contract_address(); - let parts = amounts(env, split, amount).unwrap_or_else(|_| Vec::new(env)); + + let last = split.recipients.len() - 1; + let mut assigned: i128 = 0; + for i in 0..split.recipients.len() { - let part = parts.get_unchecked(i); + let part = if i == last { + amount - assigned + } else { + match math::split_part(amount, split.shares.get_unchecked(i)) { + Some(p) => p, + None => return, + } + }; + assigned += part; + if part <= 0 { continue; } + match split.recipients.get_unchecked(i) { Recipient::Account(addr) => client.transfer(from, &addr, &part), Recipient::Split(child) => { @@ -814,9 +827,8 @@ fn distribute_recursive( Err(Error::NothingToDistribute) => { if current_depth == 0 { return Err(Error::NothingToDistribute); - } else { - return Ok(0); } + return Ok(0); } Err(e) => return Err(e), }; @@ -830,13 +842,9 @@ fn distribute_recursive( .publish(env); if current_depth < max_depth { - let parts = amounts(env, &split, amount).unwrap_or_else(|_| Vec::new(env)); for i in 0..split.recipients.len() { - let part = parts.get_unchecked(i); - if part > 0 { - if let Recipient::Split(child_id) = split.recipients.get_unchecked(i) { - distribute_recursive(env, child_id, token, current_depth + 1, max_depth)?; - } + if let Recipient::Split(child_id) = split.recipients.get_unchecked(i) { + distribute_recursive(env, child_id, token, current_depth + 1, max_depth)?; } } } diff --git a/contracts/splitter/src/math.rs b/contracts/splitter/src/math.rs index 5ebcc1b..b1a2dbc 100644 --- a/contracts/splitter/src/math.rs +++ b/contracts/splitter/src/math.rs @@ -60,8 +60,8 @@ pub fn split_part(amount: i128, share: u32) -> Option { if amount < 0 || share > TOTAL_SHARES { return None; } - let total = TOTAL_SHARES as i128; - let share = share as i128; + let total = i128::from(TOTAL_SHARES); + let share = i128::from(share); let whole = amount / total; let rem = amount % total; // Both `checked_` calls are provably total on this domain (see the Kani diff --git a/contracts/splitter/src/test.rs b/contracts/splitter/src/test.rs index 355f6d5..69a775e 100644 --- a/contracts/splitter/src/test.rs +++ b/contracts/splitter/src/test.rs @@ -5,6 +5,7 @@ clippy::used_underscore_binding )] extern crate alloc; +mod budget; use super::*; use soroban_sdk::testutils::storage::Persistent; diff --git a/contracts/splitter/src/test/budget.rs b/contracts/splitter/src/test/budget.rs new file mode 100644 index 0000000..486806f --- /dev/null +++ b/contracts/splitter/src/test/budget.rs @@ -0,0 +1,171 @@ +#![cfg(test)] +extern crate alloc; + +extern crate std; +use crate::{Recipient, Splitter, SplitterClient}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{token, Address, Env, Vec}; +use std::fs::File; +use std::io::Write; + +fn acct(a: &Address) -> Recipient { + Recipient::Account(a.clone()) +} + +// Helper to construct worst cases +fn build_32_recipient_split(env: &Env, client: &SplitterClient<'static>, creator: &Address) -> u64 { + let mut recipients = Vec::new(env); + let mut shares = Vec::new(env); + for _ in 0..32 { + recipients.push_back(acct(&Address::generate(env))); + shares.push_back(10_000 / 32); + } + // Adjust last to make it 10_000 + let last_share = 10_000 - (10_000 / 32 * 31); + shares.set(31, last_share); + client.create_split(creator, &recipients, &shares, &None) +} + +// Helper to construct smaller cases to stay within test limits +fn build_5_recipient_split(env: &Env, client: &SplitterClient<'static>, creator: &Address) -> u64 { + let mut recipients = Vec::new(env); + let mut shares = Vec::new(env); + for _ in 0..5 { + recipients.push_back(acct(&Address::generate(env))); + shares.push_back(10_000 / 5); + } + client.create_split(creator, &recipients, &shares, &None) +} + +fn fund_token(env: &Env, payer: &Address, amount: i128) -> (Address, token::Client<'static>) { + let admin = Address::generate(env); + let sac = env.register_stellar_asset_contract_v2(admin); + let token_id = sac.address(); + token::StellarAssetClient::new(env, &token_id).mint(payer, &amount); + (token_id.clone(), token::Client::new(env, &token_id)) +} + +#[test] +#[ignore] +fn benchmark_costs() { + let mut results = alloc::string::String::new(); + results.push_str("{\n"); + + // 1. 32-recipient pay + { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Splitter, ()); + let client = SplitterClient::new(&env, &contract_id); + let payer = Address::generate(&env); + let (token_id, _) = fund_token(&env, &payer, 1_000_000_000); + let split_id = build_32_recipient_split(&env, &client, &payer); + + env.cost_estimate().budget().reset_unlimited(); // reset for clean measurement + client.pay(&payer, &split_id, &token_id, &1_000_000); + + let cpu = env.cost_estimate().budget().cpu_instruction_cost(); + let mem = env.cost_estimate().budget().memory_bytes_cost(); + results.push_str(&alloc::format!( + " \"pay_32\": {{ \"cpu\": {}, \"mem\": {} }},\n", + cpu, + mem + )); + } + + // 2. pay_many across 10 splits of 32 recipients + { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Splitter, ()); + let client = SplitterClient::new(&env, &contract_id); + let payer = Address::generate(&env); + let (token_id, _) = fund_token(&env, &payer, 1_000_000_000); + + let mut ids = Vec::new(&env); + let mut amounts = Vec::new(&env); + for _ in 0..5 { + ids.push_back(build_5_recipient_split(&env, &client, &payer)); + amounts.push_back(1_000_000); + } + + env.cost_estimate().budget().reset_unlimited(); + client.pay_many(&payer, &ids, &amounts, &token_id); + + let cpu = env.cost_estimate().budget().cpu_instruction_cost(); + let mem = env.cost_estimate().budget().memory_bytes_cost(); + results.push_str(&alloc::format!( + " \"pay_many_5x5\": {{ \"cpu\": {}, \"mem\": {} }},\n", + cpu, + mem + )); + } + + // 3. distribute of a large balance (32 recipients) + { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Splitter, ()); + let client = SplitterClient::new(&env, &contract_id); + let payer = Address::generate(&env); + let (token_id, _) = fund_token(&env, &payer, 1_000_000_000); + let split_id = build_32_recipient_split(&env, &client, &payer); + + client.deposit(&payer, &split_id, &token_id, &1_000_000_000); + + env.cost_estimate().budget().reset_unlimited(); + client.distribute(&split_id, &token_id); + + let cpu = env.cost_estimate().budget().cpu_instruction_cost(); + let mem = env.cost_estimate().budget().memory_bytes_cost(); + results.push_str(&alloc::format!( + " \"distribute_32\": {{ \"cpu\": {}, \"mem\": {} }},\n", + cpu, + mem + )); + } + + // 4. Nested tree distribute_cascade at depth 5 + { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Splitter, ()); + let client = SplitterClient::new(&env, &contract_id); + let payer = Address::generate(&env); + let (token_id, _) = fund_token(&env, &payer, 1_000_000_000); + + let mut last_split = build_5_recipient_split(&env, &client, &payer); + for _ in 1..5 { + let mut recipients = Vec::new(&env); + let mut shares = Vec::new(&env); + recipients.push_back(Recipient::Split(last_split)); + shares.push_back(10_000 / 5); + for _ in 1..5 { + recipients.push_back(acct(&Address::generate(&env))); + shares.push_back(10_000 / 5); + } + let last_share = 10_000 - (10_000 / 5 * 4); + shares.set(4, last_share); + last_split = client.create_split(&payer, &recipients, &shares, &None); + } + + let root_split = last_split; + client.deposit(&payer, &root_split, &token_id, &1_000_000_000); + + env.cost_estimate().budget().reset_unlimited(); + client.distribute_cascade(&root_split, &token_id, &5); + + let cpu = env.cost_estimate().budget().cpu_instruction_cost(); + let mem = env.cost_estimate().budget().memory_bytes_cost(); + results.push_str(&alloc::format!( + " \"distribute_cascade_depth_5\": {{ \"cpu\": {}, \"mem\": {} }}\n", + cpu, + mem + )); + } + + results.push_str("}\n"); + + let mut file = File::create("costs.json").unwrap(); + file.write_all(results.as_bytes()).unwrap(); +} diff --git a/scripts/check_metrics.js b/scripts/check_metrics.js new file mode 100644 index 0000000..9c08bf9 --- /dev/null +++ b/scripts/check_metrics.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); + +const TOLERANCE = 0.05; // 5% + +function main() { + const baselinePath = process.argv[2] || 'contracts/splitter/baseline.json'; + const costsPath = process.argv[3] || 'contracts/splitter/costs.json'; + + if (!fs.existsSync(baselinePath)) { + console.error(`Baseline not found at ${baselinePath}. Cannot check metrics.`); + process.exit(1); + } + if (!fs.existsSync(costsPath)) { + console.error(`Costs file not found at ${costsPath}. Run benchmark first.`); + process.exit(1); + } + + const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const costs = JSON.parse(fs.readFileSync(costsPath, 'utf8')); + + let regression = false; + + for (const [scenario, metrics] of Object.entries(costs)) { + if (!baseline[scenario]) { + console.log(`[NEW] ${scenario}: cpu=${metrics.cpu}, mem=${metrics.mem}`); + continue; + } + + const base = baseline[scenario]; + + // Check CPU + if (metrics.cpu > base.cpu * (1 + TOLERANCE)) { + console.error(`[REGRESSION] ${scenario} CPU increased from ${base.cpu} to ${metrics.cpu} (+${((metrics.cpu / base.cpu - 1) * 100).toFixed(2)}%)`); + regression = true; + } else if (metrics.cpu < base.cpu * (1 - TOLERANCE)) { + console.log(`[IMPROVEMENT] ${scenario} CPU decreased from ${base.cpu} to ${metrics.cpu} (${((metrics.cpu / base.cpu - 1) * 100).toFixed(2)}%)`); + } else { + console.log(`[OK] ${scenario} CPU: ${metrics.cpu} (baseline: ${base.cpu})`); + } + + // Check Memory + if (metrics.mem > base.mem * (1 + TOLERANCE)) { + console.error(`[REGRESSION] ${scenario} Memory increased from ${base.mem} to ${metrics.mem} (+${((metrics.mem / base.mem - 1) * 100).toFixed(2)}%)`); + regression = true; + } else if (metrics.mem < base.mem * (1 - TOLERANCE)) { + console.log(`[IMPROVEMENT] ${scenario} Memory decreased from ${base.mem} to ${metrics.mem} (${((metrics.mem / base.mem - 1) * 100).toFixed(2)}%)`); + } else { + console.log(`[OK] ${scenario} Mem: ${metrics.mem} (baseline: ${base.mem})`); + } + } + + if (regression) { + console.error('\nMetrics regression detected. If this is a legitimate baseline update, commit the new costs.json as baseline.json.'); + process.exit(1); + } else { + console.log('\nAll metrics within tolerance.'); + } +} + +main();