From 0e543c743a3238fb6759035491e9bd8037e82b05 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 24 Apr 2026 15:51:43 +0800 Subject: [PATCH] Harden release smoke flows --- .gitignore | 2 + flows/flow-01-prerequisites.sh | 20 +- flows/flow-08-buy.sh | 9 +- flows/flow-11-dual-stack.sh | 683 ++++++++++++++++++++++++++++----- flows/lib.sh | 37 ++ flows/release-smoke.sh | 159 ++++++++ 6 files changed, 813 insertions(+), 97 deletions(-) create mode 100755 flows/release-smoke.sh diff --git a/.gitignore b/.gitignore index 300b491e3..eb04143ab 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,10 @@ charts/*/Chart.lock logs/ dist/ build/ +.build/ .cache/ .workspace/ +.workspace-*/ # Go binary /obol diff --git a/flows/flow-01-prerequisites.sh b/flows/flow-01-prerequisites.sh index b3b84d0a2..95b5c06e6 100755 --- a/flows/flow-01-prerequisites.sh +++ b/flows/flow-01-prerequisites.sh @@ -43,10 +43,26 @@ fi # Python packages required for paid inference (flow-08) step "Python eth_account + httpx installed" -if python3 -c "import eth_account, httpx" 2>/dev/null; then +if ensure_payment_python_deps; then pass "eth_account + httpx available" else - fail "Missing Python packages — run: pip install eth-account httpx" + fail "Missing Python packages and automatic venv setup failed — install eth-account httpx" +fi + +# The default OpenClaw deployment depends on the published remote-signer chart. +step "remote-signer Helm chart version is published" +rs_version=$(remote_signer_chart_version) +if [ -z "$rs_version" ]; then + fail "Could not parse remoteSignerChartVersion from internal/openclaw/openclaw.go" +elif remote_signer_chart_available "$rs_version"; then + pass "obol/remote-signer $rs_version is available" +else + helm repo update obol >/dev/null 2>&1 || true + if remote_signer_chart_available "$rs_version"; then + pass "obol/remote-signer $rs_version is available after helm repo update" + else + fail "obol/remote-signer $rs_version is not published in the configured Helm repo" + fi fi emit_metrics diff --git a/flows/flow-08-buy.sh b/flows/flow-08-buy.sh index 9d4b198e1..2c007da22 100755 --- a/flows/flow-08-buy.sh +++ b/flows/flow-08-buy.sh @@ -83,10 +83,11 @@ if command -v cast &>/dev/null; then fi # §2.3: Paid inference — sign EIP-712 ERC-3009 payment and retry -# Uses eth_account (installed with: pip install eth-account) to sign -# the TransferWithAuthorization payload, matching internal/testutil/eip712_signer.go +# Uses eth_account to sign the TransferWithAuthorization payload, matching +# internal/testutil/eip712_signer.go. If host Python lacks the dependency, +# lib.sh creates an isolated .workspace/venv and puts it on PATH. step "Paid inference via x402 payment signing" -if python3 -c "import eth_account, httpx" 2>/dev/null; then +if ensure_payment_python_deps; then paid_out=$(python3 << 'PYEOF' 2>&1 import sys, os, json, base64, secrets, time import httpx @@ -202,7 +203,7 @@ PYEOF fail "Paid inference failed — ${paid_out:0:400}" fi else - fail "eth_account/httpx not installed — run: pip install eth-account httpx" + fail "eth_account/httpx unavailable and automatic venv setup failed" fi # §2.4: Balance checks (requires cast/Foundry) diff --git a/flows/flow-11-dual-stack.sh b/flows/flow-11-dual-stack.sh index 90b903b55..d4381a80e 100755 --- a/flows/flow-11-dual-stack.sh +++ b/flows/flow-11-dual-stack.sh @@ -55,6 +55,11 @@ BOB_HTTP_ALT_PORT="${FLOW11_BOB_HTTP_ALT_PORT:-$(pick_free_port)}" BOB_HTTPS_PORT="${FLOW11_BOB_HTTPS_PORT:-$(pick_free_port)}" BOB_HTTPS_ALT_PORT="${FLOW11_BOB_HTTPS_ALT_PORT:-$(pick_free_port)}" FACILITATOR_URL="${FLOW11_FACILITATOR_URL:-https://x402.gcp.obol.tech}" +FLOW11_ARTIFACT_DIR="${FLOW11_ARTIFACT_DIR:-$OBOL_ROOT/.tmp/flow-11-$(date +%Y%m%d-%H%M%S)}" +BASE_SEPOLIA_RPC="${FLOW11_BASE_SEPOLIA_RPC:-https://sepolia.base.org}" +USDC_ADDRESS_BASE_SEPOLIA="0x036CbD53842c5426634e7929541eC2318f3dCF7e" +ERC8004_IDENTITY_REGISTRY_BASE_SEPOLIA="0x8004A818BFB912233c491871b3d84c89A494BD9e" +mkdir -p "$FLOW11_ARTIFACT_DIR" rewrite_k3d_ports() { local config_path="$1" @@ -93,6 +98,96 @@ except Exception: PY } +tunnel_hostname() { + python3 - "$1" <<'PY' +from urllib.parse import urlparse +import sys + +print(urlparse(sys.argv[1]).hostname or "") +PY +} + +system_resolves_host() { + python3 - "$1" <<'PY' +import socket +import sys + +try: + socket.getaddrinfo(sys.argv[1], 443) +except OSError: + sys.exit(1) +PY +} + +resolve_public_ipv4() { + dig +short A "$1" 2>/dev/null | grep -E '^[0-9]+(\.[0-9]+){3}$' | head -1 +} + +curl_tunnel_402_code() { + local url="$1" + local host="$2" + local ip="$3" + + if [ -n "$host" ] && [ -n "$ip" ] && ! system_resolves_host "$host"; then + curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ + --resolve "$host:443:$ip" \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' 2>/dev/null || true + else + curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' 2>/dev/null || true + fi +} + +ensure_bob_tunnel_dns() { + local host="$1" + local ip="$2" + local nodehosts patch_file + + [ -n "$host" ] || return 0 + if [ -z "$ip" ]; then + ip=$(resolve_public_ipv4 "$host" || true) + fi + if [ -z "$ip" ]; then + fail "Could not resolve public IPv4 for tunnel host $host" + return 0 + fi + + step "Bob: tunnel DNS override" + nodehosts=$(bob kubectl get configmap coredns -n kube-system -o jsonpath='{.data.NodeHosts}' 2>/dev/null || true) + if [ -z "$nodehosts" ]; then + fail "Could not read Bob CoreDNS NodeHosts" + return 0 + fi + if echo "$nodehosts" | grep -Fq "$host"; then + pass "Bob CoreDNS NodeHosts already maps $host" + return 0 + fi + + patch_file=$(mktemp) + FLOW11_NODEHOSTS="$nodehosts" FLOW11_TUNNEL_HOST="$host" FLOW11_TUNNEL_IP="$ip" python3 - <<'PY' > "$patch_file" +import json +import os + +nodehosts = os.environ["FLOW11_NODEHOSTS"].rstrip() +host = os.environ["FLOW11_TUNNEL_HOST"] +ip = os.environ["FLOW11_TUNNEL_IP"] +nodehosts = f"{nodehosts}\n{ip} {host}\n" +print(json.dumps({"data": {"NodeHosts": nodehosts}})) +PY + if bob kubectl patch configmap coredns -n kube-system --type merge --patch-file "$patch_file" >/dev/null 2>&1; then + bob kubectl rollout restart deployment/coredns -n kube-system >/dev/null 2>&1 || true + bob kubectl rollout status deployment/coredns -n kube-system --timeout=60s >/dev/null 2>&1 || true + pass "Bob CoreDNS NodeHosts maps $host -> $ip" + else + fail "Could not patch Bob CoreDNS for $host" + fi + rm -f "$patch_file" +} + # Helper to run obol as Alice or Bob alice() { OBOL_DEVELOPMENT=true \ @@ -129,6 +224,36 @@ except Exception as e: " 2>&1 || true } +bob_tunnel_402_code() { + bob kubectl exec -n openclaw-obol-agent deploy/openclaw -c openclaw -- \ + python3 -c " +import json +import urllib.error +import urllib.request + +req = urllib.request.Request('$TUNNEL_URL/services/alice-inference/v1/chat/completions', + data=json.dumps({ + 'model': 'qwen3.5:9b', + 'messages': [{'role': 'user', 'content': 'hi'}], + 'max_tokens': 5 + }).encode(), + headers={'Content-Type': 'application/json'}) +try: + resp = urllib.request.urlopen(req, timeout=20) + print(resp.status) +except urllib.error.HTTPError as e: + print(e.code) +except Exception as e: + print('ERR: %s' % e) +" 2>/dev/null || true +} + +bob_buy_skill_balance() { + bob kubectl exec \ + -n openclaw-obol-agent deploy/openclaw -c openclaw -- \ + python3 /data/.openclaw/skills/buy-inference/scripts/buy.py balance 2>&1 || true +} + run_tail_or_fail() { local desc="$1" local success="$2" @@ -153,6 +278,74 @@ run_tail_or_fail() { pass "$success" } +refresh_alice_ports() { + ALICE_HTTP_PORT="${FLOW11_ALICE_HTTP_PORT:-$(pick_free_port)}" + ALICE_HTTP_ALT_PORT="${FLOW11_ALICE_HTTP_ALT_PORT:-$(pick_free_port)}" + ALICE_HTTPS_PORT="${FLOW11_ALICE_HTTPS_PORT:-$(pick_free_port)}" + ALICE_HTTPS_ALT_PORT="${FLOW11_ALICE_HTTPS_ALT_PORT:-$(pick_free_port)}" +} + +refresh_bob_ports() { + BOB_HTTP_PORT="${FLOW11_BOB_HTTP_PORT:-$(pick_free_port)}" + BOB_HTTP_ALT_PORT="${FLOW11_BOB_HTTP_ALT_PORT:-$(pick_free_port)}" + BOB_HTTPS_PORT="${FLOW11_BOB_HTTPS_PORT:-$(pick_free_port)}" + BOB_HTTPS_ALT_PORT="${FLOW11_BOB_HTTPS_ALT_PORT:-$(pick_free_port)}" +} + +stack_init_and_up_with_retry() { + local label="$1" + local runner="$2" + local dir="$3" + local attempt out rc + + for attempt in 1 2 3; do + step "$label: stack init" + "$runner" stack init --force 2>&1 | tail -1 + if [ "$label" = "Alice" ]; then + rewrite_k3d_ports "$dir/config/k3d.yaml" \ + "$ALICE_HTTP_PORT" "$ALICE_HTTP_ALT_PORT" "$ALICE_HTTPS_PORT" "$ALICE_HTTPS_ALT_PORT" + pass "Alice ports set to $ALICE_HTTP_PORT/$ALICE_HTTP_ALT_PORT/$ALICE_HTTPS_PORT/$ALICE_HTTPS_ALT_PORT" + else + rewrite_k3d_ports "$dir/config/k3d.yaml" \ + "$BOB_HTTP_PORT" "$BOB_HTTP_ALT_PORT" "$BOB_HTTPS_PORT" "$BOB_HTTPS_ALT_PORT" + pass "Bob ports set to $BOB_HTTP_PORT/$BOB_HTTP_ALT_PORT/$BOB_HTTPS_PORT/$BOB_HTTPS_ALT_PORT" + fi + + step "$label: stack up" + set +e + out=$("$runner" stack up 2>&1) + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + printf '%s\n' "$out" | tail -3 + pass "$label stack up completed" + return 0 + fi + + printf '%s\n' "$out" | tail -120 + if [ "$attempt" -lt 3 ] && echo "$out" | grep -qiE "address already in use|failed to bind host port"; then + echo " $label stack up hit a host port bind race; retrying with fresh ports (attempt $((attempt + 1))/3)" + "$runner" stack down >/dev/null 2>&1 || true + if [ "$label" = "Alice" ]; then + refresh_alice_ports + else + refresh_bob_ports + fi + continue + fi + if [ "$attempt" -lt 3 ] && echo "$out" | grep -qiE "context deadline exceeded|Client.Timeout|cannot be reached|failed to import images"; then + echo " $label stack up hit a transient image/Helm repository error; retrying (attempt $((attempt + 1))/3)" + "$runner" stack down >/dev/null 2>&1 || true + sleep 10 + continue + fi + + fail "$label: stack up failed (exit $rc)" + emit_metrics + exit "$rc" + done +} + litellm_paid_inference() { bob kubectl exec -n llm deployment/litellm -c litellm -- \ python3 -c " @@ -176,9 +369,159 @@ try: print('CONTENT=%s' % content[:300]) except urllib.error.HTTPError as e: print('ERROR=%d %s' % (e.code, e.read().decode()[:300])) +except Exception as e: + print('ERROR=%s' % repr(e)) " 2>&1 || true } +write_receipt() { + local name="$1" + local tx="$2" + [ -n "$tx" ] || return 0 + env -u CHAIN cast receipt --json "$tx" --rpc-url "$BASE_SEPOLIA_RPC" \ + > "$FLOW11_ARTIFACT_DIR/${name}-receipt.json" 2>/dev/null || true +} + +receipt_status_ok() { + local tx="$1" + [ -n "$tx" ] || return 1 + env -u CHAIN cast receipt --json "$tx" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | \ + python3 -c 'import json,sys +try: + d=json.load(sys.stdin) +except Exception: + sys.exit(1) +sys.exit(0 if d.get("status") in ("0x1", 1, "1") else 1)' 2>/dev/null +} + +archive_receipt() { + local name="$1" + local tx="$2" + local attempts="${3:-12}" + local interval="${4:-2}" + local receipt_file="$FLOW11_ARTIFACT_DIR/${name}-receipt.json" + + [ -n "$tx" ] || return 1 + for _ in $(seq 1 "$attempts"); do + if env -u CHAIN cast receipt --json "$tx" --rpc-url "$BASE_SEPOLIA_RPC" \ + > "$receipt_file.tmp" 2>/dev/null && \ + python3 - "$receipt_file.tmp" <<'PY' 2>/dev/null +import json +import sys + +try: + data = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +sys.exit(0 if data.get("status") in ("0x1", 1, "1") else 1) +PY + then + mv "$receipt_file.tmp" "$receipt_file" + return 0 + fi + rm -f "$receipt_file.tmp" + sleep "$interval" + done + rm -f "$receipt_file.tmp" + return 1 +} + +extract_tx_hash() { + python3 - <<'PY' +import re +import sys + +text = sys.stdin.read() +for line in text.splitlines(): + if "transactionHash" not in line: + continue + match = re.search(r"transactionHash[^\n]*?(0x[0-9a-fA-F]{64})", line) + if match: + print(match.group(1)) + sys.exit(0) +sys.exit(1) +PY +} + +find_usdc_transfer() { + local from_addr="$1" + local to_addr="$2" + local amount="$3" + local from_block="$4" + local logs + + logs=$(env -u CHAIN cast logs --json --rpc-url "$BASE_SEPOLIA_RPC" \ + --address "$USDC_ADDRESS_BASE_SEPOLIA" \ + --from-block "$from_block" --to-block latest \ + "Transfer(address,address,uint256)" 2>/dev/null || true) + FLOW11_TRANSFER_LOGS="$logs" \ + FLOW11_TRANSFER_FROM="$from_addr" \ + FLOW11_TRANSFER_TO="$to_addr" \ + FLOW11_TRANSFER_AMOUNT="$amount" \ + python3 - <<'PY' +import json +import os +import sys + +try: + logs = json.loads(os.environ.get("FLOW11_TRANSFER_LOGS") or "[]") +except Exception: + sys.exit(1) + +src_expected = os.environ["FLOW11_TRANSFER_FROM"].lower().replace("0x", "") +dst_expected = os.environ["FLOW11_TRANSFER_TO"].lower().replace("0x", "") +amount_expected = int(os.environ["FLOW11_TRANSFER_AMOUNT"]) +matches = [] + +for log in logs: + topics = log.get("topics", []) + if len(topics) < 3: + continue + src = topics[1][-40:].lower() + dst = topics[2][-40:].lower() + if src != src_expected or dst != dst_expected: + continue + try: + amount = int(log.get("data", "0x0"), 16) + except ValueError: + continue + if amount != amount_expected: + continue + tx = log.get("transactionHash", "") + if tx: + matches.append((int(log.get("blockNumber", "0x0"), 16), int(log.get("logIndex", "0x0"), 16), tx, amount)) + +if not matches: + sys.exit(1) + +_, _, tx, amount = sorted(matches)[-1] +print(f"{tx} {amount}") +PY +} + +wait_usdc_transfer_receipt() { + local name="$1" + local from_addr="$2" + local to_addr="$3" + local amount="$4" + local from_block="$5" + local attempts="${6:-30}" + local interval="${7:-2}" + local match tx actual_amount + + for _ in $(seq 1 "$attempts"); do + match=$(find_usdc_transfer "$from_addr" "$to_addr" "$amount" "$from_block" 2>/dev/null || true) + tx=$(echo "$match" | awk '{print $1; exit}') + actual_amount=$(echo "$match" | awk '{print $2; exit}') + if [ -n "$tx" ] && [ "$actual_amount" = "$amount" ] && archive_receipt "$name" "$tx" 1 0; then + echo "$tx $actual_amount" + return 0 + fi + sleep "$interval" + done + return 1 +} + step "Preflight: .env key" SIGNER_KEY=$(grep REMOTE_SIGNER_PRIVATE_KEY "$OBOL_ROOT/.env" 2>/dev/null | cut -d= -f2) if [ -z "$SIGNER_KEY" ]; then @@ -193,8 +536,12 @@ ALICE_WALLET=$(env -u CHAIN cast wallet address --private-key "$SIGNER_KEY" 2>/d pass "Alice=$ALICE_WALLET, Bob=$BOB_WALLET" step "Preflight: wallets are EOAs" -alice_code=$(env -u CHAIN cast code "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null) -bob_code=$(env -u CHAIN cast code "$BOB_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null) +alice_code=$(env -u CHAIN cast code "$ALICE_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null || true) +bob_code=$(env -u CHAIN cast code "$BOB_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null || true) +if [ -z "$alice_code" ] || [ -z "$bob_code" ]; then + fail "Could not read wallet code from Base Sepolia RPC" + emit_metrics; exit 1 +fi if [ "$alice_code" != "0x" ] || [ "$bob_code" != "0x" ]; then fail "Wallet has contract code (EIP-7702?) — Alice=$alice_code Bob=$bob_code" emit_metrics; exit 1 @@ -202,9 +549,9 @@ fi pass "Both wallets are regular EOAs" step "Preflight: Bob has USDC" -bob_usdc_raw=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - "balanceOf(address)(uint256)" "$BOB_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null) -bob_usdc=$(echo "$bob_usdc_raw" | grep -oE '^[0-9]+' | head -1) +bob_usdc_raw=$(env -u CHAIN cast call "$USDC_ADDRESS_BASE_SEPOLIA" \ + "balanceOf(address)(uint256)" "$BOB_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null || true) +bob_usdc=$(echo "$bob_usdc_raw" | grep -oE '^[0-9]+' | head -1 || true) if [ -z "$bob_usdc" ] || [ "$bob_usdc" = "0" ]; then fail "Bob ($BOB_WALLET) has 0 USDC on Base Sepolia — fund first" emit_metrics; exit 1 @@ -212,7 +559,16 @@ fi pass "Bob has $bob_usdc micro-USDC" step "Preflight: Alice has ETH for registration gas" -alice_eth=$(env -u CHAIN cast balance "$ALICE_WALLET" --rpc-url https://sepolia.base.org --ether 2>/dev/null | grep -oE '^[0-9.]+' | head -1) +alice_eth="" +for _ in $(seq 1 5); do + alice_eth=$(env -u CHAIN cast balance "$ALICE_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" --ether 2>/dev/null | grep -oE '^[0-9.]+' | head -1 || true) + [ -n "$alice_eth" ] && break + sleep 2 +done +if [ -z "$alice_eth" ]; then + fail "Could not read Alice ETH balance from Base Sepolia RPC" + emit_metrics; exit 1 +fi pass "Alice has $alice_eth ETH" step "Preflight: clean stale ethereum network deployments" @@ -270,8 +626,12 @@ busy_ports=$(require_ports_free \ pass "Ports: Alice=$ALICE_HTTP_PORT/$ALICE_HTTP_ALT_PORT/$ALICE_HTTPS_PORT/$ALICE_HTTPS_ALT_PORT Bob=$BOB_HTTP_PORT/$BOB_HTTP_ALT_PORT/$BOB_HTTPS_PORT/$BOB_HTTPS_ALT_PORT" # Record pre-test balances (strip cast's scientific notation suffix) -PRE_ALICE_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) +PRE_ALICE_USDC=$(env -u CHAIN cast call "$USDC_ADDRESS_BASE_SEPOLIA" \ + "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | grep -oE '^[0-9]+' | head -1 || true) +if [ -z "$PRE_ALICE_USDC" ]; then + fail "Could not read Alice starting USDC balance" + emit_metrics; exit 1 +fi PRE_BOB_USDC=$bob_usdc # ═════════════════════════════════════════════════════════════════ @@ -293,13 +653,7 @@ for tool in kubectl helm helmfile k3d k9s openclaw; do done pass "Alice workspace ready" -step "Alice: stack init" -alice stack init 2>&1 | tail -1 -rewrite_k3d_ports "$ALICE_DIR/config/k3d.yaml" \ - "$ALICE_HTTP_PORT" "$ALICE_HTTP_ALT_PORT" "$ALICE_HTTPS_PORT" "$ALICE_HTTPS_ALT_PORT" -pass "Alice ports set to $ALICE_HTTP_PORT/$ALICE_HTTP_ALT_PORT/$ALICE_HTTPS_PORT/$ALICE_HTTPS_ALT_PORT" - -run_tail_or_fail "Alice: stack up" "Alice stack up completed" 3 alice stack up +stack_init_and_up_with_retry "Alice" alice "$ALICE_DIR" poll_step_grep "Alice: x402 pods running" "Running" 30 10 \ alice kubectl get pods -n x402 --no-headers @@ -324,16 +678,21 @@ else fi step "Alice: add Base Sepolia RPC to eRPC (for registration + metadata sync)" -alice network add base-sepolia --endpoint https://sepolia.base.org --allow-writes 2>&1 | tail -2 +alice network add base-sepolia --endpoint "$BASE_SEPOLIA_RPC" --allow-writes 2>&1 | tail -2 # eRPC needs a restart to pick up the new chain config alice kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true alice kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true pass "Base Sepolia RPC added to eRPC (with write access)" step "Alice: create ServiceOffer" +REG_START_BLOCK=$(env -u CHAIN cast block-number --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | tr -d ' ' || true) +if [ -z "$REG_START_BLOCK" ]; then + fail "Could not read Base Sepolia block number before registration" + emit_metrics; exit 1 +fi KEY_FILE=$(mktemp) echo "$SIGNER_KEY" > "$KEY_FILE" -alice sell http alice-inference \ +sell_http_out=$(alice sell http alice-inference \ --wallet "$ALICE_WALLET" \ --chain base-sepolia \ --per-request 0.001 \ @@ -345,7 +704,8 @@ alice sell http alice-inference \ --register-description "Integration test: local model inference via x402" \ --register-skills natural_language_processing/text_generation \ --register-domains technology/artificial_intelligence \ - --private-key-file "$KEY_FILE" 2>&1 | tail -8 + --private-key-file "$KEY_FILE" 2>&1) +printf '%s\n' "$sell_http_out" | tail -8 rm -f "$KEY_FILE" pass "ServiceOffer created" @@ -354,18 +714,28 @@ poll_step_grep "Alice: ServiceOffer Ready" "True" 24 5 \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' step "Alice: tunnel URL" -TUNNEL_URL=$(alice tunnel status 2>&1 | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | head -1) +TUNNEL_URL=$(alice tunnel status 2>&1 | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | head -1 || true) if [ -z "$TUNNEL_URL" ]; then fail "No tunnel URL" emit_metrics; exit 1 fi +TUNNEL_HOST=$(tunnel_hostname "$TUNNEL_URL") +TUNNEL_IP=$(resolve_public_ipv4 "$TUNNEL_HOST" || true) pass "Tunnel: $TUNNEL_URL" -poll_step_grep "Alice: 402 gate works" "402" 12 5 \ - curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST \ - "$TUNNEL_URL/services/alice-inference/v1/chat/completions" \ - -H "Content-Type: application/json" \ - -d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' +step "Alice: 402 gate works" +gate_code="" +for attempt in $(seq 1 24); do + gate_code=$(curl_tunnel_402_code "$TUNNEL_URL/services/alice-inference/v1/chat/completions" "$TUNNEL_HOST" "$TUNNEL_IP") + if [ "$gate_code" = "402" ]; then + pass "Alice: 402 gate works (attempt $attempt)" + break + fi + sleep 5 +done +if [ "$gate_code" != "402" ]; then + fail "Alice: 402 gate returned ${gate_code:-no HTTP response} after 120s" +fi step "Alice: ERC-8004 registration reflected in ServiceOffer" reg_out=$(alice sell status alice-inference -n llm 2>&1) || true echo "$reg_out" | tail -12 @@ -376,6 +746,56 @@ else fail "Registration not reflected in sell status: ${reg_out:0:200}" fi +registry_logs=$(env -u CHAIN cast logs --json --rpc-url "$BASE_SEPOLIA_RPC" \ + --address "$ERC8004_IDENTITY_REGISTRY_BASE_SEPOLIA" \ + --from-block "$REG_START_BLOCK" --to-block latest 2>/dev/null || true) +registry_txs=$(FLOW11_REGISTRY_LOGS="$registry_logs" FLOW11_AGENT_ID="$AGENT_ID" python3 - <<'PY' +import json +import os + +logs = json.loads(os.environ.get("FLOW11_REGISTRY_LOGS") or "[]") +agent_id = int(os.environ["FLOW11_AGENT_ID"]) +registration = "" +metadata = "" +transfer_sig = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +for log in logs: + topics = [t.lower() for t in log.get("topics", [])] + tx = log.get("transactionHash", "") + if not tx: + continue + topic_values = [] + for topic in topics[1:]: + try: + topic_values.append(int(topic, 16)) + except ValueError: + pass + if agent_id not in topic_values: + continue + if topics and topics[0] == transfer_sig and len(topics) >= 4 and int(topics[3], 16) == agent_id: + registration = registration or tx + elif tx != registration: + metadata = metadata or tx + +if registration: + print(f"registration={registration}") +if metadata: + print(f"metadata={metadata}") +PY +) +REGISTRATION_TX=$(echo "$registry_txs" | awk -F= '$1=="registration" {print $2; exit}') +METADATA_TX=$(echo "$registry_txs" | awk -F= '$1=="metadata" {print $2; exit}') +if [ -n "$REGISTRATION_TX" ] && receipt_status_ok "$REGISTRATION_TX"; then + write_receipt registration "$REGISTRATION_TX" + pass "Registration receipt archived: $REGISTRATION_TX" +else + fail "Could not archive registration receipt for Agent ID $AGENT_ID" +fi +if [ -n "$METADATA_TX" ] && receipt_status_ok "$METADATA_TX"; then + write_receipt metadata "$METADATA_TX" + pass "Metadata receipt archived: $METADATA_TX" +fi + # ═════════════════════════════════════════════════════════════════ # BOOTSTRAP BOB (buyer, configurable ports) # ═════════════════════════════════════════════════════════════════ @@ -390,27 +810,37 @@ for tool in kubectl helm helmfile k3d k9s openclaw; do done pass "Bob workspace ready" -step "Bob: stack init" -bob stack init 2>&1 | tail -1 -rewrite_k3d_ports "$BOB_DIR/config/k3d.yaml" \ - "$BOB_HTTP_PORT" "$BOB_HTTP_ALT_PORT" "$BOB_HTTPS_PORT" "$BOB_HTTPS_ALT_PORT" -pass "Bob ports set to $BOB_HTTP_PORT/$BOB_HTTP_ALT_PORT/$BOB_HTTPS_PORT/$BOB_HTTPS_ALT_PORT" - -run_tail_or_fail "Bob: stack up" "Bob stack up completed" 3 bob stack up +stack_init_and_up_with_retry "Bob" bob "$BOB_DIR" poll_step_grep "Bob: x402 pods running" "Running" 30 10 \ bob kubectl get pods -n x402 --no-headers step "Bob: add Base Sepolia RPC to eRPC" -bob network add base-sepolia --endpoint https://sepolia.base.org 2>&1 | tail -2 +bob network add base-sepolia --endpoint "$BASE_SEPOLIA_RPC" 2>&1 | tail -2 bob kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true bob kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true pass "Bob eRPC configured for Base Sepolia" +ensure_bob_tunnel_dns "$TUNNEL_HOST" "$TUNNEL_IP" + # Wait for Bob's OpenClaw agent to be ready poll_step_grep "Bob: OpenClaw agent ready" "Running" 24 5 \ bob kubectl get pods -n openclaw-obol-agent -l app.kubernetes.io/name=openclaw --no-headers +step "Bob: tunnel reachable from agent pod" +bob_tunnel_code="" +for attempt in $(seq 1 24); do + bob_tunnel_code=$(bob_tunnel_402_code) + if [ "$bob_tunnel_code" = "402" ]; then + pass "Bob: tunnel reachable from agent pod (attempt $attempt)" + break + fi + sleep 5 +done +if [ "$bob_tunnel_code" != "402" ]; then + fail "Bob: tunnel did not return 402 from agent pod — ${bob_tunnel_code:-no response}" +fi + # ═════════════════════════════════════════════════════════════════ # BOB: FUND REMOTE-SIGNER WALLET (shortcut — see #331 for obol wallet import) # ═════════════════════════════════════════════════════════════════ @@ -431,21 +861,38 @@ if [ -n "$BOB_SIGNER_ADDR" ]; then # Send USDC (0.05 USDC = 50000 micro-units) from .env key. # `cast send` (no --async) waits for inclusion; capture the receipt so we # can verify status=1 instead of relying on grep||true to mask failures. + FUNDING_START_BLOCK=$(env -u CHAIN cast block-number --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | tr -d ' ' || true) + if [ -z "$FUNDING_START_BLOCK" ]; then + fail "Could not read Base Sepolia block number before funding" + emit_metrics; exit 1 + fi send_out=$(env -u CHAIN cast send --private-key "$SIGNER_KEY" \ - 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "$USDC_ADDRESS_BASE_SEPOLIA" \ "transfer(address,uint256)" "$BOB_SIGNER_ADDR" 50000 \ - --rpc-url https://sepolia.base.org 2>&1 || true) + --rpc-url "$BASE_SEPOLIA_RPC" 2>&1 || true) if ! echo "$send_out" | grep -qE "^status\s+1 \(success\)"; then fail "Funding tx did not confirm status=1 — ${send_out:0:300}" emit_metrics; exit 1 fi + FUNDING_TX=$(echo "$send_out" | extract_tx_hash || true) + if [ -n "$FUNDING_TX" ] && archive_receipt funding "$FUNDING_TX"; then + pass "Funding receipt archived: $FUNDING_TX" + else + funding_match=$(wait_usdc_transfer_receipt funding "$ALICE_WALLET" "$BOB_SIGNER_ADDR" 50000 "$FUNDING_START_BLOCK" 30 2 || true) + FUNDING_TX=$(echo "$funding_match" | awk '{print $1; exit}') + if [ -n "$FUNDING_TX" ]; then + pass "Funding receipt archived from USDC Transfer log: $FUNDING_TX" + else + fail "Could not archive funding receipt" + fi + fi # Poll the direct Base Sepolia RPC until the balance reflects the transfer. # This avoids a race where step 32's agent sees 0 USDC because eRPC's # eth_call cache (10s TTL) still holds the pre-funding result. POST_FUND_BOB_SIGNER_USDC=0 for _ in $(seq 1 12); do - POST_FUND_BOB_SIGNER_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - "balanceOf(address)(uint256)" "$BOB_SIGNER_ADDR" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) + POST_FUND_BOB_SIGNER_USDC=$(env -u CHAIN cast call "$USDC_ADDRESS_BASE_SEPOLIA" \ + "balanceOf(address)(uint256)" "$BOB_SIGNER_ADDR" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | grep -oE '^[0-9]+' | head -1 || true) [ -n "$POST_FUND_BOB_SIGNER_USDC" ] && [ "$POST_FUND_BOB_SIGNER_USDC" -ge 50000 ] 2>/dev/null && break sleep 2 done @@ -453,15 +900,27 @@ if [ -n "$BOB_SIGNER_ADDR" ]; then fail "Bob's on-chain USDC did not reach 50000 — got ${POST_FUND_BOB_SIGNER_USDC:-0}" emit_metrics; exit 1 fi - POST_FUND_ALICE_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) + POST_FUND_ALICE_USDC=$(env -u CHAIN cast call "$USDC_ADDRESS_BASE_SEPOLIA" \ + "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | grep -oE '^[0-9]+' | head -1 || true) pass "Funded $BOB_SIGNER_ADDR with 0.05 USDC (on-chain: $POST_FUND_BOB_SIGNER_USDC)" # Wait for Bob's in-pod buy.py (via eRPC with 10s cache) to catch up so - # the AI agent in step 32 sees the funded balance, not a stale zero. - poll_step_grep "Bob: eRPC reflects funding" "0.05" 18 5 bob kubectl exec \ - -n openclaw-obol-agent deploy/openclaw -c openclaw -- \ - python3 /data/.openclaw/skills/buy-inference/scripts/buy.py balance + # the AI agent in step 32 sees a usable funded balance, not a stale zero. + step "Bob: eRPC reflects funding" + erpc_balance_output="" + erpc_balance_micro="" + for attempt in $(seq 1 18); do + erpc_balance_output=$(bob_buy_skill_balance) + erpc_balance_micro=$(echo "$erpc_balance_output" | sed -n 's/.*(\([0-9][0-9]*\) micro-units).*/\1/p' | head -1) + if [ -n "$erpc_balance_micro" ] && [ "$erpc_balance_micro" -ge 50000 ] 2>/dev/null; then + pass "Bob: eRPC reflects funding (attempt $attempt, balance ${erpc_balance_micro} micro-USDC)" + break + fi + sleep 5 + done + if [ -z "$erpc_balance_micro" ] || [ "$erpc_balance_micro" -lt 50000 ] 2>/dev/null; then + fail "Bob: eRPC balance did not reach 50000 micro-USDC — ${erpc_balance_output:0:200}" + fi else fail "Could not determine Bob's remote-signer address" emit_metrics; exit 1 @@ -472,7 +931,7 @@ fi # ═════════════════════════════════════════════════════════════════ step "Bob: get OpenClaw gateway token" -BOB_TOKEN=$(bob openclaw token obol-agent 2>/dev/null) +BOB_TOKEN=$(bob openclaw token obol-agent 2>/dev/null || true) if [ -z "$BOB_TOKEN" ]; then fail "Could not get Bob's gateway token" emit_metrics; exit 1 @@ -532,7 +991,7 @@ discover_response=$(curl -sf --max-time 300 \ }], \"max_tokens\": 4000, \"stream\": false - }" 2>&1) + }" 2>&1 || true) discover_content=$(extract_assistant_content "$discover_response" 2>/dev/null || true) echo "${discover_content:0:500}" @@ -556,11 +1015,11 @@ buy_response=$(curl -sf --max-time 300 \ ], \"max_tokens\": 4000, \"stream\": false - }" 2>&1) + }" 2>&1 || true) buy_content=$(extract_assistant_content "$buy_response" 2>/dev/null || true) echo "${buy_content:0:500}" -if [ -n "$buy_content" ] && [ "${#buy_content}" -gt 100 ]; then +if echo "$buy_content" | grep -qiE "purchase complete|PurchaseRequest created|pre-signed|model is now accessible"; then pass "Agent bought Alice's inference" else fail "Buy response: ${buy_response:0:300}" @@ -586,15 +1045,27 @@ buyer_status=$(buyer_sidecar_status) pass "Sidecar has auths: $buyer_status" # Extract the paid model name from sidecar status -PAID_MODEL=$(echo "$buyer_status" | grep -o 'model=[^ ]*' | sed 's/model=//' | head -1) +PAID_MODEL=$(echo "$buyer_status" | grep -o 'model=[^ ]*' | sed 's/model=//' | head -1 || true) if [ -z "$PAID_MODEL" ]; then PAID_MODEL="paid/qwen3.5:9b" # fallback fi step "Bob's agent: use paid model for inference" BOB_MASTER_KEY=$(bob kubectl get secret litellm-secrets -n llm \ - -o jsonpath='{.data.LITELLM_MASTER_KEY}' 2>/dev/null | base64 -d) -BUY_START_BLOCK=$(env -u CHAIN cast block-number --rpc-url https://sepolia.base.org 2>/dev/null | tr -d ' ') + -o jsonpath='{.data.LITELLM_MASTER_KEY}' 2>/dev/null | base64 -d 2>/dev/null || true) +if [ -z "$BOB_MASTER_KEY" ]; then + fail "Could not read Bob LiteLLM master key" + cleanup_pid "$PF_AGENT" + rm -f "$PF_AGENT_LOG" + emit_metrics; exit 1 +fi +BUY_START_BLOCK=$(env -u CHAIN cast block-number --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | tr -d ' ' || true) +if [ -z "$BUY_START_BLOCK" ]; then + fail "Could not read Base Sepolia block number before paid inference" + cleanup_pid "$PF_AGENT" + rm -f "$PF_AGENT_LOG" + emit_metrics; exit 1 +fi inference_response=$(litellm_paid_inference) if echo "$inference_response" | grep -q "STATUS=200"; then @@ -614,58 +1085,46 @@ rm -f "$PF_AGENT_LOG" # VERIFY ON-CHAIN SETTLEMENT # ═════════════════════════════════════════════════════════════════ +step "On-chain: settlement tx receipt" +settlement_match=$(wait_usdc_transfer_receipt settlement "$BOB_SIGNER_ADDR" "$ALICE_WALLET" 1000 "$BUY_START_BLOCK" 30 2 || true) +SETTLEMENT_TX=$(echo "$settlement_match" | awk '{print $1; exit}') +SETTLEMENT_AMOUNT=$(echo "$settlement_match" | awk '{print $2; exit}') +if [ -n "$SETTLEMENT_TX" ] && [ "$SETTLEMENT_AMOUNT" = "1000" ]; then + echo " tx=$SETTLEMENT_TX amount=$SETTLEMENT_AMOUNT" + pass "Settlement receipt archived and transfer amount verified" +else + fail "No successful Bob-signer -> Alice USDC settlement receipt found after block $BUY_START_BLOCK" +fi + step "On-chain: balance changes" -POST_ALICE_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) -POST_BOB_SIGNER_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - "balanceOf(address)(uint256)" "$BOB_SIGNER_ADDR" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) ALICE_AFTER_FUND_ONLY=$((PRE_ALICE_USDC - 50000)) +POST_ALICE_USDC="" +POST_BOB_SIGNER_USDC="" +for _ in $(seq 1 30); do + POST_ALICE_USDC=$(env -u CHAIN cast call "$USDC_ADDRESS_BASE_SEPOLIA" \ + "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | grep -oE '^[0-9]+' | head -1 || true) + POST_BOB_SIGNER_USDC=$(env -u CHAIN cast call "$USDC_ADDRESS_BASE_SEPOLIA" \ + "balanceOf(address)(uint256)" "$BOB_SIGNER_ADDR" --rpc-url "$BASE_SEPOLIA_RPC" 2>/dev/null | grep -oE '^[0-9]+' | head -1 || true) + if [ -n "$POST_ALICE_USDC" ] && [ "$POST_ALICE_USDC" -gt "$ALICE_AFTER_FUND_ONLY" ] 2>/dev/null && \ + [ -n "$POST_BOB_SIGNER_USDC" ] && [ "$POST_BOB_SIGNER_USDC" -lt "$POST_FUND_BOB_SIGNER_USDC" ] 2>/dev/null; then + break + fi + sleep 2 +done echo " Alice (pre-run): $PRE_ALICE_USDC" echo " Alice (expected after funding only): $ALICE_AFTER_FUND_ONLY" -echo " Alice (final): $POST_ALICE_USDC" -echo " Bob signer (final): $POST_BOB_SIGNER_USDC" +echo " Alice (final): ${POST_ALICE_USDC:-unknown}" +echo " Bob signer (after funding): $POST_FUND_BOB_SIGNER_USDC" +echo " Bob signer (final): ${POST_BOB_SIGNER_USDC:-unknown}" if [ -n "$POST_ALICE_USDC" ] && [ "$POST_ALICE_USDC" -gt "$ALICE_AFTER_FUND_ONLY" ] 2>/dev/null; then pass "Alice received USDC settlement" else - fail "Alice balance did not recover above funding-only expectation (expected > $ALICE_AFTER_FUND_ONLY, got $POST_ALICE_USDC)" + fail "Alice balance did not recover above funding-only expectation after polling (expected > $ALICE_AFTER_FUND_ONLY, got ${POST_ALICE_USDC:-unknown})" fi -if [ -n "$POST_BOB_SIGNER_USDC" ] && [ "$POST_BOB_SIGNER_USDC" -lt 50000 ] 2>/dev/null; then +if [ -n "$POST_BOB_SIGNER_USDC" ] && [ "$POST_BOB_SIGNER_USDC" -lt "$POST_FUND_BOB_SIGNER_USDC" ] 2>/dev/null; then pass "Bob remote-signer spent USDC" else - fail "Bob remote-signer balance did not drop below funded amount (expected < 50000, got $POST_BOB_SIGNER_USDC)" -fi - -step "On-chain: settlement tx hash" -transfer_logs=$(env -u CHAIN cast logs --json --rpc-url https://sepolia.base.org \ - --address 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - --from-block "$BUY_START_BLOCK" --to-block latest \ - "Transfer(address,address,uint256)" 2>/dev/null || true) -if FLOW11_TRANSFER_LOGS="$transfer_logs" FLOW11_ALICE="$ALICE_WALLET" FLOW11_BOB_SIGNER="$BOB_SIGNER_ADDR" python3 - <<'PY' -import json, os, sys - -logs = json.loads(os.environ["FLOW11_TRANSFER_LOGS"] or "[]") -alice = os.environ["FLOW11_ALICE"].lower().replace("0x", "") -bob = os.environ["FLOW11_BOB_SIGNER"].lower().replace("0x", "") -matches = [] -for log in logs: - topics = log.get("topics", []) - if len(topics) < 3: - continue - src = topics[1][-40:].lower() - dst = topics[2][-40:].lower() - if src != bob or dst != alice: - continue - amount = int(log.get("data", "0x0"), 16) - matches.append((log.get("transactionHash"), amount)) -if not matches: - sys.exit(1) -for tx, amount in matches: - print(f" tx={tx} amount={amount}") -PY -then - pass "Settlement tx hashes printed above" -else - fail "No Bob-signer -> Alice USDC transfer logs found after block $BUY_START_BLOCK" + fail "Bob remote-signer balance did not drop after polling (expected < $POST_FUND_BOB_SIGNER_USDC, got ${POST_BOB_SIGNER_USDC:-unknown})" fi # ═════════════════════════════════════════════════════════════════ @@ -681,6 +1140,47 @@ alice stack down 2>&1 | tail -1 step "Cleanup: Bob stack down" bob stack down 2>&1 | tail -1 +step "Receipts: write summary" +if FLOW11_ARTIFACT_DIR="$FLOW11_ARTIFACT_DIR" \ + FLOW11_COMMIT="$(git -C "$OBOL_ROOT" rev-parse HEAD 2>/dev/null || true)" \ + FLOW11_AGENT_ID="${AGENT_ID:-}" \ + FLOW11_ALICE="$ALICE_WALLET" \ + FLOW11_BOB="$BOB_WALLET" \ + FLOW11_BOB_SIGNER="${BOB_SIGNER_ADDR:-}" \ + FLOW11_TUNNEL="${TUNNEL_URL:-}" \ + FLOW11_REGISTRATION_TX="${REGISTRATION_TX:-}" \ + FLOW11_METADATA_TX="${METADATA_TX:-}" \ + FLOW11_FUNDING_TX="${FUNDING_TX:-}" \ + FLOW11_SETTLEMENT_TX="${SETTLEMENT_TX:-}" \ + python3 - <<'PY' +import json +import os +from pathlib import Path + +artifact_dir = Path(os.environ["FLOW11_ARTIFACT_DIR"]) +summary = { + "commit": os.environ.get("FLOW11_COMMIT", ""), + "agentId": os.environ.get("FLOW11_AGENT_ID", ""), + "alice": os.environ.get("FLOW11_ALICE", ""), + "bob": os.environ.get("FLOW11_BOB", ""), + "bobSigner": os.environ.get("FLOW11_BOB_SIGNER", ""), + "tunnel": os.environ.get("FLOW11_TUNNEL", ""), + "transactions": { + "registration": os.environ.get("FLOW11_REGISTRATION_TX", ""), + "metadata": os.environ.get("FLOW11_METADATA_TX", ""), + "funding": os.environ.get("FLOW11_FUNDING_TX", ""), + "settlement": os.environ.get("FLOW11_SETTLEMENT_TX", ""), + }, +} +artifact_dir.mkdir(parents=True, exist_ok=True) +(artifact_dir / "receipt-summary.json").write_text(json.dumps(summary, indent=2) + "\n") +PY +then + pass "Receipt summary: $FLOW11_ARTIFACT_DIR/receipt-summary.json" +else + fail "Could not write receipt summary" +fi + emit_metrics echo "" echo "════════════════════════════════════════════════════════════" @@ -688,4 +1188,5 @@ echo " Dual-stack test complete: $PASS_COUNT/$STEP_COUNT passed" echo " Alice: $ALICE_WALLET" echo " Bob: $BOB_WALLET" echo " Tunnel: $TUNNEL_URL" +echo " Artifacts: $FLOW11_ARTIFACT_DIR" echo "════════════════════════════════════════════════════════════" diff --git a/flows/lib.sh b/flows/lib.sh index bd3aa5f8b..73ac11c3d 100755 --- a/flows/lib.sh +++ b/flows/lib.sh @@ -26,6 +26,16 @@ OBOL="${OBOL:-$OBOL_BIN_DIR/obol}" STEP_COUNT=0 PASS_COUNT=0 +FAIL_COUNT=0 + +_flow_exit_status() { + local rc=$? + if [ "$rc" -eq 0 ] && [ "${FAIL_COUNT:-0}" -gt 0 ]; then + exit 1 + fi + exit "$rc" +} +trap _flow_exit_status EXIT # Well-known Hardhat/Anvil test mnemonic (deterministic, same on every install). # NEVER commit real private keys -- derive at runtime from this public mnemonic. @@ -65,7 +75,9 @@ pass() { } fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) echo "FAIL: [$STEP_COUNT] $1" + return 0 } # Run a command; pass if exit 0, fail otherwise. Captures output. @@ -134,9 +146,34 @@ cleanup_pid() { emit_metrics() { echo "METRIC steps_passed=$PASS_COUNT" + echo "METRIC steps_failed=$FAIL_COUNT" echo "METRIC total_steps=$STEP_COUNT" } +ensure_payment_python_deps() { + if python3 -c "import eth_account, httpx" >/dev/null 2>&1; then + return 0 + fi + + local venv_dir="${FLOW_PYTHON_VENV:-$OBOL_ROOT/.workspace/venv}" + python3 -m venv "$venv_dir" || return 1 + "$venv_dir/bin/python" -m pip install -q --upgrade pip || return 1 + "$venv_dir/bin/python" -m pip install -q eth-account httpx || return 1 + export PATH="$venv_dir/bin:$PATH" + + python3 -c "import eth_account, httpx" >/dev/null 2>&1 +} + +remote_signer_chart_version() { + awk -F'"' '/remoteSignerChartVersion =/ {print $2; exit}' \ + "$OBOL_ROOT/internal/openclaw/openclaw.go" +} + +remote_signer_chart_available() { + local version="$1" + helm search repo obol/remote-signer --versions 2>/dev/null | awk -v v="$version" '$2 == v {found=1} END {exit found ? 0 : 1}' +} + # Port helpers — shared so any flow can auto-pick ingress ports and do a # pre-bind sanity check instead of hardcoding 80/8080/443/8443. diff --git a/flows/release-smoke.sh b/flows/release-smoke.sh new file mode 100755 index 000000000..8a8253793 --- /dev/null +++ b/flows/release-smoke.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# Release smoke runner. +# +# Runs the documented black-box flow scripts in release order, preserves logs, +# treats any logged FAIL as a failed flow, and cleans test stacks on exit. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=flows/lib.sh +source "$SCRIPT_DIR/lib.sh" + +RUN_ID="${RELEASE_SMOKE_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +ARTIFACT_DIR="${RELEASE_SMOKE_ARTIFACT_DIR:-$OBOL_ROOT/.tmp/release-smoke-$RUN_ID}" +REPORT="$ARTIFACT_DIR/RELEASE_REPORT.md" +mkdir -p "$ARTIFACT_DIR" "$OBOL_BIN_DIR" "$OBOL_CONFIG_DIR" "$OBOL_DATA_DIR" + +cleanup_stacks() { + if [ "${RELEASE_SMOKE_KEEP_STACKS:-false}" = "true" ]; then + return 0 + fi + + local config_dir stack_id cluster + for config_dir in "$OBOL_CONFIG_DIR" "$OBOL_ROOT/.workspace-alice/config" "$OBOL_ROOT/.workspace-bob/config"; do + [ -f "$config_dir/.stack-id" ] || continue + stack_id=$(cat "$config_dir/.stack-id" 2>/dev/null || true) + [ -n "$stack_id" ] || continue + cluster="obol-stack-$stack_id" + k3d cluster delete "$cluster" >/dev/null 2>&1 || true + done +} +trap cleanup_stacks EXIT + +write_report_header() { + cat > "$REPORT" <> "$REPORT" +} + +append_report_footer() { + cat >> "$REPORT" < Building obol" + (cd "$OBOL_ROOT" && go build -o "$OBOL" ./cmd/obol) + + local tool src + for tool in kubectl helm helmfile k3d k9s openclaw; do + src=$(command -v "$tool" 2>/dev/null || true) + [ -n "$src" ] && ln -sf "$src" "$OBOL_BIN_DIR/$tool" + done + + echo "==> Ensuring Python payment dependencies" + ensure_payment_python_deps +} + +run_flow() { + local flow="$1" + local name log rc fail_count result + name=$(basename "$flow" .sh) + log="$ARTIFACT_DIR/$name.log" + + echo + echo "===== START $name =====" + set +e + if [ "$name" = "flow-11-dual-stack" ]; then + FLOW11_ARTIFACT_DIR="$ARTIFACT_DIR/flow-11-receipts" bash "$flow" 2>&1 | tee "$log" + else + bash "$flow" 2>&1 | tee "$log" + fi + rc=${PIPESTATUS[0]} + set -e + + fail_count=$(grep -c '^FAIL:' "$log" 2>/dev/null || true) + if [ "$rc" -eq 0 ] && [ "$fail_count" -eq 0 ]; then + result="PASS" + else + result="FAIL" + fi + append_report_row "$name" "$result" "$fail_count" "$rc" + echo "===== END $name result=$result rc=$rc fails=$fail_count =====" + + [ "$result" = "PASS" ] +} + +cleanup_default_stack_before_dual() { + echo + echo "==> Cleaning default stack before dual-stack flow" + "$OBOL" stack down >/dev/null 2>&1 || true + if [ -f "$OBOL_CONFIG_DIR/.stack-id" ]; then + k3d cluster delete "obol-stack-$(cat "$OBOL_CONFIG_DIR/.stack-id")" >/dev/null 2>&1 || true + fi +} + +main() { + write_report_header + prepare_workspace + + local failed=0 + local flow + local flows=( + "$SCRIPT_DIR/flow-01-prerequisites.sh" + "$SCRIPT_DIR/flow-02-stack-init-up.sh" + "$SCRIPT_DIR/flow-03-inference.sh" + "$SCRIPT_DIR/flow-04-agent.sh" + "$SCRIPT_DIR/flow-05-network.sh" + "$SCRIPT_DIR/flow-06-sell-setup.sh" + "$SCRIPT_DIR/flow-07-sell-verify.sh" + "$SCRIPT_DIR/flow-10-anvil-facilitator.sh" + "$SCRIPT_DIR/flow-08-buy.sh" + "$SCRIPT_DIR/flow-09-lifecycle.sh" + ) + + for flow in "${flows[@]}"; do + if ! run_flow "$flow"; then + failed=$((failed + 1)) + fi + done + + cleanup_default_stack_before_dual + + if ! run_flow "$SCRIPT_DIR/flow-11-dual-stack.sh"; then + failed=$((failed + 1)) + fi + + append_report_footer + + echo + echo "Report: $REPORT" + if [ "$failed" -gt 0 ]; then + echo "Release smoke failed: $failed flow(s)" + return 1 + fi + echo "Release smoke passed" +} + +main "$@"