harness W1: lib primitives, target providers, register scenario

- lib/{json,lgx,daemon,wallet,chain}.sh: the proven helpers from the
  modules-repo e2e script and the mix orchestrator, re-expressed over the
  node_call seam (host daemons now, containers later). daemon env keeps the
  env -i isolation; die tails the failing node's log.
- artifacts.sh: env override -> nix build from the flake pins; assembles
  E2E_MODULES_DIR; pin-consistency gate (rln-modules' rln-layouts rev must
  equal the locked lez-rln rev — decode skew otherwise reads as chain bugs).
- targets/local.sh: dev.sh lifecycle (process-group kill, RPC readiness,
  900s first-boot budget) or E2E_DEVNET=external attach; fresh faucet
  provisioning via the checkout's tools, verify.sh on every path, staged
  fixture -> contract env, per-stage timings. targets/testnet.sh: committed
  descriptor + liveness assert, no checkout needed (store-path stage.sh).
- scenarios/register: the full membership lifecycle (claim -> register ->
  active -> merkle proof + registry cross-check -> generate/verify proof +
  tampered-signal invalid), all budgets from the contract, registry id
  logos:${E2E_TARGET}:<config-hex>. R2/R4 diagnostics kept.
- harness/selftest.sh: no-chain gate — artifacts, lgx install, daemon boot,
  module load, JSON plumbing. PASSES on darwin-arm64.
- devShell + coreutils (timeout); contract.md gains the target-input table.
This commit is contained in:
Arseniy Klempner
2026-08-10 19:46:16 -06:00
parent 5cfdeac577
commit 33383063de
15 changed files with 1349 additions and 76 deletions
+52
View File
@@ -0,0 +1,52 @@
# Committed deployments
Each `deployments/<name>/` 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/<name>/
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=<name>` — 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 <logos-lez-rln>
bash tools/deployments/provision.sh --name <name> \
--sequencer https://testnet.lez.logos.co/ --funding faucet \
--outdir <this-repo>/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 <logos-lez-rln>/tools/deployments/verify.sh deployments/<name>` 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.
+13
View File
@@ -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/<id>/scenario.env` declares: `NODES` (daemon count),
+2
View File
@@ -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
];
};
}
+92 -7
View File
@@ -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=<dir>: 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)"; }
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))"
}
+95 -7
View File
@@ -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)"; }
. "$(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 <node> <id_commitment_hex> [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 <node>
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 <<EOF
CAUSE: the run's funding account ran out of RLNTOK mid-run (each
registration costs price_per_unit x rate_limit, read live from
get_registry_bounds).
FIX: claim a bigger budget — raise the scenario's rate limit budget
(E2E_RATE_LIMIT / the scenario's claim multiplier) and re-run; nothing
needs re-provisioning:
E2E_RATE_LIMIT=<smaller rate> ./run.sh ${E2E_SCENARIO:-<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 <<EOF
CAUSE: the RLN rate-limit pool is exhausted — this tree is effectively full.
FIX: run against a fresh tree. --target local provisions one per run:
./run.sh ${E2E_SCENARIO:-<scenario>} --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 <<EOF
CAUSE: unknown. Inspect the node log:
grep -iE 'register|balance|rate limit|payment|tree' $(node_log_path "$node")
EOF
fi
}
+137 -8
View File
@@ -1,11 +1,140 @@
# shellcheck shell=bash
# harness/lib/daemon.sh — logoscore daemon lifecycle + the node_call seam.
# W1-A ports daemon boot/poll/load-module from the modules-repo e2e script.
# node_call has the same signature whether the node is a host process (now) or
# a container (compose topology, P3) — scenarios never know the difference.
#
# node_call/node_logs have the same signature whether the node is a host
# process (now) or a container (compose topology, P3) — scenarios never know
# the difference. A node id is a name; its state lives in
# $E2E_RUN_DIR/nodes/<node>/{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)"; } # <node> <module> <method> [args…]
node_logs() { die "daemon.sh: not implemented yet (W1-A)"; }
. "$(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 <node>
daemon_start() {
local node="${1:?daemon_start <node>}" 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 </dev/null >>"$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 <node> <module>…
daemon_load_modules() {
local node="${1:?daemon_load_modules <node> <module>…}"; 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 <node> <module> <method> [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 <node> [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:-<none>}"
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
}
+112 -8
View File
@@ -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)"; }
# 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)
'; }
+74 -5
View File
@@ -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 <dest>/<name>/{manifest.json,<variant files>,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)"; }
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 <flake-attr> <src-tree> [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 <bundle.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"
}
+88 -8
View File
@@ -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)"; }
. "$(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 <node> [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 <node>
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 <node>
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 <account> holds at least <want> RLNTOK (credit lands async).
# Prints the last seen balance; 1 when the budget runs out.
# Usage: wait_balance <node> <account> <want>
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
}
+74
View File
@@ -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:-<empty>}"
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:-<none>}' raw=${RAW:-<empty>}"
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
+203 -18
View File
@@ -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=<dir> logos-lez-rln checkout carrying dev.sh and
# tools/deployments (default ../logos-lez-rln).
# E2E_SEQUENCER=<url> endpoint (default http://127.0.0.1:3040/).
# E2E_DEPLOYMENT_DIR=<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() { :; }
_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 <url> <budget_s> — 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) </dev/null >>"$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:-<none>}, wallet home ${E2E_WALLET_HOME:-<none>}"
say "E2E_KEEP=1: reattach with E2E_DEVNET=external E2E_DEPLOYMENT_DIR=${E2E_DEPLOYMENT_DIR:-<none>}"
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=""
}
+116 -8
View File
@@ -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/<name> (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/<name> (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=<name> descriptor dir under deployments/ — required, no
# default: a testnet run always names the tree it
# spends against.
# LEZ_RLN_CHECKOUT=<dir> 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() { :; }
_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=<name> (deployments/: ${available:-<none committed>})"
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:-<none committed>})"
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() { :; }
+2
View File
@@ -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
+288 -6
View File
@@ -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}:<config-account-hex>.
# 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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<none>}"
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:-<none>})"
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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<empty>}" ;;
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:-<empty>}" ;;
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)"
+1 -1
View File
@@ -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"