From 80426992fe04458a4379c69ad43174cc80146e92 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 11 May 2026 12:59:43 +0200 Subject: [PATCH 1/2] feat: add create cluster sem checks --- scripts/create-cluster-compare.sh | 395 ++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100755 scripts/create-cluster-compare.sh diff --git a/scripts/create-cluster-compare.sh b/scripts/create-cluster-compare.sh new file mode 100755 index 00000000..ebd03c4f --- /dev/null +++ b/scripts/create-cluster-compare.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +# Compare `charon create cluster` and `pluto create cluster` across a small +# matrix of CLI argument combinations. +# +# The generated lock contains fresh validator keys, threshold shares, ENRs, and +# signatures, so Charon and Pluto cannot produce byte-identical lock files until +# both commands expose deterministic entropy inputs. By default this script +# compares the deterministic lock surface and also verifies that each command +# writes the exact same lock to every node directory. Use --exact to additionally +# require Charon and Pluto canonical JSON lock equality. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -z "${PLUTO_BIN:-}" ]]; then + PLUTO_BIN="${ROOT_DIR}/target/debug/pluto" +fi + +if [[ -z "${CHARON_BIN:-}" ]]; then + if [[ -x "${ROOT_DIR}/../charon/charon" ]]; then + CHARON_BIN="${ROOT_DIR}/../charon/charon" + else + CHARON_BIN="charon" + fi +fi + +: "${WORK_DIR:=/tmp/create-cluster-compare}" +: "${KEEP_WORK:=0}" + +FEE_RECIPIENT_ADDRESS="0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF" +WITHDRAWAL_ADDRESS="0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF" + +EXACT=0 +CASE_FILTER="" + +usage() { + cat <<'USAGE' +Usage: scripts/create-cluster-compare.sh [--case NAME] [--exact] [--keep-work] + +Environment: + PLUTO_BIN Path to pluto binary. Defaults to ./target/debug/pluto. + CHARON_BIN Path to charon binary. Defaults to ../charon/charon, then PATH. + WORK_DIR Scratch/output directory. Defaults to /tmp/create-cluster-compare. + KEEP_WORK Keep scratch directory when set to 1/true/yes/on. + +Modes: + default Compare deterministic lock semantics and per-node lock equality. + --exact Also require Charon and Pluto canonical JSON lock equality. + +Cases: + basic + threshold-default + two-partial-deposits + four-partial-deposits + target-gas-limit + compounding +USAGE +} + +while (($#)); do + case "$1" in + --case) + CASE_FILTER="${2:?--case requires a name}" + shift 2 + ;; + --exact) + EXACT=1 + shift + ;; + --keep-work) + KEEP_WORK=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +is_truthy() { + case "${1:-}" in + 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;; + *) return 1 ;; + esac +} + +log() { + printf '[create-cluster-compare] %s\n' "$*" +} + +fail() { + printf '[create-cluster-compare] ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_bin() { + local label="$1" + local bin="$2" + + if [[ -x "${bin}" ]] || command -v "${bin}" >/dev/null 2>&1; then + return 0 + fi + + fail "${label} binary not found or not executable: ${bin}" +} + +case_nodes() { + case "$1" in + basic) echo 4 ;; + threshold-default) echo 3 ;; + two-partial-deposits) echo 4 ;; + four-partial-deposits) echo 4 ;; + target-gas-limit) echo 4 ;; + compounding) echo 4 ;; + *) fail "unknown case: $1" ;; + esac +} + +case_args() { + case "$1" in + basic) + printf '%s\0' \ + --nodes=4 \ + --threshold=3 \ + --num-validators=1 \ + --network=goerli \ + "--fee-recipient-addresses=${FEE_RECIPIENT_ADDRESS}" \ + "--withdrawal-addresses=${WITHDRAWAL_ADDRESS}" \ + --insecure-keys + ;; + threshold-default) + printf '%s\0' \ + --nodes=3 \ + --num-validators=2 \ + --network=goerli \ + "--fee-recipient-addresses=${FEE_RECIPIENT_ADDRESS}" \ + "--withdrawal-addresses=${WITHDRAWAL_ADDRESS}" \ + --insecure-keys + ;; + two-partial-deposits) + printf '%s\0' \ + --nodes=4 \ + --threshold=3 \ + --num-validators=1 \ + --network=goerli \ + --deposit-amounts=31,1 \ + "--fee-recipient-addresses=${FEE_RECIPIENT_ADDRESS}" \ + "--withdrawal-addresses=${WITHDRAWAL_ADDRESS}" \ + --insecure-keys + ;; + four-partial-deposits) + printf '%s\0' \ + --nodes=4 \ + --threshold=3 \ + --num-validators=1 \ + --network=goerli \ + --deposit-amounts=8,8,8,8 \ + "--fee-recipient-addresses=${FEE_RECIPIENT_ADDRESS}" \ + "--withdrawal-addresses=${WITHDRAWAL_ADDRESS}" \ + --insecure-keys + ;; + target-gas-limit) + printf '%s\0' \ + --nodes=4 \ + --threshold=3 \ + --num-validators=1 \ + --network=goerli \ + --target-gas-limit=30000000 \ + "--fee-recipient-addresses=${FEE_RECIPIENT_ADDRESS}" \ + "--withdrawal-addresses=${WITHDRAWAL_ADDRESS}" \ + --insecure-keys + ;; + compounding) + printf '%s\0' \ + --nodes=4 \ + --threshold=3 \ + --num-validators=1 \ + --network=goerli \ + --compounding \ + "--fee-recipient-addresses=${FEE_RECIPIENT_ADDRESS}" \ + "--withdrawal-addresses=${WITHDRAWAL_ADDRESS}" \ + --insecure-keys + ;; + *) + fail "unknown case: $1" + ;; + esac +} + +run_case_for_bin() { + local label="$1" + local bin="$2" + local name="$3" + local out_dir="$4" + shift 4 + local args=("$@") + + mkdir -p "${out_dir}" + log "${name}: running ${label}" + if ! "${bin}" create cluster --cluster-dir="${out_dir}" "${args[@]}" >"${out_dir}/create-cluster.stdout" 2>"${out_dir}/create-cluster.stderr"; then + printf '%s\n' "---- ${label} stdout ----" >&2 + cat "${out_dir}/create-cluster.stdout" >&2 || true + printf '%s\n' "---- ${label} stderr ----" >&2 + cat "${out_dir}/create-cluster.stderr" >&2 || true + fail "${name}: ${label} create cluster failed" + fi +} + +lock_path() { + local dir="$1" + printf '%s/node0/cluster-lock.json' "${dir}" +} + +canonical_json() { + jq -S . "$1" +} + +canonical_summary() { + jq -S ' + def validators: (.distributed_validators // .validators // []); + def clean_address: + if type == "string" then ascii_downcase else . end; + { + cluster_definition: { + name: (.cluster_definition.name // ""), + version: .cluster_definition.version, + num_validators: .cluster_definition.num_validators, + threshold: .cluster_definition.threshold, + dkg_algorithm: .cluster_definition.dkg_algorithm, + fork_version: .cluster_definition.fork_version, + deposit_amounts: (.cluster_definition.deposit_amounts // [] | map(tostring)), + consensus_protocol: (.cluster_definition.consensus_protocol // ""), + target_gas_limit: (.cluster_definition.target_gas_limit // 0), + compounding: (.cluster_definition.compounding // false), + validators: ( + .cluster_definition.validators // [] + | map({ + fee_recipient_address: (.fee_recipient_address // "" | clean_address), + withdrawal_address: (.withdrawal_address // "" | clean_address) + }) + ) + }, + operator_count: (.cluster_definition.operators | length), + distributed_validator_count: (validators | length), + public_share_counts: [ + validators[] + | (.public_shares // .pubshares // []) + | length + ], + partial_deposit_amounts: [ + validators[] + | (.partial_deposit_data // .deposit_data // []) + | map(.amount | tostring) + ], + builder_registration_gas_limits: [ + validators[] + | .builder_registration.message.gas_limit + ], + builder_registration_timestamps: [ + validators[] + | .builder_registration.message.timestamp + ], + node_signature_count: (.node_signatures // [] | length), + has_signature_aggregate: ((.signature_aggregate // "") != ""), + lock_hash_hex_len: ((.lock_hash // "") | sub("^0x"; "") | length) + } + ' "$1" +} + +verify_node_locks_same() { + local label="$1" + local dir="$2" + local nodes="$3" + local first + first="$(lock_path "${dir}")" + + [[ -s "${first}" ]] || fail "${label}: missing lock file: ${first}" + + for ((i = 1; i < nodes; i++)); do + local other="${dir}/node${i}/cluster-lock.json" + [[ -s "${other}" ]] || fail "${label}: missing lock file: ${other}" + if ! cmp -s "${first}" "${other}"; then + fail "${label}: node${i}/cluster-lock.json differs from node0/cluster-lock.json" + fi + done +} + +compare_files() { + local left="$1" + local right="$2" + local diff_file="$3" + + if diff -u "${left}" "${right}" >"${diff_file}"; then + return 0 + fi + + return 1 +} + +run_case() { + local name="$1" + local nodes + local args + local charon_dir + local pluto_dir + local case_dir + + nodes="$(case_nodes "${name}")" + args=() + while IFS= read -r -d '' arg; do + args+=("${arg}") + done < <(case_args "${name}") + + case_dir="${WORK_DIR}/${name}" + charon_dir="${case_dir}/charon" + pluto_dir="${case_dir}/pluto" + + rm -rf "${case_dir}" + mkdir -p "${case_dir}" + + log "${name}: args: ${args[*]}" + run_case_for_bin "charon" "${CHARON_BIN}" "${name}" "${charon_dir}" "${args[@]}" + run_case_for_bin "pluto" "${PLUTO_BIN}" "${name}" "${pluto_dir}" "${args[@]}" + + verify_node_locks_same "${name}: charon" "${charon_dir}" "${nodes}" + verify_node_locks_same "${name}: pluto" "${pluto_dir}" "${nodes}" + + canonical_summary "$(lock_path "${charon_dir}")" >"${case_dir}/charon.summary.json" + canonical_summary "$(lock_path "${pluto_dir}")" >"${case_dir}/pluto.summary.json" + + if compare_files "${case_dir}/charon.summary.json" "${case_dir}/pluto.summary.json" "${case_dir}/summary.diff"; then + log "${name}: semantic lock summary matches" + else + printf '%s\n' "---- ${name} semantic diff ----" >&2 + cat "${case_dir}/summary.diff" >&2 + fail "${name}: Charon and Pluto semantic lock summaries differ" + fi + + if (( EXACT )); then + canonical_json "$(lock_path "${charon_dir}")" >"${case_dir}/charon.lock.canonical.json" + canonical_json "$(lock_path "${pluto_dir}")" >"${case_dir}/pluto.lock.canonical.json" + + if compare_files "${case_dir}/charon.lock.canonical.json" "${case_dir}/pluto.lock.canonical.json" "${case_dir}/exact.diff"; then + log "${name}: exact canonical lock JSON matches" + else + printf '%s\n' "---- ${name} exact diff ----" >&2 + cat "${case_dir}/exact.diff" >&2 + fail "${name}: exact canonical lock JSON differs" + fi + fi +} + +main() { + require_bin "jq" "jq" + require_bin "pluto" "${PLUTO_BIN}" + require_bin "charon" "${CHARON_BIN}" + + if ! is_truthy "${KEEP_WORK}"; then + rm -rf "${WORK_DIR}" + fi + mkdir -p "${WORK_DIR}" + + local cases=( + basic + threshold-default + two-partial-deposits + four-partial-deposits + target-gas-limit + compounding + ) + + local ran=0 + for case_name in "${cases[@]}"; do + if [[ -n "${CASE_FILTER}" && "${CASE_FILTER}" != "${case_name}" ]]; then + continue + fi + run_case "${case_name}" + ran=$((ran + 1)) + done + + if (( ran == 0 )); then + fail "no cases matched --case=${CASE_FILTER}" + fi + + log "passed ${ran} case(s); artifacts: ${WORK_DIR}" +} + +main "$@" From ce800cd976249248ad43cd46c030953b20e0e09d Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Fri, 15 May 2026 12:38:03 +0200 Subject: [PATCH 2/2] fix: require CHARON_BIN and drop --exact in compare script Removed the silent ../charon/charon and PATH fallbacks for CHARON_BIN so runs cannot pick up a stale or mismatched charon version, and removed the --exact mode since charon and pluto draw randomness from independent CSPRNGs and cannot produce byte-identical locks. Co-authored-by: varex83 --- scripts/create-cluster-compare.sh | 47 ++++++------------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/scripts/create-cluster-compare.sh b/scripts/create-cluster-compare.sh index ebd03c4f..7f4607a6 100755 --- a/scripts/create-cluster-compare.sh +++ b/scripts/create-cluster-compare.sh @@ -3,11 +3,10 @@ # matrix of CLI argument combinations. # # The generated lock contains fresh validator keys, threshold shares, ENRs, and -# signatures, so Charon and Pluto cannot produce byte-identical lock files until -# both commands expose deterministic entropy inputs. By default this script -# compares the deterministic lock surface and also verifies that each command -# writes the exact same lock to every node directory. Use --exact to additionally -# require Charon and Pluto canonical JSON lock equality. +# signatures derived from independent CSPRNGs in each binary, so Charon and Pluto +# cannot produce byte-identical lock files. This script compares the +# deterministic lock surface (cluster definition, counts, deposit amounts, etc.) +# and verifies that each command writes the same lock to every node directory. set -euo pipefail @@ -18,11 +17,9 @@ if [[ -z "${PLUTO_BIN:-}" ]]; then fi if [[ -z "${CHARON_BIN:-}" ]]; then - if [[ -x "${ROOT_DIR}/../charon/charon" ]]; then - CHARON_BIN="${ROOT_DIR}/../charon/charon" - else - CHARON_BIN="charon" - fi + printf '[create-cluster-compare] ERROR: %s\n' \ + "CHARON_BIN must be set to the path of a built charon binary" >&2 + exit 1 fi : "${WORK_DIR:=/tmp/create-cluster-compare}" @@ -31,23 +28,18 @@ fi FEE_RECIPIENT_ADDRESS="0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF" WITHDRAWAL_ADDRESS="0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF" -EXACT=0 CASE_FILTER="" usage() { cat <<'USAGE' -Usage: scripts/create-cluster-compare.sh [--case NAME] [--exact] [--keep-work] +Usage: scripts/create-cluster-compare.sh [--case NAME] [--keep-work] Environment: PLUTO_BIN Path to pluto binary. Defaults to ./target/debug/pluto. - CHARON_BIN Path to charon binary. Defaults to ../charon/charon, then PATH. + CHARON_BIN Path to charon binary. Required. WORK_DIR Scratch/output directory. Defaults to /tmp/create-cluster-compare. KEEP_WORK Keep scratch directory when set to 1/true/yes/on. -Modes: - default Compare deterministic lock semantics and per-node lock equality. - --exact Also require Charon and Pluto canonical JSON lock equality. - Cases: basic threshold-default @@ -64,10 +56,6 @@ while (($#)); do CASE_FILTER="${2:?--case requires a name}" shift 2 ;; - --exact) - EXACT=1 - shift - ;; --keep-work) KEEP_WORK=1 shift @@ -218,10 +206,6 @@ lock_path() { printf '%s/node0/cluster-lock.json' "${dir}" } -canonical_json() { - jq -S . "$1" -} - canonical_summary() { jq -S ' def validators: (.distributed_validators // .validators // []); @@ -342,19 +326,6 @@ run_case() { cat "${case_dir}/summary.diff" >&2 fail "${name}: Charon and Pluto semantic lock summaries differ" fi - - if (( EXACT )); then - canonical_json "$(lock_path "${charon_dir}")" >"${case_dir}/charon.lock.canonical.json" - canonical_json "$(lock_path "${pluto_dir}")" >"${case_dir}/pluto.lock.canonical.json" - - if compare_files "${case_dir}/charon.lock.canonical.json" "${case_dir}/pluto.lock.canonical.json" "${case_dir}/exact.diff"; then - log "${name}: exact canonical lock JSON matches" - else - printf '%s\n' "---- ${name} exact diff ----" >&2 - cat "${case_dir}/exact.diff" >&2 - fail "${name}: exact canonical lock JSON differs" - fi - fi } main() {