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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true

[profile.release-with-debug]
inherits = "release"
debug = true
3 changes: 2 additions & 1 deletion contracts/splitter-proofs/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128, Overflow> {
Ok(amount - assigned)
}
Expand Down Expand Up @@ -87,7 +88,7 @@ pub fn payout(
from_is_vault: bool,
scratch: &mut [i128],
) -> Result<Ledger, Overflow> {
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
Expand Down
6 changes: 6 additions & 0 deletions contracts/splitter/baseline.json
Original file line number Diff line number Diff line change
@@ -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 }
}
28 changes: 18 additions & 10 deletions contracts/splitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -714,12 +714,25 @@ fn amounts(env: &Env, split: &Split, amount: i128) -> Result<Vec<i128>, 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) => {
Expand Down Expand Up @@ -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),
};
Expand All @@ -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)?;
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions contracts/splitter/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ pub fn split_part(amount: i128, share: u32) -> Option<i128> {
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
Expand Down
1 change: 1 addition & 0 deletions contracts/splitter/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
clippy::used_underscore_binding
)]
extern crate alloc;
mod budget;

use super::*;
use soroban_sdk::testutils::storage::Persistent;
Expand Down
171 changes: 171 additions & 0 deletions contracts/splitter/src/test/budget.rs
Original file line number Diff line number Diff line change
@@ -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();
}
61 changes: 61 additions & 0 deletions scripts/check_metrics.js
Original file line number Diff line number Diff line change
@@ -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();
Loading