diff --git a/deployments/README.md b/deployments/README.md new file mode 100644 index 0000000..ecfea74 --- /dev/null +++ b/deployments/README.md @@ -0,0 +1,52 @@ +# Committed deployments + +Each `deployments//` is a **testnet** deployment profile: an RLN tree +already provisioned on a hosted sequencer, committed so a run spends against a +known tree instead of re-provisioning one. + +``` +deployments// + deployment.json descriptor: name, tree_id, sequencer, registration_program_id, + merkle_program_id, config_account, payment_account, + supply_holding, funding (faucet|wallet-key) + storage.json the wallet holding payment_account (and, on wallet-key + deployments, supply_holding) +``` + +`--target testnet` requires `E2E_DEPLOYMENT=` — there is no default: a +testnet run always names the tree it spends against. The target reads +`.sequencer` from the descriptor, asserts it answers `getLastBlockId`, and +stages the pair through logos-lez-rln's `tools/deployments/stage.sh` into the +run's wallet home; every other contract value (tree id, config account, +funding) comes out of that staged fixture. + +The descriptor and its wallet are one unit: `stage.sh` fails when the wallet +does not hold the descriptor's accounts. Copy both files or neither. + +`--target local` never reads this directory — it provisions a fresh tree per +run into `$E2E_RUN_DIR/deployments/local-e2e`. + +## Adding one + +Provision against the hosted sequencer from a logos-lez-rln checkout, then +commit the pair: + +```sh +cd +bash tools/deployments/provision.sh --name \ + --sequencer https://testnet.lez.logos.co/ --funding faucet \ + --outdir /deployments +``` + +`funding=faucet` deployments are the paid `Register` path (anyone claims +tokens up to the deployment's cap). `wallet-key` deployments carry a +pre-minted supply in the committed wallet — a scenario that needs the faucet +must assert `E2E_FUNDING=faucet`. + +Guest drift invalidates a descriptor: rebuilt guest binaries change the +program id, so the same `tree_id` derives a different `config_account`. Run +`bash /tools/deployments/verify.sh deployments/` after a +guest bump; a failure means re-provision, not a chain bug. + +The committed wallet is a test wallet on a test chain — treat any key in here +as public. diff --git a/docs/contract.md b/docs/contract.md index 95aee9a..4f8c785 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -37,6 +37,19 @@ channel. | `E2E_EPOCH_SIZE_SEC` | RLN epoch size passed to `start` (local 60, testnet 600) | | `E2E_ROOT_WINDOW_TIMEOUT_S` | `verify_proof` root-window retry budget (local 60, testnet 120) | +## Target inputs (caller → target) + +| var | meaning | +|---|---| +| `E2E_DEVNET` | local only: `host` (default — run lez-rln's `dev.sh`) \| `external` (attach to a running sequencer) | +| `E2E_DEVNET_TIMEOUT_S` | local/host: devnet readiness budget (default 900 — first boot cargo-builds the sequencer) | +| `LEZ_RLN_CHECKOUT` | lez-rln working tree for dev.sh + provisioning (default `../logos-lez-rln`; must have host bins + guest blobs built) | +| `E2E_DEPLOYMENT` | testnet only, required: name of a committed descriptor under `deployments/` | +| `E2E_DEPLOYMENT_DIR` | local/external only: reuse an existing provisioned deployment (refused under `E2E_DEVNET=host` — dev.sh wipes the chain) | + +Scenario-specific knobs (e.g. `register`'s `E2E_RATE_LIMIT`) are documented in +the scenario's header, never invented in the harness. + ## scenario.env Each `scenarios//scenario.env` declares: `NODES` (daemon count), diff --git a/flake.nix b/flake.nix index 17ed9aa..5f80281 100644 --- a/flake.nix +++ b/flake.nix @@ -53,6 +53,7 @@ # . "$(nix build .#pins --no-link --print-out-paths)" pins = pkgs.writeText "e2e-pins.env" '' E2E_LEZ_RLN_SRC=${lez-rln} + E2E_RLN_MODULES_SRC=${rln-modules} ''; } # The module bundles every scenario loads, re-exported from the @@ -79,6 +80,7 @@ rsync gnutar shellcheck + coreutils # timeout(1) for the harness call caps; absent on stock macOS ]; }; } diff --git a/harness/artifacts.sh b/harness/artifacts.sh index dc4ef0c..ea18ee2 100644 --- a/harness/artifacts.sh +++ b/harness/artifacts.sh @@ -1,17 +1,102 @@ # shellcheck shell=bash # harness/artifacts.sh — resolve every binary/bundle a scenario loads. # -# Resolution order per artifact (W1-A implements): +# Resolution order per artifact: # 1. explicit env override: LOGOSCORE, WALLET_LGX, LEZ_RLN_LGX, RLN_LGX -# 2. nix build from the flake pins: .#logoscore, .#wallet-lgx, +# 2. RLN_MODULES_CHECKOUT=: build the three bundles from that working +# tree (filtered copy + --override-input rln-modules) — the dev loop for +# changing a module and running a scenario against it. +# 3. nix build from the flake pins: .#logoscore, .#wallet-lgx, # .#lez-rln-module-lgx, .#rln-module-lgx. Verified 2026-08-10: the module # bundles build from a clean rln-modules fetch — the module-builder # stages the sdk and regenerates the scaffold in-derivation. (The # checkout-side staging scripts are only for bare-cargo dev loops.) # -# Also W1-A: the pin-consistency check — the rln-modules tree pins rln-layouts -# to a logos-lez-rln rev in logos-lez-rln-module/rust-lib/Cargo.toml; if that -# rev differs from the lez-rln source in use, chain-state decode skew shows up -# as phantom chain bugs. Assert equality, fail loudly with both revs. +# The pin-consistency check: the rln-modules tree pins rln-layouts to a +# logos-lez-rln rev in logos-lez-rln-module/rust-lib/Cargo.toml; if that rev +# differs from the lez-rln source in use, chain-state decode skew shows up as +# phantom chain bugs. Assert equality, fail loudly with both revs. +# +# Exports beyond docs/contract.md: +# E2E_LEZ_RLN_SRC pinned logos-lez-rln source (read-only /nix/store) +# E2E_RLN_MODULES_SRC pinned logos-rln-modules source +# LEZ_RLN_CHECKOUT passthrough override for the lez-rln SOURCE +# E2E_LEZ_RLN the checkout when it exists, else the pinned store +# path. tools/deployments/stage.sh runs fine from the +# store path; provisioning needs a real checkout — +# which of the two a target needs is the target's call. -resolve_artifacts() { die "artifacts.sh: not implemented yet (W1-A)"; } \ No newline at end of file +E2E_ROOT="${E2E_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +. "$(dirname "${BASH_SOURCE[0]}")/lib/lgx.sh" + +_nix_out() { + local out + out=$(nix build "$E2E_ROOT#$1" --no-link --print-out-paths | tail -1) + [ -n "$out" ] || die "nix build .#$1 produced no output path" + printf '%s' "$out" +} + +_bundle_lgx() { + if [ -n "${RLN_MODULES_CHECKOUT:-}" ]; then + module_lgx "$1" "$RLN_MODULES_CHECKOUT" + else + lgx_of "$(_nix_out "$1")" + fi +} + +# rln-layouts is the on-chain wire: the module stack decodes chain state with +# it, so its pinned lez-rln rev must be the rev whose programs are deployed. +_check_pins() { + local pins layouts_rev lock_rev + pins=$(_nix_out pins) + # shellcheck source=/dev/null + . "$pins" + export E2E_LEZ_RLN_SRC E2E_RLN_MODULES_SRC + layouts_rev=$(grep -E '^rln-layouts' \ + "$E2E_RLN_MODULES_SRC/logos-lez-rln-module/rust-lib/Cargo.toml" \ + | grep -oE '[0-9a-f]{40}' | head -1) + lock_rev=$(jq -r '.nodes["lez-rln"].locked.rev' "$E2E_ROOT/flake.lock") + [ -n "$layouts_rev" ] || die "cannot read the rln-layouts rev from the rln-modules pin" + [ -n "$lock_rev" ] || die "cannot read the lez-rln rev from flake.lock" + [ "$layouts_rev" = "$lock_rev" ] || die "pin skew: rln-modules pins rln-layouts to lez-rln $layouts_rev, flake.lock pins lez-rln $lock_rev — the module stack would decode chain state with the wrong layouts. Bump one of the two." + say "pins consistent: lez-rln ${lock_rev:0:12} (rln-layouts + flake.lock)" +} + +resolve_artifacts() { + section "artifacts" + [ -n "${E2E_RUN_DIR:-}" ] || die "resolve_artifacts: E2E_RUN_DIR unset (run.sh sets it)" + local tool out + for tool in nix jq python3 tar curl rsync; do + command -v "$tool" >/dev/null || die "missing tool: $tool" + done + + _check_pins + if [ -n "${LEZ_RLN_CHECKOUT:-}" ] && [ -d "$LEZ_RLN_CHECKOUT" ]; then + E2E_LEZ_RLN="$LEZ_RLN_CHECKOUT" + else + E2E_LEZ_RLN="$E2E_LEZ_RLN_SRC" + fi + export E2E_LEZ_RLN LEZ_RLN_CHECKOUT="${LEZ_RLN_CHECKOUT:-}" + say "lez-rln source: $E2E_LEZ_RLN" + + if [ -z "${LOGOSCORE:-}" ]; then + out=$(_nix_out logoscore) + LOGOSCORE="$out/bin/logoscore" + fi + [ -x "$LOGOSCORE" ] || die "logoscore not executable: $LOGOSCORE" + export LOGOSCORE + + [ -n "${WALLET_LGX:-}" ] || WALLET_LGX=$(_bundle_lgx wallet-lgx) + [ -n "${LEZ_RLN_LGX:-}" ] || LEZ_RLN_LGX=$(_bundle_lgx lez-rln-module-lgx) + [ -n "${RLN_LGX:-}" ] || RLN_LGX=$(_bundle_lgx rln-module-lgx) + export WALLET_LGX LEZ_RLN_LGX RLN_LGX + say "bundles: $(basename "$WALLET_LGX"), $(basename "$LEZ_RLN_LGX"), $(basename "$RLN_LGX")" + + E2E_MODULES_DIR="$E2E_RUN_DIR/modules" + export E2E_MODULES_DIR + mkdir -p "$E2E_MODULES_DIR" + install_lgx "$WALLET_LGX" + install_lgx "$LEZ_RLN_LGX" + install_lgx "$RLN_LGX" + say "modules dir: $E2E_MODULES_DIR ($(lgx_platform))" +} diff --git a/harness/lib/chain.sh b/harness/lib/chain.sh index 3308f68..a610062 100644 --- a/harness/lib/chain.sh +++ b/harness/lib/chain.sh @@ -1,9 +1,97 @@ # shellcheck shell=bash -# harness/lib/chain.sh — chain-level helpers shared by scenarios. -# W1-A ports chain_head, confirm_and_ready (the leaf-confirmation barrier, -# including the optimistic-vs-actual-leaf race fix) and diagnose_reg (log-scan -# auto-diagnosis with copy-pasteable fixes) from the mix orchestrator. +# harness/lib/chain.sh — chain-level helpers shared by scenarios, over the +# node_call/node_logs seam (host daemon now, container later). +# +# Env beyond docs/contract.md: +# E2E_REGISTRY_MOD registry-provider module id (default +# liblogos_lez_rln_module) +# E2E_ACTUAL_LEAF set by confirm_and_ready to the on-chain leaf index -chain_head() { die "chain.sh: not implemented yet (W1-A)"; } -confirm_and_ready() { die "chain.sh: not implemented yet (W1-A)"; } -diagnose_reg() { die "chain.sh: not implemented yet (W1-A)"; } \ No newline at end of file +. "$(dirname "${BASH_SOURCE[0]}")/daemon.sh" + +E2E_REGISTRY_MOD="${E2E_REGISTRY_MOD:-liblogos_lez_rln_module}" + +# Usage: chain_head [endpoint] (default $E2E_SEQUENCER) +chain_head() { + local ep="${1:-${E2E_SEQUENCER:-}}" head + [ -n "$ep" ] || return 1 + head=$(curl -sS -m 15 -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"getLastBlockId","params":[],"id":1}' "$ep" \ + | python3 -c 'import json,sys +try: + print(json.load(sys.stdin)["result"]) +except Exception: + pass') + case "$head" in ''|*[!0-9]*) return 1 ;; esac + printf '%s' "$head" +} + +# On-chain confirmation barrier for a membership: wait until the registry +# provider reports registered:true for our commitment on the CANONICAL tree +# BEFORE the caller proceeds, so a following registration lands on a DISTINCT +# leaf (a merkle proof alone was unreliable: it is served for the OPTIMISTIC +# leaf before the tree advances, so leaves collided). Reads the ACTUAL leaf +# into E2E_ACTUAL_LEAF and flags any mismatch with the optimistic one. +# Usage: confirm_and_ready [optimistic_leaf] [label] +confirm_and_ready() { + local node="$1" idc="$2" lopt="${3:-}" + local label="${4:-$node}" + local iv="${E2E_POLL_INTERVAL_S:-10}" res conf="" flag="" _w tries + [ -n "${E2E_CONFIG_ACCOUNT:-}" ] || die "confirm_and_ready: E2E_CONFIG_ACCOUNT unset (the target sets it)" + tries=$(( ${E2E_CONFIRM_TIMEOUT_S:-600} / iv )) + [ "$tries" -lt 1 ] && tries=1 + E2E_ACTUAL_LEAF="" + for _w in $(seq 1 "$tries"); do + res=$(node_call "$node" "$E2E_REGISTRY_MOD" get_membership \ + "$(argfile confirm_cfg "$E2E_CONFIG_ACCOUNT")" "$(argfile confirm_idc "$idc")" | jres) + case "$res" in + *'"registered":true'*) + conf=true + E2E_ACTUAL_LEAF=$(printf '%s' "$res" | jfield leaf_index) + break ;; + esac + sleep "$iv" + done + if [ "$conf" != "true" ]; then + diagnose_reg "$node" + return 1 + fi + [ -n "$lopt" ] && [ "$lopt" != "$E2E_ACTUAL_LEAF" ] \ + && flag=" !! LEAF MISMATCH (proof for $lopt, actual $E2E_ACTUAL_LEAF)" + say "$label confirmed leaf_opt=${lopt:-n/a} leaf_actual=$E2E_ACTUAL_LEAF$flag" + return 0 +} + +# Diagnose a registration that never confirmed by scanning the node's log for +# the rln program's assert strings, then print the remediation. +# Usage: diagnose_reg +diagnose_reg() { + local node="$1" logs + logs=$(node_logs "$node" "${E2E_DIAG_LINES:-2000}" 2>/dev/null) + printf '%s\n' " !! RLN registration on node '$node' did not confirm on-chain." >&2 + if printf '%s' "$logs" | grep -qiE "Insufficient balance|may be out of funds|range end index 49"; then + cat >&2 < ./run.sh ${E2E_SCENARIO:-} --target ${E2E_TARGET:-local} +EOF + elif printf '%s' "$logs" | grep -qiE "Would exceed max total rate limit|max_total_rate_limit"; then + cat >&2 <} --target local + On testnet, provision a new deployment from the lez-rln checkout + (tools/deployments/provision.sh, tree_id is the single knob) and commit its + descriptor under deployments/. +EOF + else + cat >&2 </{config,daemon.log}, so NODES>1 is a matter of +# calling daemon_start twice. +# +# Sourcing this file REPLACES compat.sh's die with one that first prints the +# tail of $E2E_DIE_NODE's daemon log (set by daemon_start to the first node); +# die_node picks a different node for one call. +# +# Env beyond docs/contract.md: +# E2E_DAEMON_ENV extra KEY=VALUE pairs (space-separated) for every daemon +# E2E_DIE_NODE node whose log tail die prints +# E2E_NODES node ids started so far (daemon_stop_all's list) -daemon_start() { die "daemon.sh: not implemented yet (W1-A)"; } -daemon_load_modules() { die "daemon.sh: not implemented yet (W1-A)"; } -daemon_stop() { die "daemon.sh: not implemented yet (W1-A)"; } -node_call() { die "daemon.sh: not implemented yet (W1-A)"; } # [args…] -node_logs() { die "daemon.sh: not implemented yet (W1-A)"; } \ No newline at end of file +. "$(dirname "${BASH_SOURCE[0]}")/json.sh" + +E2E_NODES="${E2E_NODES:-}" + +node_cfg_dir() { gv NODECFG "$1"; } +node_log_path() { gv NODELOG "$1"; } + +die() { + printf '%s\n' "e2e: FAIL: $*" >&2 + local log + log=$(node_log_path "${E2E_DIE_NODE:-}") + if [ -n "$log" ] && [ -f "$log" ]; then + printf '%s\n' "---- daemon log tail (${E2E_DIE_NODE}) ----" >&2 + tail -40 "$log" >&2 || true + fi + exit 1 +} + +die_node() { local node="$1"; shift; E2E_DIE_NODE="$node"; die "$@"; } + +# Usage: daemon_start +daemon_start() { + local node="${1:?daemon_start }" dir cfg log pid _t + [ -n "${LOGOSCORE:-}" ] || die "daemon_start: LOGOSCORE unset (resolve_artifacts first)" + [ -n "${E2E_MODULES_DIR:-}" ] || die "daemon_start: E2E_MODULES_DIR unset (resolve_artifacts first)" + dir="${E2E_RUN_DIR:?daemon_start: E2E_RUN_DIR unset}/nodes/$node" + cfg="$dir/config" + log="$dir/daemon.log" + mkdir -p "$cfg" + sv NODECFG "$node" "$cfg" + sv NODELOG "$node" "$log" + E2E_NODES="$E2E_NODES $node" + [ -n "${E2E_DIE_NODE:-}" ] || E2E_DIE_NODE="$node" + + # env -i: Qt strips DYLD_* otherwise, and daemon+client must agree on the + # effective TMPDIR (QLocalSocket path). LEZ_RLN_TREE_ID_HEX must survive + # into the daemon: rln_core derives PDAs from it. + local -a envv + envv=(HOME="$HOME" PATH="$PATH" LOGOSCORE_CONFIG_DIR="$cfg" + RUST_BACKTRACE=full QT_QPA_PLATFORM=offscreen) + if [ -n "${E2E_WALLET_HOME:-}" ]; then + envv=("${envv[@]}" "NSSA_WALLET_HOME_DIR=$E2E_WALLET_HOME" "LEE_WALLET_HOME_DIR=$E2E_WALLET_HOME") + fi + if [ -n "${E2E_TREE_ID:-}" ]; then + envv=("${envv[@]}" "LEZ_RLN_TREE_ID_HEX=$E2E_TREE_ID") + fi + local kv + for kv in ${E2E_DAEMON_ENV:-}; do envv=("${envv[@]}" "$kv"); done + + say "$node: starting logoscore daemon" + # exec: $! must be the daemon itself so daemon_stop can kill just this node. + (cd "$dir" && exec env -i "${envv[@]}" "$LOGOSCORE" -m "$E2E_MODULES_DIR" -D >"$log" 2>&1) & + pid=$! + sv NODEPID "$node" "$pid" + disown "$pid" 2>/dev/null || true + + for _t in $(seq 1 60); do + [ -f "$cfg/client/config.json" ] && break + sleep 1 + done + [ -f "$cfg/client/config.json" ] || die_node "$node" "daemon produced no client config" + for _t in $(seq 1 60); do + _with_timeout 5 env -u TMPDIR LOGOSCORE_CONFIG_DIR="$cfg" "$LOGOSCORE" --quiet --json list-modules 2>/dev/null \ + | grep -q '"capability_module".*"loaded"' && break + sleep 1 + done + sleep 5 +} + +# Usage: daemon_load_modules … +daemon_load_modules() { + local node="${1:?daemon_load_modules …}"; shift + [ $# -gt 0 ] || die "daemon_load_modules: no modules given" + local cfg log mod + cfg=$(node_cfg_dir "$node") + log=$(node_log_path "$node") + [ -n "$cfg" ] || die "daemon_load_modules: unknown node '$node'" + for mod in "$@"; do + say "$node: load-module $mod" + _with_timeout 30 env -u TMPDIR LOGOSCORE_CONFIG_DIR="$cfg" "$LOGOSCORE" --json load-module "$mod" \ + >>"$log" 2>&1 || die_node "$node" "load-module $mod failed" + done +} + +# Usage: node_call [args…] +node_call() { + local node="$1"; shift + local cfg + cfg=$(node_cfg_dir "$node") + [ -n "$cfg" ] || die "node_call: unknown node '$node'" + export E2E_CFG_DIR="$cfg" + call_json "$@" +} + +# Usage: node_logs [lines] (whole log when lines is omitted) +node_logs() { + local node="$1" lines="${2:-}" log + log=$(node_log_path "$node") + [ -n "$log" ] && [ -f "$log" ] || return 1 + if [ -n "$lines" ]; then tail -n "$lines" "$log"; else cat "$log"; fi +} + +daemon_stop() { + local node="$1" pid + pid=$(gv NODEPID "$node") + [ -n "$pid" ] || return 0 + kill "$pid" 2>/dev/null || true + sv NODEPID "$node" "" +} + +daemon_stop_all() { + local node + if [ "${E2E_KEEP:-0}" = "1" ]; then + say "E2E_KEEP=1: leaving daemons up, state in ${E2E_RUN_DIR:-}" + return + fi + for node in $E2E_NODES; do daemon_stop "$node"; done + # The run's modules dir is unique per run, so this cannot reach another + # run's daemons. + [ -n "${E2E_MODULES_DIR:-}" ] && pkill -f "logoscore -m $E2E_MODULES_DIR" 2>/dev/null + return 0 +} diff --git a/harness/lib/json.sh b/harness/lib/json.sh index fc8b367..5112606 100644 --- a/harness/lib/json.sh +++ b/harness/lib/json.sh @@ -1,11 +1,115 @@ # shellcheck shell=bash # harness/lib/json.sh — JSON plumbing for logoscore --json calls. -# W1-A ports these from logos-rln-modules/logos-rln-module/tests/ -# e2e_register_testnet.sh (argfile / call_json / jres / jfield / jval / to_hex). +# +# call_json drives the daemon whose LOGOSCORE_CONFIG_DIR is $E2E_CFG_DIR; +# daemon.sh's node_call sets that per node, so a scenario that talks to one +# node can call call_json directly. +# +# Env beyond docs/contract.md: +# LOGOSCORE the CLI (harness/artifacts.sh exports it) +# E2E_CFG_DIR config dir of the daemon a bare call_json talks to +# E2E_ARGS_DIR where argfile materialises @file args (default +# $E2E_RUN_DIR/args) +# CALL_TIMEOUT per-call timeout in seconds (default 180) -argfile() { die "json.sh: not implemented yet (W1-A)"; } -call_json() { die "json.sh: not implemented yet (W1-A)"; } -jres() { die "json.sh: not implemented yet (W1-A)"; } -jfield() { die "json.sh: not implemented yet (W1-A)"; } -jval() { die "json.sh: not implemented yet (W1-A)"; } -to_hex() { die "json.sh: not implemented yet (W1-A)"; } \ No newline at end of file +# Digit-leading strings (base58 accounts, hex) must go via @file or the CLI +# coerces them to numbers. +argfile() { + local dir="${E2E_ARGS_DIR:-${E2E_RUN_DIR:-.}/args}" + mkdir -p "$dir" || die "argfile: cannot create $dir" + printf '%s' "$2" > "$dir/$1.arg" + printf '@%s' "$dir/$1.arg" +} + +# timeout(1) is coreutils; stock macOS has neither it nor gtimeout. A missing +# timeout must not fail every call — run uncapped instead. +_with_timeout() { + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" + else "$@" + fi +} + +call_json() { + local mod="$1" meth="$2"; shift 2 + [ -n "${LOGOSCORE:-}" ] || die "call_json: LOGOSCORE unset (resolve_artifacts first)" + [ -n "${E2E_CFG_DIR:-}" ] || die "call_json: no daemon selected (use node_call)" + # env -u TMPDIR: daemon and client must agree on the effective TMPDIR + # (QLocalSocket path); the daemon runs under env -i, i.e. without one. + _with_timeout "${CALL_TIMEOUT:-180}" env -u TMPDIR LOGOSCORE_CONFIG_DIR="$E2E_CFG_DIR" \ + "$LOGOSCORE" --json call "$mod" "$meth" "$@" 2>/dev/null +} + +jres() { + python3 -c ' +import json, sys +for line in sys.stdin: + line = line.strip() + if not line.startswith("{"): + continue + try: + d = json.loads(line) + except Exception: + continue + if d.get("status") == "ok" and "result" in d: + r = d["result"] + # Compact separators: callers case-glob against the modules own + # compact JSON, so re-serialized values must not add spaces. + print(r if isinstance(r, str) else json.dumps(r, separators=(",", ":"))) + break +' +} + +# The CLI envelope status ("ok"/"error"), empty when the call emitted no JSON. +jstatus() { + python3 -c ' +import json, sys +for line in sys.stdin: + line = line.strip() + if not line.startswith("{"): + continue + try: + d = json.loads(line) + except Exception: + continue + if "status" in d: + print(d["status"]) + break +' +} + +jfield() { python3 -c " +import json, sys +try: + print(json.load(sys.stdin).get('$1', '')) +except Exception: + print('') +"; } + +to_hex() { python3 -c 'import sys; print(sys.stdin.buffer.read().hex())'; } + +# Unwrap a LogosResult envelope {success,value,error} (the -> result methods: +# start/stop/generate_proof/verify_proof/get_epoch_quota/ +# get_registry_parameters) to its value on success or its error string on +# failure; passes anything else through unchanged (tolerates a double-encoded +# envelope, like the lp clients do). +jval() { python3 -c ' +import json, sys +raw = sys.stdin.read().strip() +try: + d = json.loads(raw) +except Exception: + print(raw); sys.exit() +if isinstance(d, str): + try: + d = json.loads(d) + except Exception: + print(d); sys.exit() +if isinstance(d, dict) and "success" in d and ("value" in d or "error" in d): + out = d.get("value") if d.get("success") else d.get("error") + # Compact separators — see jres. + print(out if isinstance(out, str) else json.dumps(out, separators=(",", ":"))) +else: + print(raw) +'; } diff --git a/harness/lib/lgx.sh b/harness/lib/lgx.sh index 2558123..678e743 100644 --- a/harness/lib/lgx.sh +++ b/harness/lib/lgx.sh @@ -1,8 +1,77 @@ # shellcheck shell=bash # harness/lib/lgx.sh — build/unpack/install .lgx module bundles. -# W1-A ports module_lgx + install_lgx from the modules-repo e2e script and the -# flatten loop from harness/container/entrypoint.sh — one implementation for -# host runs and image builds. +# +# One implementation for host runs and image builds: install_lgx flattens a +# bundle into //{manifest.json,,variant}, picking +# this host's variant and falling back to the bundle's only variant (the +# single-variant case the container entrypoint has). +# +# Env beyond docs/contract.md: +# E2E_PLATFORM variant name override (default: derived from uname) +# E2E_ROOT repo root holding flake.nix (default: two dirs up) -module_lgx() { die "lgx.sh: not implemented yet (W1-A)"; } -install_lgx() { die "lgx.sh: not implemented yet (W1-A)"; } \ No newline at end of file +E2E_ROOT="${E2E_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" + +lgx_platform() { + if [ -n "${E2E_PLATFORM:-}" ]; then printf '%s' "$E2E_PLATFORM"; return; fi + case "$(uname -s)-$(uname -m)" in + Darwin-arm64) printf 'darwin-arm64-dev' ;; + Linux-x86_64) printf 'linux-x86_64-dev' ;; + Linux-aarch64) printf 'linux-aarch64-dev' ;; + *) die "unsupported platform $(uname -s)-$(uname -m)" ;; + esac +} + +lgx_of() { + local out; out=$(find "$1/" -maxdepth 1 -name '*.lgx' | head -1) + [ -f "$out" ] || die "no .lgx under $1" + printf '%s' "$out" +} + +# Build a module bundle from a WORKING TREE instead of the flake pin: copy the +# tree (filtered) and override the flake input with it. The copy carries the +# tree verbatim — uncommitted changes and any gitignored staged sources — +# minus target/result/.git: a raw `path:` override copies rust-lib/target +# (~1 GB of local cargo artifacts) into /nix/store on every eval and fills the +# disk. Nix content-addresses the copy, so unchanged trees rebuild for free. +# Usage: module_lgx [input-name] +module_lgx() { + local attr="$1" tree="$2" input="${3:-rln-modules}" + [ -d "$tree" ] || die "module_lgx: no source tree at $tree" + local src="${E2E_RUN_DIR:-.}/src-$input" + say "$attr: building from a filtered copy of $tree" >&2 + rsync -a --delete --exclude '*/rust-lib/target' --exclude 'result' \ + --exclude 'result-*' --exclude '.git' "$tree/" "$src/" \ + || die "rsync $tree failed" + local out + out=$(cd "$E2E_ROOT" && nix build --no-link --print-out-paths ".#$attr" \ + --override-input "$input" "path:$src") || die "nix build .#$attr failed" + lgx_of "$out" +} + +# Usage: install_lgx [dest-modules-dir] +install_lgx() { + local lgx="$1" dest="${2:-${E2E_MODULES_DIR:-}}" name tmp plat variant nvar + [ -f "$lgx" ] || die "install_lgx: no bundle at $lgx" + [ -n "$dest" ] || die "install_lgx: no destination (set E2E_MODULES_DIR)" + name=$(tar xzOf "$lgx" manifest.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["name"])') + [ -n "$name" ] || die "install_lgx: cannot read name from $lgx" + tmp=$(mktemp -d) + tar xzf "$lgx" -C "$tmp" || die "install_lgx: cannot unpack $lgx" + plat=$(lgx_platform) + if [ -d "$tmp/variants/$plat" ]; then + variant="$plat" + else + # Single-variant bundles (image builds) carry whatever the builder + # produced; anything else is a genuine platform mismatch. + nvar=$(find "$tmp/variants" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') + [ "$nvar" = "1" ] || die "install_lgx: $lgx has no variants/$plat" + variant=$(basename "$(find "$tmp/variants" -mindepth 1 -maxdepth 1 -type d | head -1)") + fi + rm -rf "${dest:?}/$name" + mkdir -p "$dest/$name" + cp "$tmp/manifest.json" "$dest/$name/" + cp -L "$tmp/variants/$variant/"* "$dest/$name/" + printf '%s' "$variant" > "$dest/$name/variant" + rm -rf "$tmp" +} diff --git a/harness/lib/wallet.sh b/harness/lib/wallet.sh index ad420d5..6d493a7 100644 --- a/harness/lib/wallet.sh +++ b/harness/lib/wallet.sh @@ -1,10 +1,90 @@ # shellcheck shell=bash -# harness/lib/wallet.sh — wallet open/sync/funding helpers. -# W1-A ports open + sync_to_block + fresh-unused-holding from the modules-repo -# e2e script, and sync_wallet (chunked) + wait_balance from the mix -# orchestrator. +# harness/lib/wallet.sh — wallet open/sync/funding helpers, over the node_call +# seam (host daemon now, container later). +# +# Env beyond docs/contract.md: +# E2E_WALLET_MOD wallet module id (default logos_execution_zone) +# E2E_REGISTRY_MOD registry-provider module id (default +# liblogos_lez_rln_module) +# SYNC_STEP blocks per sync_to_block call (default 3000) -wallet_open() { die "wallet.sh: not implemented yet (W1-A)"; } -wallet_sync() { die "wallet.sh: not implemented yet (W1-A)"; } -wallet_fresh_holding() { die "wallet.sh: not implemented yet (W1-A)"; } -wait_balance() { die "wallet.sh: not implemented yet (W1-A)"; } \ No newline at end of file +. "$(dirname "${BASH_SOURCE[0]}")/daemon.sh" +. "$(dirname "${BASH_SOURCE[0]}")/chain.sh" + +E2E_WALLET_MOD="${E2E_WALLET_MOD:-logos_execution_zone}" +E2E_REGISTRY_MOD="${E2E_REGISTRY_MOD:-liblogos_lez_rln_module}" +SYNC_STEP="${SYNC_STEP:-3000}" + +# Usage: wallet_open [wallet_home] +# storage.json is the mutable wallet; the staged fixture ships it as +# storage.json.seed so a re-run starts from the deployment's own accounts. +wallet_open() { + local node="$1" home="${2:-${E2E_WALLET_HOME:-}}" + [ -n "$home" ] || die "wallet_open: no wallet home (the target sets E2E_WALLET_HOME)" + [ -f "$home/wallet_config.json" ] || die "wallet_open: no wallet_config.json in $home" + if [ ! -f "$home/storage.json" ]; then + cp "$home/storage.json.seed" "$home/storage.json" \ + || die "wallet_open: cannot seed $home/storage.json" + fi + node_call "$node" "$E2E_WALLET_MOD" open "$home/wallet_config.json" "$home/storage.json" >/dev/null \ + || die_node "$node" "wallet open failed" +} + +# Sync to the chain head in SYNC_STEP chunks (a single jump over a long chain +# times the sequencer poll out). Prints the block actually reached; stops early +# when a chunk makes no progress. +# Usage: wallet_sync +wallet_sync() { + local node="$1" head cur tgt next + head=$(chain_head) || die "wallet_sync: cannot probe chain head" + cur=$(node_call "$node" "$E2E_WALLET_MOD" get_last_synced_block | jres | jval) + case "$cur" in ''|*[!0-9]*) cur=0 ;; esac + while [ "$cur" -lt "$head" ]; do + tgt=$((cur + SYNC_STEP)) + [ "$tgt" -gt "$head" ] && tgt="$head" + node_call "$node" "$E2E_WALLET_MOD" sync_to_block "$tgt" >/dev/null 2>&1 + next=$(node_call "$node" "$E2E_WALLET_MOD" get_last_synced_block | jres | jval) + case "$next" in ''|*[!0-9]*) break ;; esac + [ "$next" = "$cur" ] && break + cur="$next" + done + printf '%s' "$cur" +} + +# create_account_public derives accounts DETERMINISTICALLY from the wallet's +# key chain, so early derivations collide with accounts earlier runs already +# created on-chain — walk the chain until an account with no on-chain token +# data. Prints the account id; 1 when the walk is exhausted. +# Usage: wallet_fresh_holding +wallet_fresh_holding() { + local node="$1" acc bal_json _d + for _d in $(seq 1 "${E2E_DERIVE_TRIES:-30}"); do + acc=$(node_call "$node" "$E2E_WALLET_MOD" create_account_public | jres) || acc="" + case "$acc" in ''|ERR|None) sleep 2; continue ;; esac + bal_json=$(node_call "$node" "$E2E_REGISTRY_MOD" get_token_balance \ + "$(argfile fresh_holding "$acc")" | jres) || bal_json="" + case "$bal_json" in + *'"exists":false'*) printf '%s' "$acc"; return 0 ;; + esac + done + return 1 +} + +# Poll until holds at least RLNTOK (credit lands async). +# Prints the last seen balance; 1 when the budget runs out. +# Usage: wait_balance +wait_balance() { + local node="$1" acct="$2" want="$3" bal=0 _w tries + local iv="${E2E_POLL_INTERVAL_S:-5}" + tries=$(( ${E2E_CONFIRM_TIMEOUT_S:-180} / iv )) + [ "$tries" -lt 1 ] && tries=1 + for _w in $(seq 1 "$tries"); do + bal=$(node_call "$node" "$E2E_REGISTRY_MOD" get_token_balance \ + "$(argfile wait_balance "$acct")" | jres | jfield balance) + case "$bal" in ''|*[!0-9]*) bal=0 ;; esac + if [ "$bal" -ge "$want" ]; then printf '%s' "$bal"; return 0; fi + sleep "$iv" + done + printf '%s' "$bal" + return 1 +} diff --git a/harness/selftest.sh b/harness/selftest.sh new file mode 100755 index 0000000..b8fa6ca --- /dev/null +++ b/harness/selftest.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# harness/selftest.sh — the no-chain proof of the harness layers: resolve the +# artifacts, boot one daemon with the three modules loaded, and call into both +# RLN modules with NO sequencer anywhere. The assertion is that the call comes +# back as well-formed JSON (a value OR a module error envelope) — a chain +# failure is a PASS here; what is under test is artifacts -> lgx -> daemon -> +# json, not the chain. +# +# Safe to run on a laptop with nothing else up: no chain, no docker, no ports. +# +# bash harness/selftest.sh E2E_KEEP=1 keeps the daemon + run dir +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +. "$HERE/lib/compat.sh" + +export E2E_KEEP="${E2E_KEEP:-0}" +export CALL_TIMEOUT="${CALL_TIMEOUT:-60}" +E2E_RUN_DIR="$ROOT/runs/$(date +%Y%m%d-%H%M%S)-selftest" +export E2E_RUN_DIR +mkdir -p "$E2E_RUN_DIR" +say "run dir: $E2E_RUN_DIR" + +. "$HERE/artifacts.sh" +. "$HERE/lib/daemon.sh" + +NODE=selftest +cleanup() { + daemon_stop_all + [ "${E2E_KEEP:-0}" = "1" ] || rm -rf "$E2E_RUN_DIR" +} +trap cleanup EXIT + +resolve_artifacts +daemon_start "$NODE" +daemon_load_modules "$NODE" logos_execution_zone liblogos_lez_rln_module liblogos_rln_module + +section "no-chain probes" +FAIL=0 +# A dummy scope: the registry id shape the membership module parses, and a +# config account no deployment owns. Neither can succeed without a chain — the +# point is that the failure arrives as JSON. +DUMMY_CFG=11111111111111111111111111111111 +DUMMY_ID=$(printf '%064d' 0) +REGISTRY_ID="logos:selftest:$(printf '%064d' 1)" + +RAW=$(node_call "$NODE" liblogos_lez_rln_module get_registry_bounds "$(argfile st_cfg "$DUMMY_CFG")") +STATUS=$(printf '%s' "$RAW" | jstatus) +if [ "$STATUS" = "ok" ] || [ "$STATUS" = "error" ]; then + say "liblogos_lez_rln_module.get_registry_bounds -> status=$STATUS result='$(printf '%s' "$RAW" | jres)'" +else + say "FAIL liblogos_lez_rln_module.get_registry_bounds returned no JSON: ${RAW:-}" + FAIL=$((FAIL + 1)) +fi + +RAW=$(node_call "$NODE" liblogos_rln_module get_registry_parameters \ + "$REGISTRY_ID" "$(argfile st_rlnid "$DUMMY_ID")") +STATUS=$(printf '%s' "$RAW" | jstatus) +RES=$(printf '%s' "$RAW" | jres) +if [ "$STATUS" = "ok" ] && [ -n "$RES" ]; then + # -> result: the envelope carries either the parameters or a typed error. + say "liblogos_rln_module.get_registry_parameters -> $(printf '%s' "$RES" | jval)" +else + say "FAIL liblogos_rln_module.get_registry_parameters: status='${STATUS:-}' raw=${RAW:-}" + FAIL=$((FAIL + 1)) +fi + +section "selftest" +if [ "$FAIL" = "0" ]; then + say "PASS — artifacts, lgx install, daemon boot, module load and JSON plumbing all work" +else + die_node "$NODE" "selftest: $FAIL probe(s) failed" +fi diff --git a/harness/targets/local.sh b/harness/targets/local.sh index b4acd36..0aaafaf 100644 --- a/harness/targets/local.sh +++ b/harness/targets/local.sh @@ -1,22 +1,207 @@ # shellcheck shell=bash -# harness/targets/local.sh — the local-sequencer target (W1-B implements). +# harness/targets/local.sh — the local-sequencer target. # -# target_up: -# E2E_DEVNET=host (default) run logos-lez-rln's root dev.sh (from -# $LEZ_RLN_CHECKOUT, default ../logos-lez-rln) in -# the background; it clones + runs the standalone -# sequencer_service on port 3040 and wipes its -# rocksdb each start. -# E2E_DEVNET=external attach to an already-running sequencer -# (inner dev loop; skips re-provisioning). -# Readiness = JSON-RPC getLastBlockId >= 1, not a port probe. -# Then provision a fresh deployment (lez-rln tools/deployments/provision.sh -# --funding faucet) into $E2E_RUN_DIR and stage it (stage.sh) into -# $E2E_WALLET_HOME; export the contract env (docs/contract.md). -# Provisioning needs prebuilt run_setup/derive_accounts + the risc0 guest -# blobs in the checkout — when missing, print the exact build recipe and die. +# target_up boots (or attaches to) a sequencer on 127.0.0.1:3040, provisions a +# fresh faucet-funded RLN deployment on it, stages that into a wallet home and +# exports the contract env (docs/contract.md) with the local poll budgets. # -# target_down: kill the sequencer we started unless E2E_KEEP=1. +# Inputs beyond the contract: +# E2E_DEVNET=host|external host (default) runs logos-lez-rln's root dev.sh +# in the background: it clones the pinned +# sequencer source, wipes its rocksdb and starts +# sequencer_service on port 3040 (first boot +# cargo-builds it). external attaches to a +# sequencer already listening at E2E_SEQUENCER. +# LEZ_RLN_CHECKOUT= logos-lez-rln checkout carrying dev.sh and +# tools/deployments (default ../logos-lez-rln). +# E2E_SEQUENCER= endpoint (default http://127.0.0.1:3040/). +# E2E_DEPLOYMENT_DIR= external mode only: reuse this deployment +# instead of provisioning a fresh one. +# E2E_DEVNET_TIMEOUT_S=900 readiness budget for the sequencer. +# The contract's poll budgets honour a pre-set env; the defaults below are +# the contract's local values. +# +# Readiness is JSON-RPC getLastBlockId >= 1, never a port probe: the listener +# accepts connections before the chain produces its first block. +# +# target_down kills the dev.sh process tree unless E2E_KEEP=1. -target_up() { die "targets/local.sh: not implemented yet (W1-B)"; } -target_down() { :; } \ No newline at end of file +_LOCAL_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +_LOCAL_DEVNET_PID="" + +# Chain head, or non-zero when the endpoint does not answer. +_local_chain_head() { + local head + head=$(curl -sS -m 10 -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"getLastBlockId","params":[],"id":1}' \ + "$1" 2>/dev/null | jq -re '.result // empty' 2>/dev/null) || return 1 + case "$head" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s' "$head" +} + +# _local_wait_chain — block until the chain has a block. +_local_wait_chain() { + local url="$1" budget="$2" waited=0 head + while [ "$waited" -lt "$budget" ]; do + head=$(_local_chain_head "$url") || head="" + if [ -n "$head" ] && [ "$head" -ge 1 ] 2>/dev/null; then + say "sequencer ready at $url (block $head)" + return 0 + fi + sleep 5 + waited=$((waited + 5)) + if [ $((waited % 60)) -eq 0 ]; then + say " waiting for $url (${waited}s of ${budget}s)" + fi + done + return 1 +} + +_local_start_devnet() { + local lez="$1" log="$E2E_RUN_DIR/devnet.log" + [ -f "$lez/dev.sh" ] || die "no logos-lez-rln checkout at $lez (set LEZ_RLN_CHECKOUT)" + command -v cargo >/dev/null || die "dev.sh needs cargo — install Rust (https://rustup.rs)" + say "starting devnet: $lez/dev.sh (log: $log)" + # Monitor mode puts the job in its own process group, so target_down can + # signal cargo and the sequencer it spawns as one tree. + set -m + (cd "$lez" && exec bash ./dev.sh) >"$log" 2>&1 & + _LOCAL_DEVNET_PID=$! + set +m + # Off the job table: the group id stays valid for target_down, and the + # shell stops reporting the job's death on the harness's own stdout. + disown "$_LOCAL_DEVNET_PID" 2>/dev/null || true +} + +_local_kill_devnet() { + local pid="$1" waited=0 port stale p + kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + while [ "$waited" -lt 10 ]; do + _local_chain_head "$E2E_SEQUENCER" >/dev/null || return 0 + sleep 1 + waited=$((waited + 1)) + done + kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true + # The sequencer binary can outlive its cargo parent; dev.sh frees the port + # the same way on its next start. + port=$(printf '%s' "$E2E_SEQUENCER" | sed -n 's|.*://[^:/]*:\([0-9][0-9]*\).*|\1|p') + stale=$(lsof -ti "tcp:${port:-3040}" 2>/dev/null || true) + for p in $stale; do + kill "$p" 2>/dev/null || true + done +} + +# Provisioning runs run_setup + derive_accounts out of the checkout and deploys +# the guest blobs, so both must be built before a fresh local deployment. +_local_require_build() { + local lez="$1" missing="" + [ -x "$lez/lez-rln/target/release/run_setup" ] || missing="$missing lez-rln/target/release/run_setup" + [ -x "$lez/lez-rln/target/release/derive_accounts" ] || missing="$missing lez-rln/target/release/derive_accounts" + ls "$lez"/lez-rln/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/*.bin >/dev/null 2>&1 \ + || missing="$missing lez-rln/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/*.bin" + [ -z "$missing" ] && return 0 + die "logos-lez-rln is not built for provisioning — missing:$missing + + build it (order matters — the host build strips the deploy blobs): + cd $lez/lez-rln + cargo risczero build --manifest-path methods/guest/Cargo.toml + PYO3_PYTHON=\$(command -v python3) cargo build --release --bin run_setup --bin derive_accounts" +} + +_local_provision() { + local lez="$1" outroot="$2" log="$E2E_RUN_DIR/provision.log" + _local_require_build "$lez" + mkdir -p "$outroot" + say "provisioning a fresh faucet deployment on $E2E_SEQUENCER (run_setup — several minutes; log: $log)" + (cd "$lez" && bash tools/deployments/provision.sh \ + --name local-e2e --sequencer "$E2E_SEQUENCER" --outdir "$outroot" \ + --funding faucet) >>"$log" 2>&1 || { + [ -f "$log" ] && tail -40 "$log" >&2 + die "provision.sh failed — see $log" + } +} + +target_up() { + section "target: local" + for tool in curl jq python3; do + command -v "$tool" >/dev/null || die "missing tool: $tool" + done + + local lez devnet t0 dep_dir + lez="${LEZ_RLN_CHECKOUT:-$_LOCAL_HERE/../logos-lez-rln}" + [ -d "$lez" ] && lez="$(cd "$lez" && pwd)" + devnet="${E2E_DEVNET:-host}" + E2E_SEQUENCER="${E2E_SEQUENCER:-http://127.0.0.1:3040/}" + + t0=$(date +%s) + case "$devnet" in + host) + _local_start_devnet "$lez" + _local_wait_chain "$E2E_SEQUENCER" "${E2E_DEVNET_TIMEOUT_S:-900}" \ + || die "devnet never produced a block within ${E2E_DEVNET_TIMEOUT_S:-900}s — see $E2E_RUN_DIR/devnet.log" + ;; + external) + _local_wait_chain "$E2E_SEQUENCER" 30 \ + || die "E2E_DEVNET=external but no sequencer answers getLastBlockId at $E2E_SEQUENCER" + ;; + *) die "E2E_DEVNET must be host|external, got '$devnet'" ;; + esac + say "timing: devnet-up $(( $(date +%s) - t0 ))s" + + t0=$(date +%s) + dep_dir="${E2E_DEPLOYMENT_DIR:-}" + if [ "$devnet" = "external" ] && [ -n "$dep_dir" ]; then + [ -f "$dep_dir/deployment.json" ] && [ -f "$dep_dir/storage.json" ] \ + || die "E2E_DEPLOYMENT_DIR=$dep_dir has no deployment.json + storage.json" + say "reusing deployment $dep_dir" + else + [ -z "$dep_dir" ] || die "E2E_DEPLOYMENT_DIR is only honoured with E2E_DEVNET=external (a fresh devnet knows no earlier tree)" + _local_provision "$lez" "$E2E_RUN_DIR/deployments" + dep_dir="$E2E_RUN_DIR/deployments/local-e2e" + fi + # The guest-drift guard: a rebuilt guest re-derives a different config for + # the same tree_id, which otherwise surfaces as a chain bug. + bash "$lez/tools/deployments/verify.sh" "$dep_dir" || die "verify.sh failed for $dep_dir" + say "timing: provision $(( $(date +%s) - t0 ))s" + + t0=$(date +%s) + E2E_DEPLOYMENT_DIR="$dep_dir" + E2E_WALLET_HOME="$E2E_RUN_DIR/wallet-home" + bash "$lez/tools/deployments/stage.sh" "$dep_dir" "$E2E_WALLET_HOME" \ + || die "stage.sh failed for $dep_dir" + # stage.sh emits the wallet as a seed; the run mutates its own copy. + cp "$E2E_WALLET_HOME/storage.json.seed" "$E2E_WALLET_HOME/storage.json" \ + || die "no storage.json.seed in $E2E_WALLET_HOME" + + E2E_TREE_ID=$(grep -oE 'LEZ_RLN_TREE_ID_HEX=[0-9a-f]{64}' "$E2E_WALLET_HOME/env.sh" | cut -d= -f2) + E2E_CONFIG_ACCOUNT=$(tr -d '\n\r' < "$E2E_WALLET_HOME/config_account.txt") + E2E_FUNDING=$(tr -d '\n\r' < "$E2E_WALLET_HOME/funding.txt") + [ -n "$E2E_TREE_ID" ] && [ -n "$E2E_CONFIG_ACCOUNT" ] && [ -n "$E2E_FUNDING" ] \ + || die "staged fixtures incomplete in $E2E_WALLET_HOME" + say "timing: stage $(( $(date +%s) - t0 ))s" + + E2E_CONFIRM_TIMEOUT_S="${E2E_CONFIRM_TIMEOUT_S:-120}" + E2E_POLL_INTERVAL_S="${E2E_POLL_INTERVAL_S:-5}" + E2E_EPOCH_SIZE_SEC="${E2E_EPOCH_SIZE_SEC:-60}" + E2E_ROOT_WINDOW_TIMEOUT_S="${E2E_ROOT_WINDOW_TIMEOUT_S:-60}" + export E2E_SEQUENCER E2E_DEPLOYMENT_DIR E2E_WALLET_HOME E2E_TREE_ID \ + E2E_CONFIG_ACCOUNT E2E_FUNDING E2E_CONFIRM_TIMEOUT_S E2E_POLL_INTERVAL_S \ + E2E_EPOCH_SIZE_SEC E2E_ROOT_WINDOW_TIMEOUT_S + say "deployment: tree ${E2E_TREE_ID:0:8}… config $E2E_CONFIG_ACCOUNT funding $E2E_FUNDING" +} + +target_down() { + if [ "${E2E_KEEP:-0}" = "1" ]; then + [ -n "$_LOCAL_DEVNET_PID" ] \ + && say "E2E_KEEP=1: devnet pid $_LOCAL_DEVNET_PID up at $E2E_SEQUENCER (log $E2E_RUN_DIR/devnet.log)" + say "E2E_KEEP=1: deployment ${E2E_DEPLOYMENT_DIR:-}, wallet home ${E2E_WALLET_HOME:-}" + say "E2E_KEEP=1: reattach with E2E_DEVNET=external E2E_DEPLOYMENT_DIR=${E2E_DEPLOYMENT_DIR:-}" + return 0 + fi + [ -n "$_LOCAL_DEVNET_PID" ] || return 0 + say "stopping devnet (pid $_LOCAL_DEVNET_PID)" + _local_kill_devnet "$_LOCAL_DEVNET_PID" + _LOCAL_DEVNET_PID="" +} diff --git a/harness/targets/testnet.sh b/harness/targets/testnet.sh index 0480cce..67b28eb 100644 --- a/harness/targets/testnet.sh +++ b/harness/targets/testnet.sh @@ -1,12 +1,120 @@ # shellcheck shell=bash -# harness/targets/testnet.sh — the hosted-testnet target (W1-B implements). +# harness/targets/testnet.sh — the hosted-testnet target. # -# target_up: no chain lifecycle — read a committed descriptor from -# deployments/ (default via E2E_DEPLOYMENT), assert the sequencer -# answers getLastBlockId, stage into $E2E_WALLET_HOME, export the contract -# env with testnet poll budgets (docs/contract.md). +# No chain lifecycle: the descriptor is committed under this repo's +# deployments/ (see deployments/README.md). target_up asserts the +# descriptor's sequencer answers getLastBlockId, stages the deployment into +# E2E_WALLET_HOME and exports the contract env (docs/contract.md) with the +# testnet poll budgets. # -# target_down: nothing to tear down. +# Inputs beyond the contract: +# E2E_DEPLOYMENT= descriptor dir under deployments/ — required, no +# default: a testnet run always names the tree it +# spends against. +# LEZ_RLN_CHECKOUT= logos-lez-rln source providing +# tools/deployments/stage.sh. Resolution: this var, +# then E2E_LEZ_RLN_SRC (harness/artifacts.sh), then +# the flake pin (nix build .#pins), then +# ../logos-lez-rln. +# The contract's poll budgets honour a pre-set env; the defaults below are +# the contract's testnet values. +# +# Descriptors are staged, never verified here: verify.sh re-derives program ids +# from built guest binaries, which a testnet run must not require. -target_up() { die "targets/testnet.sh: not implemented yet (W1-B)"; } -target_down() { :; } \ No newline at end of file +_TESTNET_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +_testnet_chain_head() { + local head + head=$(curl -sS -m 15 -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"getLastBlockId","params":[],"id":1}' \ + "$1" 2>/dev/null | jq -re '.result // empty' 2>/dev/null) || return 1 + case "$head" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s' "$head" +} + +# The lez-rln source carrying tools/deployments/stage.sh. +_testnet_lez_src() { + local pins + if [ -n "${LEZ_RLN_CHECKOUT:-}" ]; then + printf '%s' "$LEZ_RLN_CHECKOUT" + return 0 + fi + if [ -n "${E2E_LEZ_RLN_SRC:-}" ]; then + printf '%s' "$E2E_LEZ_RLN_SRC" + return 0 + fi + if pins=$(nix build "$_TESTNET_HERE#pins" --no-link --print-out-paths 2>/dev/null) \ + && [ -f "$pins" ]; then + # shellcheck disable=SC1090 + . "$pins" + if [ -n "${E2E_LEZ_RLN_SRC:-}" ]; then + printf '%s' "$E2E_LEZ_RLN_SRC" + return 0 + fi + fi + [ -d "$_TESTNET_HERE/../logos-lez-rln" ] || return 1 + (cd "$_TESTNET_HERE/../logos-lez-rln" && pwd) +} + +_testnet_deployments() { + local d names="" + for d in "$_TESTNET_HERE"/deployments/*/; do + [ -f "$d/deployment.json" ] || continue + names="$names $(basename "$d")" + done + printf '%s' "${names# }" +} + +target_up() { + section "target: testnet" + for tool in curl jq python3; do + command -v "$tool" >/dev/null || die "missing tool: $tool" + done + + local name dep_dir lez head available + available=$(_testnet_deployments) + name="${E2E_DEPLOYMENT:-}" + [ -n "$name" ] \ + || die "--target testnet needs E2E_DEPLOYMENT= (deployments/: ${available:-})" + dep_dir="$_TESTNET_HERE/deployments/$name" + [ -f "$dep_dir/deployment.json" ] && [ -f "$dep_dir/storage.json" ] \ + || die "no deployment 'deployments/$name' with deployment.json + storage.json (available: ${available:-})" + + lez=$(_testnet_lez_src) \ + || die "no logos-lez-rln source for tools/deployments/stage.sh (set LEZ_RLN_CHECKOUT)" + [ -f "$lez/tools/deployments/stage.sh" ] || die "no tools/deployments/stage.sh under $lez" + + E2E_SEQUENCER=$(jq -re '.sequencer' "$dep_dir/deployment.json") \ + || die "deployments/$name/deployment.json has no sequencer" + head=$(_testnet_chain_head "$E2E_SEQUENCER") \ + || die "sequencer $E2E_SEQUENCER does not answer getLastBlockId (deployment '$name')" + say "sequencer $E2E_SEQUENCER at block $head" + + E2E_DEPLOYMENT_DIR="$dep_dir" + E2E_WALLET_HOME="$E2E_RUN_DIR/wallet-home" + bash "$lez/tools/deployments/stage.sh" "$dep_dir" "$E2E_WALLET_HOME" \ + || die "stage.sh failed for deployments/$name" + # stage.sh emits the wallet as a seed; the run mutates its own copy. + cp "$E2E_WALLET_HOME/storage.json.seed" "$E2E_WALLET_HOME/storage.json" \ + || die "no storage.json.seed in $E2E_WALLET_HOME" + + E2E_TREE_ID=$(grep -oE 'LEZ_RLN_TREE_ID_HEX=[0-9a-f]{64}' "$E2E_WALLET_HOME/env.sh" | cut -d= -f2) + E2E_CONFIG_ACCOUNT=$(tr -d '\n\r' < "$E2E_WALLET_HOME/config_account.txt") + E2E_FUNDING=$(tr -d '\n\r' < "$E2E_WALLET_HOME/funding.txt") + [ -n "$E2E_TREE_ID" ] && [ -n "$E2E_CONFIG_ACCOUNT" ] && [ -n "$E2E_FUNDING" ] \ + || die "staged fixtures incomplete in $E2E_WALLET_HOME" + + E2E_CONFIRM_TIMEOUT_S="${E2E_CONFIRM_TIMEOUT_S:-600}" + E2E_POLL_INTERVAL_S="${E2E_POLL_INTERVAL_S:-10}" + E2E_EPOCH_SIZE_SEC="${E2E_EPOCH_SIZE_SEC:-600}" + E2E_ROOT_WINDOW_TIMEOUT_S="${E2E_ROOT_WINDOW_TIMEOUT_S:-120}" + export E2E_SEQUENCER E2E_DEPLOYMENT_DIR E2E_WALLET_HOME E2E_TREE_ID \ + E2E_CONFIG_ACCOUNT E2E_FUNDING E2E_CONFIRM_TIMEOUT_S E2E_POLL_INTERVAL_S \ + E2E_EPOCH_SIZE_SEC E2E_ROOT_WINDOW_TIMEOUT_S + say "deployment: $name tree ${E2E_TREE_ID:0:8}… config $E2E_CONFIG_ACCOUNT funding $E2E_FUNDING" +} + +target_down() { :; } diff --git a/run.sh b/run.sh index 3926e11..a80130e 100755 --- a/run.sh +++ b/run.sh @@ -23,6 +23,7 @@ list_scenarios() { for env in "$HERE"/scenarios/*/scenario.env; do [ -f "$env" ] || continue id=$(basename "$(dirname "$env")") + # shellcheck source=/dev/null status=$(. "$env"; printf '%s' "${STATUS:-active}") printf ' %-14s %s\n' "$id" "$status" done @@ -66,6 +67,7 @@ say "run dir: $E2E_RUN_DIR" . "$HERE/harness/artifacts.sh" resolve_artifacts +# shellcheck source=/dev/null . "$TARGET_SH" trap 'target_down' EXIT target_up diff --git a/scenarios/register/run.sh b/scenarios/register/run.sh index 9764aa0..e1f21b5 100755 --- a/scenarios/register/run.sh +++ b/scenarios/register/run.sh @@ -1,8 +1,290 @@ #!/usr/bin/env bash -# W1-C ports logos-rln-modules/logos-rln-module/tests/e2e_register_testnet.sh -# here, minus its harness stages (staging/daemon/wallet move to harness/lib), -# with poll budgets from the contract env instead of testnet constants and the -# registry id derived as logos:${E2E_TARGET}:. +# scenarios/register — the single-node membership lifecycle over the real +# module stack (logos_execution_zone -> liblogos_lez_rln_module -> +# liblogos_rln_module). Drives a PAID registration through the membership +# module's spec surface — the faucet-funded Register instruction, NOT the +# gifter's RegisterFree path: +# +# open wallet -> sync -> derive fresh holding -> claim_tokens (faucet) +# -> unlock_keystore -> register (membership module GENERATES the credential +# in-module) -> poll get_membership_state to "active" -> select_membership +# -> get_merkle_proof -> cross-check via liblogos_lez_rln_module.get_membership +# -> start (warm root window) -> generate_proof -> get_epoch_quota +# -> verify_proof (verdict valid) -> verify_proof with a tampered signal +# (verdict invalid) +# +# Besides the registration itself this is the acceptance for two open +# architecture risks: +# R2 — lp_* calls INTO a Rust module (membership -> rln over the raw lp +# client). A provider_failure on every membership call while direct +# liblogos_lez_rln_module calls succeed means the lp transport to Rust +# modules is broken -> fall back to the generated typed client. +# R4 — the host stamping instance_persistence_path. unlock_keystore +# failing with kind "internal" (no persistence path) means logoscore +# does not provide one -> the module needs an explicit override. +# +# Target-agnostic: chain, deployment, funding mode and every poll budget +# arrive through the harness contract (docs/contract.md) — the target is a +# flag, and nothing here is derived locally. +# +# Cost: one registration at rate_limit=100 burns 100 x price_per_unit, taken +# from a fresh faucet claim; the run takes as long as the target's +# confirmation budget allows. +# +# Env beyond docs/contract.md: +# E2E_RATE_LIMIT=100 registration rate limit set -uo pipefail -. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/harness/lib/compat.sh" -die "scenarios/register: not implemented yet (W1-C)" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +for _lib in compat json lgx daemon wallet chain; do + # shellcheck source=/dev/null + . "$ROOT/harness/lib/$_lib.sh" +done + +RATE_LIMIT="${E2E_RATE_LIMIT:-100}" +NODE=n1 + +for _v in LOGOSCORE E2E_MODULES_DIR E2E_SEQUENCER E2E_WALLET_HOME E2E_CONFIG_ACCOUNT \ + E2E_TREE_ID E2E_FUNDING E2E_CONFIRM_TIMEOUT_S E2E_POLL_INTERVAL_S \ + E2E_EPOCH_SIZE_SEC E2E_ROOT_WINDOW_TIMEOUT_S; do + eval "[ -n \"\${$_v:-}\" ]" || die "contract env missing: $_v (see docs/contract.md)" +done +[ "$E2E_POLL_INTERVAL_S" -ge 1 ] 2>/dev/null || die "E2E_POLL_INTERVAL_S must be a positive integer" +[ "$E2E_FUNDING" = "faucet" ] \ + || die "target '$E2E_TARGET' provides funding=$E2E_FUNDING — this scenario exercises the faucet-paid Register path (no gifter); pick a faucet deployment" + +# Poll count for a contract budget, floor 1. +polls() { + local n=$(( $1 / $2 )) + [ "$n" -ge 1 ] || n=1 + printf '%s' "$n" +} + +NODE_UP=0 +DYING=0 +# The daemon's reply is usually the whole story on failure. +die() { + printf '%s\n' "e2e: FAIL: $*" >&2 + if [ "$DYING" = 0 ] && [ "$NODE_UP" = 1 ]; then + DYING=1 + echo "---- node log tail ----" >&2 + node_logs "$NODE" 40 >&2 || true + fi + exit 1 +} +cleanup() { + if [ "${E2E_KEEP:-0}" = "1" ]; then + say "E2E_KEEP=1: leaving node $NODE up, state in $E2E_RUN_DIR" + return + fi + [ "$NODE_UP" = 1 ] && daemon_stop "$NODE" +} +trap cleanup EXIT + +CONFIG_HEX=$(python3 - "$E2E_CONFIG_ACCOUNT" <<'EOF' +import sys +A = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +n = 0 +for c in sys.argv[1]: + n = n * 58 + A.index(c) +print(n.to_bytes(32, "big").hex()) +EOF +) || die "cannot decode config account '$E2E_CONFIG_ACCOUNT'" +REGISTRY_ID="logos:${E2E_TARGET}:$CONFIG_HEX" +say "registry: $REGISTRY_ID (tree ${E2E_TREE_ID:0:8}…, sequencer $E2E_SEQUENCER)" + +# ---------- node ------------------------------------------------------------ +section "node" +daemon_start "$NODE" || die "daemon_start $NODE failed" +NODE_UP=1 +daemon_load_modules "$NODE" logos_execution_zone liblogos_lez_rln_module liblogos_rln_module \ + || die "load-module failed" + +# ---------- wallet: open + sync --------------------------------------------- +section "wallet" +wallet_open "$NODE" || die "wallet open failed" +CHAIN_HEAD=$(chain_head) || die "cannot probe chain head at $E2E_SEQUENCER" +say "syncing wallet to chain head $CHAIN_HEAD" +wallet_sync "$NODE" "$CHAIN_HEAD" + +# ---------- faucet funding (Register-instruction path, no gifter) ----------- +say "deriving a fresh holding account" +HOLDING=$(wallet_fresh_holding "$NODE") || HOLDING="" +[ -n "$HOLDING" ] || die "no unused holding account" +say "holding: $HOLDING" + +# rate_limit x price_per_unit, doubled for slack — read the live price from +# the v1.1 bounds method rather than hardcoding the deployment's tariff. +BOUNDS=$(node_call "$NODE" liblogos_lez_rln_module get_registry_bounds \ + "$(argfile cfg "$E2E_CONFIG_ACCOUNT")" | jres) || BOUNDS="" +[ -n "$BOUNDS" ] || die "get_registry_bounds failed (rln module up?)" +PRICE=$(printf '%s' "$BOUNDS" | jfield price_per_unit) +[ -n "$PRICE" ] || die "no price_per_unit in bounds: $BOUNDS" +CLAIM=$(( RATE_LIMIT * PRICE * 2 )) +say "claiming $CLAIM RLNTOK from the faucet (rate $RATE_LIMIT x price $PRICE x2)" +CLAIM_RES=$(node_call "$NODE" liblogos_lez_rln_module claim_tokens \ + "$(argfile cfg2 "$E2E_CONFIG_ACCOUNT")" "$(argfile hold "$HOLDING")" "$CLAIM" | jres) || CLAIM_RES="" +[ -n "$CLAIM_RES" ] || die "claim_tokens failed" +wait_balance "$NODE" "$HOLDING" "$CLAIM" || die "faucet credit never landed (want $CLAIM)" + +# ---------- scope (the identity is generated INSIDE the module) -------------- +# The consumer supplies only the scope (registry_id + rln_identifier) and the +# rate limit; register mints and persists the credential in-module and the +# secret never crosses the wire. +RLN_ID=$(openssl rand -hex 32) + +# ---------- the registration, through the membership module ----------------- +section "registration" +UNLOCK=$(node_call "$NODE" liblogos_rln_module unlock_keystore e2e-test-password | jres) || UNLOCK="" +case "$UNLOCK" in + *'"unlocked":true'*) say "keystore unlocked" ;; + *'no persistence path'*|*'not initialized'*) + die "R4 CONFIRMED: host provides no instance_persistence_path — unlock said: $UNLOCK" ;; + *) die "unlock_keystore failed: ${UNLOCK:-}" ;; +esac + +OPTIONS_JSON="{\"funding_holding_account_id\":\"$HOLDING\"}" +say "register($REGISTRY_ID, rate $RATE_LIMIT) via membership module" +REG=$(node_call "$NODE" liblogos_rln_module register \ + "$REGISTRY_ID" "$(argfile rlnid "$RLN_ID")" "$RATE_LIMIT" "$OPTIONS_JSON" | jres) || REG="" +case "$REG" in + *'"state":"pending"'*) ;; + *'provider_failure'*) + die "R2 CONFIRMED?: membership->rln lp transport failed. Reply: $REG — check whether direct liblogos_lez_rln_module calls above succeeded (they did if you see this), which isolates the fault to lp calls INTO a Rust module. Fallback: generated typed client." ;; + *) die "register failed: ${REG:-}" ;; +esac +MEMBERSHIP_HASH=$(printf '%s' "$REG" | jfield membership_hash) +# The commitment is public — the module surfaces it in the Membership view (no +# secret). Used only for the sibling cross-check below. +COMMITMENT=$(printf '%s' "$REG" | python3 -c \ + 'import json,sys; print(json.load(sys.stdin).get("credential",{}).get("identity_commitment",""))' 2>/dev/null || true) +say "pending membership: $MEMBERSHIP_HASH (commitment ${COMMITMENT:0:16}…)" + +say "polling get_membership_state to active (budget ${E2E_CONFIRM_TIMEOUT_S}s)…" +STATE="" +STATE_JSON="" +for _t in $(seq 1 "$(polls "$E2E_CONFIRM_TIMEOUT_S" "$E2E_POLL_INTERVAL_S")"); do + STATE_JSON=$(node_call "$NODE" liblogos_rln_module get_membership_state \ + "$REGISTRY_ID" "$(argfile rlnid "$RLN_ID")" | jres) || STATE_JSON="" + STATE=$(printf '%s' "$STATE_JSON" | jfield state) + say " state poll $_t: ${STATE:-}" + case "$STATE" in + active|grace_period) break ;; + failed) die "registration FAILED: $(node_call "$NODE" liblogos_rln_module get_memberships "$REGISTRY_ID" | jres)" ;; + esac + sleep "$E2E_POLL_INTERVAL_S" +done +[ "$STATE" = "active" ] || [ "$STATE" = "grace_period" ] \ + || die "membership never became active (last state: ${STATE:-})" +LEAF=$(printf '%s' "$STATE_JSON" | jfield leaf_index) +say "ACTIVE at leaf $LEAF" + +# ---------- post-registration surface ---------------------------------------- +# select_membership returns the PUBLIC view only (the secret never leaves the +# module); assert the membership_hash rather than a released credential. +SELECTED=$(node_call "$NODE" liblogos_rln_module select_membership \ + "$REGISTRY_ID" "$(argfile rlnid "$RLN_ID")" "" | jres) || SELECTED="" +case "$SELECTED" in + *"$MEMBERSHIP_HASH"*) say "select_membership returned the public membership" ;; + *) die "select_membership did not return the membership: ${SELECTED:-}" ;; +esac + +PROOF=$(node_call "$NODE" liblogos_rln_module get_merkle_proof "$REGISTRY_ID" "$LEAF" | jres) || PROOF="" +case "$PROOF" in + *'"valid_roots"'*) say "get_merkle_proof returned a rooted proof" ;; + *) die "get_merkle_proof failed: ${PROOF:-}" ;; +esac + +CROSS=$(node_call "$NODE" liblogos_lez_rln_module get_membership \ + "$(argfile cfg3 "$E2E_CONFIG_ACCOUNT")" "$(argfile commit2 "$COMMITMENT")" | jres) || CROSS="" +case "$CROSS" in + *'"registered":true'*) say "cross-check: rln module sees the membership ($(printf '%s' "$CROSS" | jfield state))" ;; + *) die "cross-check get_membership failed: ${CROSS:-}" ;; +esac + +# ---------- rate-limit proofs (the spec's rate-limiting portion) -------------- +# start() warms the registry's valid-root window; generate_proof spends a +# message_id slot and proves in-module (the secret never crosses the wire); +# verify_proof serves from the local window only — it is expected to answer +# not_ready until the warm-up read lands, so poll that away first. +section "rate-limit proofs" +say "start(registries=[$REGISTRY_ID]) to warm the root window" +# epoch_size: verify_proof binds proofs to the current epoch (±1), and the +# window warm-up polling below can span tens of seconds — a 1s default epoch +# would expire the proof before verification. The target sizes it to its own +# confirmation speed. +START=$(node_call "$NODE" liblogos_rln_module start \ + "{\"epoch_size_sec\":$E2E_EPOCH_SIZE_SEC,\"registries\":[\"$REGISTRY_ID\"]}" | jres | jval) || START="" +case "$START" in + *'"started":true'*) ;; + *) die "start failed: ${START:-}" ;; +esac + +SIGNAL_HEX=$(printf 'logos e2e signal' | to_hex) +say "generate_proof over the registered membership" +# timestamp: the consumer's Unix-seconds clock — the module derives the proof's +# epoch from it (not its own clock). `date +%s` == now, so the epoch lands in +# the start()'d window. +# str: forces a literal string — a bare or @file numeric arg is coerced to a +# JSON number by the CLI, which the tstr dispatch then reads as "". +PROOF_JSON=$(node_call "$NODE" liblogos_rln_module generate_proof \ + "$REGISTRY_ID" "$(argfile rlnid2 "$RLN_ID")" "$(argfile sig "$SIGNAL_HEX")" "str:$(date +%s)" | jres | jval) || PROOF_JSON="" +case "$PROOF_JSON" in + *'"proof"'*'"nullifier"'*|*'"nullifier"'*'"proof"'*) ;; + *) die "generate_proof failed: ${PROOF_JSON:-}" ;; +esac +MESSAGE_ID=$(printf '%s' "$PROOF_JSON" | jfield message_id) +say "proof issued (message_id ${MESSAGE_ID:-?}, epoch $(printf '%s' "$PROOF_JSON" | jfield epoch))" + +# The quota snapshot (logos-delivery's QuotaProvider shape): numeric +# epoch_index + rate_limit + remaining, decremented by the proof above — +# asserted strictly only when the epoch didn't roll in between. +QUOTA=$(node_call "$NODE" liblogos_rln_module get_epoch_quota \ + "$REGISTRY_ID" "$(argfile rlnid5 "$RLN_ID")" | jres | jval) || QUOTA="" +case "$QUOTA" in + *'"epoch_index"'*'"remaining"'*) ;; + *) die "get_epoch_quota failed: ${QUOTA:-}" ;; +esac +REMAINING=$(printf '%s' "$QUOTA" | jfield remaining) +Q_EPOCH=$(printf '%s' "$QUOTA" | jfield epoch_index) +PROOF_EPOCH=$(printf '%s' "$PROOF_JSON" | jfield epoch) +if [ "$Q_EPOCH" = "$PROOF_EPOCH" ]; then + [ "$REMAINING" = "$((RATE_LIMIT - 1))" ] \ + || die "quota remaining $REMAINING != $((RATE_LIMIT - 1)) after one proof" + say "epoch quota: remaining $REMAINING/$RATE_LIMIT in epoch $Q_EPOCH" +else + say "epoch rolled between proof and quota (proof $PROOF_EPOCH, quota $Q_EPOCH) — remaining $REMAINING" +fi + +say "verify_proof from the local root window (polling not_ready away)…" +VALID="" +for _t in $(seq 1 "$(polls "$E2E_ROOT_WINDOW_TIMEOUT_S" "$E2E_POLL_INTERVAL_S")"); do + VERIFY=$(node_call "$NODE" liblogos_rln_module verify_proof \ + "$REGISTRY_ID" "$(argfile rlnid3 "$RLN_ID")" "$(argfile sig2 "$SIGNAL_HEX")" \ + "$(argfile proof "$PROOF_JSON")" | jres | jval) || VERIFY="" + case "$VERIFY" in + *'"verdict":"valid"'*) VALID=yes; break ;; + *'"verdict":"invalid"'*) die "verify_proof rejected our own fresh proof: $VERIFY" ;; + *'not_ready'*) say " root window still cold ($_t)"; sleep "$E2E_POLL_INTERVAL_S" ;; + *) die "verify_proof failed: ${VERIFY:-}" ;; + esac +done +[ "$VALID" = "yes" ] || die "verify_proof never left not_ready (root window warm-up)" +say "verify_proof: valid" + +# A different signal against the same proof MUST be invalid — not an error. +TAMPER_HEX=$(printf 'tampered signal' | to_hex) +TVERIFY=$(node_call "$NODE" liblogos_rln_module verify_proof \ + "$REGISTRY_ID" "$(argfile rlnid4 "$RLN_ID")" "$(argfile sig3 "$TAMPER_HEX")" \ + "$(argfile proof2 "$PROOF_JSON")" | jres | jval) || TVERIFY="" +case "$TVERIFY" in + *'"verdict":"invalid"'*) say "tampered signal correctly invalid" ;; + *) die "tampered signal was not rejected: ${TVERIFY:-}" ;; +esac + +echo +echo "e2e: PASS — registered on $REGISTRY_ID" +echo "e2e: membership_hash $MEMBERSHIP_HASH" +echo "e2e: leaf_index $LEAF" +echo "e2e: funded by $HOLDING (faucet claim, no gifter)" diff --git a/scenarios/register/scenario.env b/scenarios/register/scenario.env index 9076665..68ff2f6 100644 --- a/scenarios/register/scenario.env +++ b/scenarios/register/scenario.env @@ -1,6 +1,6 @@ # register — the module-stack e2e: faucet claim, RLN membership registration, # merkle proof + registry cross-check, proof generation/verification (valid + -# tampered). Ported from logos-rln-modules in W1-C. +# tampered). Needs a faucet-funded deployment (E2E_FUNDING=faucet). NODES=1 NEEDS_MODULES="logos_execution_zone liblogos_lez_rln_module liblogos_rln_module" TARGETS="local testnet"