From f29db52d876276f27c2f3c6543ca82f73a722b23 Mon Sep 17 00:00:00 2001 From: Tommy Volk Date: Fri, 31 Jul 2026 13:16:59 -0500 Subject: [PATCH] chore: harden market contract guardrails --- Cargo.lock | 2 + crates/deadcat-client/Cargo.toml | 2 + .../deadcat-client/tests/simplicity_budget.rs | 250 +++++++++++++----- crates/deadcat-contracts/build.rs | 24 +- .../src/binary_market/compiled.rs | 4 +- .../tests/fixtures/binary_market_abi.txt | 29 ++ .../deadcat-contracts/tests/golden_vectors.rs | 74 +++++- flake.nix | 9 +- scripts/check-smplx-pin.sh | 161 ++++++++++- 9 files changed, 472 insertions(+), 83 deletions(-) create mode 100644 crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt diff --git a/Cargo.lock b/Cargo.lock index 1bbe1b6..311ab94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,6 +905,7 @@ dependencies = [ "rand 0.8.7", "serde", "serde_json", + "simplicity-lang", "smplx-regtest", "smplx-std", "tempfile", @@ -3897,6 +3898,7 @@ dependencies = [ "ghost-cell", "hex-conservative 0.2.2", "miniscript", + "serde", "simplicity-sys", ] diff --git a/crates/deadcat-client/Cargo.toml b/crates/deadcat-client/Cargo.toml index 7d973c5..d26dc9a 100644 --- a/crates/deadcat-client/Cargo.toml +++ b/crates/deadcat-client/Cargo.toml @@ -23,6 +23,8 @@ deadcat-iroh = { path = "../deadcat-iroh" } deadcat-node = { path = "../deadcat-node" } serde.workspace = true serde_json.workspace = true +# Enable serde for the smplx-reexported Simplicity Cost/NodeBounds metrics. +simplicity-lang = { version = "=0.8.0", features = ["serde"] } smplx-regtest.workspace = true tempfile.workspace = true tokio.workspace = true diff --git a/crates/deadcat-client/tests/simplicity_budget.rs b/crates/deadcat-client/tests/simplicity_budget.rs index 04f57b4..d74821c 100644 --- a/crates/deadcat-client/tests/simplicity_budget.rs +++ b/crates/deadcat-client/tests/simplicity_budget.rs @@ -24,19 +24,24 @@ use elements::{AssetId, LockTime, OutPoint, Script, Sequence, TxOut, TxOutWitnes use serde::Serialize; use simplex::program::{ProgramTrait as _, WitnessTrait as _}; use simplex::simplicityhl::simplicity::jet::Elements; -use simplex::simplicityhl::simplicity::{BitIter, RedeemNode}; - -#[derive(Debug)] -struct Underbudget { - label: String, - milliweight: String, - stack_bytes: usize, - required_annex_bytes: usize, -} +use simplex::simplicityhl::simplicity::{BitIter, Cost, RedeemNode}; + +// Rounded CI ceilings with headroom above the reviewed maxima of 4,557,857 mw, +// 70,903 cells, 62 frames, 6,223 stack bytes, 15,334 transaction bytes, +// 17,284 WU, and 4,321 vB. The exact measurements remain in the JSON report. +const MAX_MARKET_COVENANT_COST_MILLIWEIGHT: u64 = 5_000_000; +const MAX_MARKET_INPUT_EXTRA_CELLS: usize = 80_000; +const MAX_MARKET_INPUT_EXTRA_FRAMES: usize = 70; +const MAX_MARKET_COVENANT_STACK_BYTES: usize = 7_000; +const MAX_MARKET_TX_BYTES: usize = 17_000; +const MAX_MARKET_TX_WEIGHT: usize = 19_000; +const MAX_MARKET_TX_VSIZE: usize = 5_000; #[derive(Clone, Copy, Debug, Default, Serialize)] struct CovenantMetrics { cost_milliweight: u64, + max_extra_cells: usize, + max_extra_frames: usize, program_bytes: usize, witness_bytes: usize, stack_bytes: usize, @@ -45,7 +50,12 @@ struct CovenantMetrics { impl CovenantMetrics { fn add_assign(&mut self, other: Self) { - self.cost_milliweight += other.cost_milliweight; + self.cost_milliweight = self + .cost_milliweight + .checked_add(other.cost_milliweight) + .expect("aggregate Simplicity cost fits u64"); + self.max_extra_cells = self.max_extra_cells.max(other.max_extra_cells); + self.max_extra_frames = self.max_extra_frames.max(other.max_extra_frames); self.program_bytes += other.program_bytes; self.witness_bytes += other.witness_bytes; self.stack_bytes += other.stack_bytes; @@ -53,6 +63,13 @@ impl CovenantMetrics { } } +fn cost_milliweight(cost: Cost) -> u64 { + serde_json::to_value(cost) + .expect("serialize typed Simplicity cost") + .as_u64() + .expect("Simplicity cost serializes as integer milliweight") +} + #[derive(Debug, Serialize)] struct MarketMetrics<'a> { stage: &'a str, @@ -65,55 +82,117 @@ struct MarketMetrics<'a> { tx_discount_vsize: usize, } -impl std::fmt::Display for Underbudget { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - "{}: cost={}mw stack={}B required_annex={}B", - self.label, self.milliweight, self.stack_bytes, self.required_annex_bytes - ) - } +fn assert_at_most(label: &str, metric: &str, actual: T, ceiling: T) +where + T: Copy + PartialOrd + std::fmt::Display, +{ + assert!( + actual <= ceiling, + "{label}: {metric} {actual} exceeds CI ceiling {ceiling}" + ); } -fn failure_report(failures: &[Underbudget]) -> String { - failures - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n") +fn assert_market_resource_ceilings(metrics: &MarketMetrics<'_>) { + let label = format!("{}/{}", metrics.stage, metrics.rt_input_side); + assert_at_most( + &label, + "aggregate covenant cost (milliweight)", + metrics.covenant.cost_milliweight, + MAX_MARKET_COVENANT_COST_MILLIWEIGHT, + ); + assert_at_most( + &label, + "maximum per-input extra cells", + metrics.covenant.max_extra_cells, + MAX_MARKET_INPUT_EXTRA_CELLS, + ); + assert_at_most( + &label, + "maximum per-input extra frames", + metrics.covenant.max_extra_frames, + MAX_MARKET_INPUT_EXTRA_FRAMES, + ); + assert_at_most( + &label, + "aggregate covenant stack bytes", + metrics.covenant.stack_bytes, + MAX_MARKET_COVENANT_STACK_BYTES, + ); + assert_at_most( + &label, + "transaction bytes", + metrics.tx_bytes, + MAX_MARKET_TX_BYTES, + ); + assert_at_most( + &label, + "transaction weight", + metrics.tx_weight, + MAX_MARKET_TX_WEIGHT, + ); + assert_at_most( + &label, + "transaction vsize", + metrics.tx_vsize, + MAX_MARKET_TX_VSIZE, + ); } -fn record_budget( - label: impl Into, - stack: &[Vec], - failures: &mut Vec, -) -> CovenantMetrics { +fn assert_canonical_padding(label: &str, annex: &[u8]) { + assert_eq!(annex.first(), Some(&0x50), "{label}: annex tag"); + assert!( + annex[1..].iter().all(|byte| *byte == 0), + "{label}: annex must contain only the 0x50 tag followed by zero padding" + ); +} + +fn record_budget(label: impl Into, stack: &[Vec]) -> CovenantMetrics { + let label = label.into(); let stack = stack.to_vec(); let (core_stack, annex) = strip_taproot_annex(&stack); assert_eq!( core_stack.len(), 4, - "finalized Simplicity stack must have four core elements" + "{label}: finalized Simplicity stack must have four core elements" ); let redeem = RedeemNode::decode::<_, _, Elements>( BitIter::from(core_stack[1].iter().copied()), BitIter::from(core_stack[0].iter().copied()), ) .expect("decode finalized Simplicity program"); - let cost = redeem.bounds().cost; - if !cost.is_budget_valid(&stack) { - failures.push(Underbudget { - label: label.into(), - milliweight: cost.to_string(), - stack_bytes: elements::encode::serialize(&stack).len(), - required_annex_bytes: cost.get_padding(&stack).map_or(0, |annex| annex.len()), - }); + let bounds = redeem.bounds(); + let cost = bounds.cost; + assert!( + cost.is_budget_valid(&stack), + "{label}: finalized stack is underbudget for execution cost {cost}mw" + ); + + let canonical_padding = cost.get_padding(&core_stack.to_vec()); + assert_eq!( + annex, + canonical_padding.as_deref(), + "{label}: finalized stack must use exactly the canonical budget padding" + ); + if let Some(annex) = annex { + assert_canonical_padding(&label, annex); + if annex.len() > 1 { + let mut shortened = stack.clone(); + assert_eq!( + shortened.last_mut().expect("annex").pop(), + Some(0), + "{label}: last annex byte must be zero padding" + ); + assert!( + !cost.is_budget_valid(&shortened), + "{label}: finalized annex contains unnecessary zero padding" + ); + } } + CovenantMetrics { - cost_milliweight: cost - .to_string() - .parse() - .expect("Simplicity cost is an integer milliweight"), + cost_milliweight: cost_milliweight(cost), + max_extra_cells: bounds.extra_cells, + max_extra_frames: bounds.extra_frames, program_bytes: core_stack[1].len(), witness_bytes: core_stack[0].len(), stack_bytes: elements::encode::serialize(&stack).len(), @@ -121,6 +200,68 @@ fn record_budget( } } +#[test] +fn simplicity_budget_padding_is_minimal_at_compact_size_boundaries() { + // Keep the four-item shape of a finalized Simplicity stack. The contents do + // not matter for budget accounting, which uses consensus-encoded length. + let stack = vec![Vec::new(); 4]; + let base_budget = elements::encode::serialize(&stack).len() + 50; + let cases = [ + ("tag-only-annex", 1_usize, 1_usize), + ("annex-252/deficit-253", 253, 252), + ("annex-253/deficit-254", 254, 253), + ("annex-253/deficit-255", 255, 253), + ("annex-253/deficit-256", 256, 253), + ("annex-254/deficit-257", 257, 254), + ("annex-65535/deficit-65538", 65_538, 65_535), + ("annex-65536/deficit-65539", 65_539, 65_536), + ("annex-65536/deficit-65540", 65_540, 65_536), + ("annex-65536/deficit-65541", 65_541, 65_536), + ("annex-65537/deficit-65542", 65_542, 65_537), + ]; + + for (label, deficit, expected_annex_len) in cases { + let milliweight = + u32::try_from((base_budget + deficit) * 1_000).expect("boundary test cost fits u32"); + let cost = Cost::from_milliweight(milliweight); + assert!( + !cost.is_budget_valid(&stack), + "{label}: unpadded stack unexpectedly has sufficient budget" + ); + + let annex = cost + .get_padding(&stack) + .unwrap_or_else(|| panic!("{label}: expected padding")); + assert_eq!(annex.len(), expected_annex_len, "{label}: annex length"); + assert_canonical_padding(label, &annex); + + let mut padded = stack.clone(); + padded.push(annex); + assert!( + cost.is_budget_valid(&padded), + "{label}: generated padding must satisfy the budget" + ); + if expected_annex_len == 1 { + let removed = padded.pop().expect("tag-only annex"); + assert_eq!(removed.as_slice(), &[0x50], "{label}: removed annex"); + assert!( + !cost.is_budget_valid(&padded), + "{label}: removing the tag-only annex must leave the core stack underbudget" + ); + } else { + assert_eq!( + padded.last_mut().expect("annex").pop(), + Some(0), + "{label}: boundary annex must end in zero padding" + ); + assert!( + !cost.is_budget_valid(&padded), + "{label}: removing one zero must make the padding insufficient" + ); + } + } +} + fn asset(byte: u8) -> AssetId { AssetId::from_slice(&[byte; 32]).expect("asset id") } @@ -741,7 +882,6 @@ fn every_finalized_market_stack_has_sufficient_simplicity_budget() { let network = SimplicityNetwork::ElementsRegtest { policy_asset: params.collateral_asset_id, }; - let mut failures = Vec::new(); let mut measurements = Vec::new(); let compiled = CompiledBinaryMarket::new(params).expect("compile canonical market"); for side in [RtSide::A, RtSide::B] { @@ -834,12 +974,11 @@ fn every_finalized_market_stack_has_sufficient_simplicity_budget() { covenant.add_assign(record_budget( format!("{label}/{side:?}/input-{input_index}"), stack, - &mut failures, )); } let transaction = pset.extract_tx().expect("extract finalized market tx"); - measurements.push(MarketMetrics { + let metrics = MarketMetrics { stage: label, rt_input_side: match side { RtSide::A => "a", @@ -851,7 +990,9 @@ fn every_finalized_market_stack_has_sufficient_simplicity_budget() { tx_vsize: transaction.vsize(), tx_discount_weight: transaction.discount_weight(), tx_discount_vsize: transaction.discount_vsize(), - }); + }; + assert_market_resource_ceilings(&metrics); + measurements.push(metrics); let interpreted = interpret_binary_market_spend_with_compiled( &compiled, before, @@ -948,11 +1089,6 @@ fn every_finalized_market_stack_has_sufficient_simplicity_budget() { } } } - let report = failure_report(&failures); - assert!( - failures.is_empty(), - "underbudget finalized market stacks:\n{report}" - ); eprintln!( "DEADCAT_AB_MARKET_METRICS={}", serde_json::to_string(&measurements).expect("serialize market measurements") @@ -1688,17 +1824,7 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato if canonical_stack.len() == 5 { mixed_stack.push(canonical_stack[4].clone()); } - let mut failures = Vec::new(); - record_budget( - format!("mixed-follower-{slot:?}"), - &mixed_stack, - &mut failures, - ); - assert!( - failures.is_empty(), - "mixed follower is underbudget: {}", - failure_report(&failures) - ); + record_budget(format!("mixed-follower-{slot:?}"), &mixed_stack); mixed_witness_pset.inputs_mut()[input_index].final_script_witness = Some(mixed_stack); } let mixed_transaction = mixed_witness_pset diff --git a/crates/deadcat-contracts/build.rs b/crates/deadcat-contracts/build.rs index d65c904..b6bb858 100644 --- a/crates/deadcat-contracts/build.rs +++ b/crates/deadcat-contracts/build.rs @@ -1,4 +1,4 @@ -use std::process::Command; +use std::{process::Command, str}; const EXPECTED_SMPLX_VERSION: &str = "0.0.9"; @@ -12,12 +12,24 @@ fn main() { .unwrap_or_else(|error| { panic!("failed to invoke `simplex` ({error}); build inside `nix develop .#default`") }); - assert!(version.status.success(), "`simplex --version` failed"); - - let version = String::from_utf8_lossy(&version.stdout); assert!( - version.contains(EXPECTED_SMPLX_VERSION), - "simplex CLI/library skew: expected {EXPECTED_SMPLX_VERSION}, got {version:?}" + version.status.success(), + "`simplex --version` failed: {}", + String::from_utf8_lossy(&version.stderr) + ); + + let version_output = str::from_utf8(&version.stdout) + .unwrap_or_else(|error| panic!("`simplex --version` returned invalid UTF-8: {error}")); + let mut version_lines = version_output.lines(); + let actual_version = match (version_lines.next(), version_lines.next()) { + (Some(line), None) => line + .strip_prefix("Simplex ") + .unwrap_or_else(|| panic!("unexpected `simplex --version` output: {version_output:?}")), + _ => panic!("unexpected `simplex --version` output: {version_output:?}"), + }; + assert_eq!( + actual_version, EXPECTED_SMPLX_VERSION, + "simplex CLI/library skew: expected {EXPECTED_SMPLX_VERSION}, got {actual_version}" ); let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); diff --git a/crates/deadcat-contracts/src/binary_market/compiled.rs b/crates/deadcat-contracts/src/binary_market/compiled.rs index 2bc88ed..f7f8dee 100644 --- a/crates/deadcat-contracts/src/binary_market/compiled.rs +++ b/crates/deadcat-contracts/src/binary_market/compiled.rs @@ -18,7 +18,7 @@ use simplex::simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo use simplex::simplicityhl::simplicity::{ BitMachine, HasCmr as _, RedeemNode, Value as SimplicityValue, leaf_version, }; -use simplex::simplicityhl::{CompiledProgram, UnstableFeatures, WitnessValues}; +use simplex::simplicityhl::{CompiledProgram, UnstableFeature, UnstableFeatures, WitnessValues}; use thiserror::Error; use super::{BinaryMarketEconomics, BinaryMarketParams, BinaryMarketSlot}; @@ -83,7 +83,7 @@ impl CompiledBinaryMarket { let arguments = contract_arguments(params)?; let compiled = CompiledProgram::new_with_unstable( BinaryMarketProgram::SOURCE, - &UnstableFeatures::all(), + &UnstableFeatures::new([UnstableFeature::Imports]), arguments.build_arguments(), false, Box::new(ElementsJetHinter), diff --git a/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt b/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt new file mode 100644 index 0000000..8390c6f --- /dev/null +++ b/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt @@ -0,0 +1,29 @@ +parameters: +BASE_PAYOUT: u64 +COLLATERAL_ASSET_ID: u256 +EXPIRY_HEIGHT: u32 +NO_REISSUANCE_TOKEN_ID: u256 +NO_RT_ASSET_A_PARITY: bool +NO_RT_ASSET_A_X: u256 +NO_RT_ASSET_B_PARITY: bool +NO_RT_ASSET_B_X: u256 +NO_RT_VALUE_PARITY: bool +NO_RT_VALUE_X: u256 +NO_TOKEN_ASSET_ID: u256 +ORACLE_PUBLIC_KEY: u256 +YES_REISSUANCE_TOKEN_ID: u256 +YES_RT_ASSET_A_PARITY: bool +YES_RT_ASSET_A_X: u256 +YES_RT_ASSET_B_PARITY: bool +YES_RT_ASSET_B_X: u256 +YES_RT_VALUE_PARITY: bool +YES_RT_VALUE_X: u256 +YES_TOKEN_ASSET_ID: u256 +witnesses: +ORACLE_OUTCOME_YES: bool +ORACLE_SIGNATURE: [u8; 64] +OUTPUT_BASE: u32 +PATH: u8 +REDEEM_YES: bool +SLOT: u8 +TOKENS_BURNED: u64 diff --git a/crates/deadcat-contracts/tests/golden_vectors.rs b/crates/deadcat-contracts/tests/golden_vectors.rs index 1f4f823..b99010e 100644 --- a/crates/deadcat-contracts/tests/golden_vectors.rs +++ b/crates/deadcat-contracts/tests/golden_vectors.rs @@ -1,9 +1,13 @@ use std::fmt::Write as _; -use deadcat_contracts::binary_market::{BinaryMarketSlot, CompiledBinaryMarket}; +use deadcat_contracts::binary_market::{ + BinaryMarketProgram, BinaryMarketSlot, CompiledBinaryMarket, +}; use deadcat_contracts::rt::{ABF_A, ABF_B, RtLeg, RtSide, YES_CBF, commitments, factors, no_cbf}; use deadcat_types::BinaryMarketParams; use elements::AssetId; +use simplex::simplicityhl::ast::ElementsJetHinter; +use simplex::simplicityhl::{TemplateProgram, UnstableFeature, UnstableFeatures}; const NUMS_PUBLIC_KEY: [u8; 32] = [ 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, @@ -27,6 +31,27 @@ fn sample_params() -> BinaryMarketParams { } } +fn sequential_asset(start: u8) -> AssetId { + let mut bytes = [0_u8; 32]; + for (offset, byte) in bytes.iter_mut().enumerate() { + *byte = start + u8::try_from(offset).expect("32-byte asset offset"); + } + AssetId::from_slice(&bytes).expect("asset ID") +} + +fn nonuniform_params() -> BinaryMarketParams { + BinaryMarketParams { + oracle_public_key: NUMS_PUBLIC_KEY, + collateral_asset_id: sequential_asset(0x00), + yes_token_asset_id: sequential_asset(0x20), + no_token_asset_id: sequential_asset(0x40), + yes_reissuance_token_id: sequential_asset(0x60), + no_reissuance_token_id: sequential_asset(0x80), + base_payout: 1_000, + expiry_height: 250_000, + } +} + fn hex(bytes: &[u8]) -> String { let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { @@ -43,6 +68,53 @@ fn compressed_commitments(leg: RtLeg, side: RtSide, asset_id: AssetId) -> (Strin ) } +#[test] +fn nonuniform_contract_arguments_compile_to_stable_cmr() { + // Every asset byte is position-sensitive. This golden therefore changes if + // `contract_arguments` accidentally substitutes display order for consensus + // byte order before compiling the parameterized program. + let compiled = CompiledBinaryMarket::new(nonuniform_params()).expect("compile market"); + assert_eq!( + hex(&compiled.cmr()), + "17cc73d71216f687eaed7cd5dc05743f35eb75d907fbd7c996e2012f22caf7bf" + ); +} + +#[test] +fn binary_market_abi_is_stable() { + let template = TemplateProgram::new_with_unstable( + BinaryMarketProgram::SOURCE, + &UnstableFeatures::new([UnstableFeature::Imports]), + Box::new(ElementsJetHinter), + ) + .expect("parse binary-market template"); + let abi = template.generate_abi_meta().expect("generate ABI metadata"); + + let mut parameters: Vec = abi + .param_types + .iter() + .map(|(name, ty)| format!("{name}: {ty}")) + .collect(); + parameters.sort_unstable(); + let mut witnesses: Vec = abi + .witness_types + .iter() + .map(|(name, ty)| format!("{name}: {ty}")) + .collect(); + witnesses.sort_unstable(); + + let actual = format!( + "parameters:\n{}\nwitnesses:\n{}\n", + parameters.join("\n"), + witnesses.join("\n") + ); + assert_eq!( + actual, + include_str!("fixtures/binary_market_abi.txt"), + "binary-market parameter/witness ABI changed" + ); +} + #[test] fn nonuniform_asset_ids_preserve_consensus_byte_order() { let yes_asset_id = AssetId::from_slice(&[ diff --git a/flake.nix b/flake.nix index 07ded64..24c0131 100644 --- a/flake.nix +++ b/flake.nix @@ -47,7 +47,12 @@ checks.simplex-version = pkgs.runCommand "deadcat-simplex-version" { nativeBuildInputs = [ smplx ]; } '' - simplex --version | grep -F "0.0.9" + actual="$(simplex --version)" + expected="Simplex ${smplx.version}" + if [ "$actual" != "$expected" ]; then + echo "expected '$expected', got '$actual'" >&2 + exit 1 + fi touch $out ''; @@ -55,7 +60,9 @@ packages = with pkgs; [ just git + jq pkg-config + python3 cargo-nextest ] ++ [ smplx diff --git a/scripts/check-smplx-pin.sh b/scripts/check-smplx-pin.sh index 7a4006a..0ef628e 100644 --- a/scripts/check-smplx-pin.sh +++ b/scripts/check-smplx-pin.sh @@ -5,18 +5,157 @@ expected="0.0.9" expected_simplicityhl="0.6.0" expected_simplicity_lang="0.8.0" expected_simplicity_sys="0.7.0" +expected_registry_source="registry+https://github.com/rust-lang/crates.io-index" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -simplex --version | grep -F "$expected" >/dev/null -grep -F "smplx-std = \"=$expected\"" "$root/Cargo.toml" >/dev/null -grep -F "smplx-sdk = \"=$expected\"" "$root/Cargo.toml" >/dev/null -grep -F "smplx-regtest = \"=$expected\"" "$root/Cargo.toml" >/dev/null -grep -F "version = \"$expected\";" "$root/flake.nix" >/dev/null -grep -A1 -F 'name = "simplicityhl"' "$root/Cargo.lock" \ - | grep -F "version = \"$expected_simplicityhl\"" >/dev/null -grep -A1 -F 'name = "simplicity-lang"' "$root/Cargo.lock" \ - | grep -F "version = \"$expected_simplicity_lang\"" >/dev/null -grep -A1 -F 'name = "simplicity-sys"' "$root/Cargo.lock" \ - | grep -F "version = \"$expected_simplicity_sys\"" >/dev/null +fail() { + echo "smplx pin check failed: $*" >&2 + exit 1 +} + +command -v jq >/dev/null || fail "jq is required; run inside nix develop .#default" +command -v python3 >/dev/null || fail "Python 3 is required; run inside nix develop .#default" +python3 -c 'import tomllib' 2>/dev/null \ + || fail "Python 3.11 or newer with tomllib is required; run inside nix develop .#default" + +expected_cli_output="Simplex $expected" +actual_cli_output="$(simplex --version)" \ + || fail "could not run simplex --version" +[[ "$actual_cli_output" == "$expected_cli_output" ]] \ + || fail "expected CLI output '$expected_cli_output', got '$actual_cli_output'" + +flake_version="$(nix eval --raw "$root#simplex.version")" \ + || fail "could not evaluate the flake's simplex version" +[[ "$flake_version" == "$expected" ]] \ + || fail "flake resolves simplex $flake_version, expected $expected" + +metadata="$(cargo metadata --format-version 1 --locked --manifest-path "$root/Cargo.toml")" \ + || fail "cargo metadata could not resolve the locked workspace graph" + +assert_workspace_requirement() { + local package_name="$1" + local expected_requirement="$2" + local requirements + + requirements="$( + jq -r --arg name "$package_name" ' + .workspace_members as $members + | [ + .packages[] + | select(.id as $id | $members | index($id)) + | .dependencies[] + | select(.name == $name) + | .req + ] + | unique + | sort + | join(", ") + ' <<<"$metadata" + )" + + [[ "$requirements" == "$expected_requirement" ]] \ + || fail "workspace requirement for $package_name is '${requirements:-}', expected '$expected_requirement'" +} + +assert_resolved_crates_io_package() { + local package_name="$1" + local expected_version="$2" + local identities + local expected_identity="$expected_version @ $expected_registry_source" + + identities="$( + jq -r --arg name "$package_name" ' + [ + .packages[] + | select(.name == $name) + | "\(.version) @ \(.source // "")" + ] + | sort + | join(", ") + ' <<<"$metadata" + )" + + [[ "$identities" == "$expected_identity" ]] \ + || fail "resolved $package_name identity '${identities:-}', expected only '$expected_identity'" +} + +assert_cli_lock_stack() { + local lockfile="$root/nix/smplx-Cargo.lock" + + if ! python3 - \ + "$lockfile" \ + "$expected_registry_source" \ + "simplicityhl=$expected_simplicityhl" \ + "simplicity-lang=$expected_simplicity_lang" \ + "simplicity-sys=$expected_simplicity_sys" <<'PYTHON' +import sys +import tomllib +from pathlib import Path + +lock_path = Path(sys.argv[1]) +expected_source = sys.argv[2] +expected_versions = dict(spec.split("=", 1) for spec in sys.argv[3:]) + +with lock_path.open("rb") as lock_file: + packages = tomllib.load(lock_file).get("package", []) + +errors = [] +for name, expected_version in expected_versions.items(): + matches = [package for package in packages if package.get("name") == name] + expected_identity = f"{expected_version} @ {expected_source}" + actual_identities = sorted( + f"{package.get('version', '')} @ {package.get('source', '')}" + for package in matches + ) + if actual_identities != [expected_identity]: + actual = ", ".join(actual_identities) if actual_identities else "" + errors.append(f"{name}: found '{actual}', expected only '{expected_identity}'") + +if errors: + print(f"{lock_path} has an unexpected Simplicity stack:", file=sys.stderr) + for error in errors: + print(f" {error}", file=sys.stderr) + raise SystemExit(1) +PYTHON + then + fail "CLI lockfile Simplicity stack does not match the workspace pin" + fi +} + +for package_name in smplx-std smplx-sdk smplx-regtest; do + assert_workspace_requirement "$package_name" "=$expected" +done + +required_smplx_packages=( + smplx-build + smplx-macros + smplx-regtest + smplx-sdk + smplx-std + smplx-test +) + +for package_name in "${required_smplx_packages[@]}"; do + assert_resolved_crates_io_package "$package_name" "$expected" +done + +resolved_smplx_packages="$( + jq -r ' + [.packages[] | select(.name | startswith("smplx-")) | .name] + | unique + | sort + | .[] + ' <<<"$metadata" +)" +[[ -n "$resolved_smplx_packages" ]] || fail "workspace graph contains no smplx-* packages" + +while IFS= read -r package_name; do + assert_resolved_crates_io_package "$package_name" "$expected" +done <<<"$resolved_smplx_packages" + +assert_resolved_crates_io_package simplicityhl "$expected_simplicityhl" +assert_resolved_crates_io_package simplicity-lang "$expected_simplicity_lang" +assert_resolved_crates_io_package simplicity-sys "$expected_simplicity_sys" +assert_cli_lock_stack echo "smplx $expected and compatible Simplicity $expected_simplicityhl/$expected_simplicity_lang/$expected_simplicity_sys are pinned"