diff --git a/CLAUDE.md b/CLAUDE.md index d1afd992..158079a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Payment-gated access to cluster services via x402 (HTTP 402 micropayments, Traef **Buy-side flow**: `buy.py probe` sees 402 pricing → `buy.py buy` validates the token contract exists on-chain → pre-signs payment auths (ERC-3009 for USDC, Permit2 for OBOL) into a `PurchaseRequest` CR in the agent namespace → serviceoffer-controller writes buyer config/auth files into `llm` and publishes `paid/` → the in-pod `x402-buyer` sidecar spends one auth per paid request. Agent-managed refill runs through `buy.py process --all`, not the controller. -**buy.py** lives at `${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py` inside the agent pod (skill name: `buy-inference`, not `buy`). Commands: +**buy.py** lives at `${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py` inside the agent pod (skill name: `buy-x402`, not `buy`). Commands: ``` probe [--model ] Probe x402 pricing from a 402 endpoint buy --endpoint --model Pre-sign ERC-3009 auths + create PurchaseRequest diff --git a/README.md b/README.md index 11c7b1c6..09aa383d 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ obol sell status -n # 4) Buyer wallet and balances are available. obol kubectl exec -n hermes-obol-agent deploy/hermes -c hermes -- \ - python3 /data/.hermes/obol-skills/buy-inference/scripts/buy.py balance + python3 /data/.hermes/obol-skills/buy-x402/scripts/buy.py balance ``` Run the paid tests only after all four checks pass. diff --git a/flows/README.md b/flows/README.md index e941f8a2..b5adec25 100644 --- a/flows/README.md +++ b/flows/README.md @@ -17,7 +17,7 @@ idempotent and exits non-zero on failure. - `flow-10-anvil-facilitator.sh` — Anvil + Facilitator local test infra (§3). Run BEFORE flow-08. - `flow-11-dual-stack.sh` — Dual-Stack: Alice sells, Bob discovers via ERC-8004 and buys. - `flow-12-obol-payment.sh` — OBOL payment asset over the existing USDC commerce baseline. -- `flow-13-dual-stack-obol.sh` — Dual-Stack OBOL: Alice sells, Bob discovers and buys, but the payment asset is a fork-local OBOL ERC20Permit token and the facilitator is a local x402-rs build (not the public Obol facilitator). Use this when you want to validate the OBOL Permit2 path end-to-end without depending on the public Obol facilitator or any USDC contract. Both obol stacks share ONE local Anvil fork of Base Sepolia via `host.k3d.internal:$ANVIL_PORT`. Requires `cast` + `anvil` + `forge` and an `X402_FACILITATOR_BIN` (or `X402_RS_DIR`) pointing at an x402-rs build with `eip2612GasSponsoring`; the script skips with a single PASS if neither is set. +- `flow-13-dual-stack-obol.sh` — Dual-Stack OBOL: Alice sells, Bob discovers and buys, but the payment asset is a fork-local OBOL ERC20Permit token and the facilitator is local (not the public Obol facilitator). Use this when you want to validate the OBOL Permit2 path end-to-end without depending on the public Obol facilitator or any USDC contract. Both obol stacks share ONE local Anvil fork of Base Sepolia via `host.k3d.internal:$ANVIL_PORT`. Requires `cast` + `anvil` + `forge`; the local facilitator runs as `ghcr.io/x402-rs/x402-facilitator:1.4.7`. - `flow-14-live-obol-base-sepolia.sh` — Live Base Sepolia OBOL dual-stack: Alice registers/sells, Bob discovers/buys, and settlement is verified against the deployed OBOL ERC20Permit token and public Obol facilitator. Requires `REMOTE_SIGNER_PRIVATE_KEY` funded with Base Sepolia ETH and the second deterministic derived Bob key funded with OBOL; `OBOL_TOKEN_BASE_SEPOLIA` defaults to the current live Base Sepolia OBOL token (`0x54AE82bc871a4E3E8E2FE1173Cb864B8563D44D4`) and can be overridden. `lib.sh` is shared helpers; `release-smoke.sh` is the release gate. diff --git a/flows/flow-01-prerequisites.sh b/flows/flow-01-prerequisites.sh index 67287246..c6d59ac5 100755 --- a/flows/flow-01-prerequisites.sh +++ b/flows/flow-01-prerequisites.sh @@ -53,7 +53,7 @@ fi 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" + fail "Could not parse RemoteSignerChartVersion from internal/agentruntime/charts.go" elif remote_signer_chart_available "$rs_version"; then pass "obol/remote-signer $rs_version is available" else diff --git a/flows/flow-10-anvil-facilitator.sh b/flows/flow-10-anvil-facilitator.sh index 2d5c2750..363dcb4a 100755 --- a/flows/flow-10-anvil-facilitator.sh +++ b/flows/flow-10-anvil-facilitator.sh @@ -13,7 +13,7 @@ mkdir -p "$FLOW_STATE_DIR" ANVIL_LOG="$FLOW_STATE_DIR/anvil.log" ANVIL_PID_FILE="$FLOW_STATE_DIR/anvil.pid" FACILITATOR_LOG="$FLOW_STATE_DIR/facilitator.log" -FACILITATOR_PID_FILE="$FLOW_STATE_DIR/facilitator.pid" +FACILITATOR_CONTAINER_FILE="$FLOW_STATE_DIR/facilitator.container" cluster_facilitator_host() { if [ -n "${CLUSTER_FACILITATOR_HOST:-}" ]; then @@ -131,24 +131,12 @@ if curl -sf http://localhost:4040/supported >/dev/null 2>&1; then pass "Facilitator already running on port 4040" FACILITATOR_PORT=4040 else - old_pid=$(cat "$FACILITATOR_PID_FILE" 2>/dev/null || true) - if [ -n "$old_pid" ] && ! kill -0 "$old_pid" 2>/dev/null; then - rm -f "$FACILITATOR_PID_FILE" - fi - - # Binary discovery: X402_FACILITATOR_BIN env → ~/Development/R&D/x402-rs - FACILITATOR_BIN="${X402_FACILITATOR_BIN:-}" - if [ -z "$FACILITATOR_BIN" ]; then - X402_RS_DIR="${X402_RS_DIR:-$HOME/Development/R&D/x402-rs}" - for candidate in \ - "$X402_RS_DIR/target/release/x402-facilitator" \ - "$X402_RS_DIR/target/release/facilitator"; do - [ -f "$candidate" ] && FACILITATOR_BIN="$candidate" && break - done - fi + old_container=$(cat "$FACILITATOR_CONTAINER_FILE" 2>/dev/null || true) + [ -n "$old_container" ] && docker rm -f "$old_container" >/dev/null 2>&1 || true - if [ -z "$FACILITATOR_BIN" ]; then - fail "x402-facilitator binary not found — set X402_FACILITATOR_BIN or build from x402-rs repo" + FACILITATOR_IMAGE=$(x402_facilitator_image || true) + if [ -z "$FACILITATOR_IMAGE" ]; then + fail "x402-facilitator image unavailable: ghcr.io/x402-rs/x402-facilitator:1.4.7" emit_metrics; exit 0 fi @@ -165,37 +153,24 @@ else "schemes": [{"id": "v1-eip155-exact","chains":"eip155:*"},{"id":"v2-eip155-exact","chains":"eip155:*"}] } FEOF - FACILITATOR_PID=$(FACILITATOR_LOG="$FACILITATOR_LOG" FACILITATOR_BIN="$FACILITATOR_BIN" FACILITATOR_CONFIG="$FACILITATOR_CONFIG" python3 - <<'PY' -import os -import subprocess - -log_path = os.environ["FACILITATOR_LOG"] -bin_path = os.environ["FACILITATOR_BIN"] -cfg_path = os.environ["FACILITATOR_CONFIG"] - -with open(log_path, "ab", buffering=0) as log_file: - proc = subprocess.Popen( - [bin_path, "--config", cfg_path], - stdin=subprocess.DEVNULL, - stdout=log_file, - stderr=subprocess.STDOUT, - start_new_session=True, - close_fds=True, - ) - print(proc.pid) -PY -) - echo "$FACILITATOR_PID" > "$FACILITATOR_PID_FILE" + FACILITATOR_CONTAINER="obol-flow10-x402-facilitator" + if ! start_x402_facilitator_container "$FACILITATOR_CONTAINER" "$FACILITATOR_CONFIG" "$FACILITATOR_LOG"; then + fail "Facilitator container failed to start — inspect $FACILITATOR_LOG" + emit_metrics; exit 0 + fi + echo "$FACILITATOR_CONTAINER" > "$FACILITATOR_CONTAINER_FILE" sleep 3 if curl -sf http://localhost:$FACILITATOR_PORT/supported >/dev/null 2>&1; then - if kill -0 "$FACILITATOR_PID" 2>/dev/null; then + if docker ps --format '{{.Names}}' | grep -qx "$FACILITATOR_CONTAINER"; then pass "Facilitator started on port $FACILITATOR_PORT" else + write_x402_facilitator_logs "$FACILITATOR_CONTAINER" "$FACILITATOR_LOG" fail "Facilitator exited after startup — inspect $FACILITATOR_LOG" emit_metrics; exit 0 fi else - fail "Facilitator failed to start (bin: $FACILITATOR_BIN)" + write_x402_facilitator_logs "$FACILITATOR_CONTAINER" "$FACILITATOR_LOG" + fail "Facilitator failed to start (image: $FACILITATOR_IMAGE)" emit_metrics; exit 0 fi fi diff --git a/flows/flow-11-dual-stack.sh b/flows/flow-11-dual-stack.sh index 9564ca19..054f7e4b 100755 --- a/flows/flow-11-dual-stack.sh +++ b/flows/flow-11-dual-stack.sh @@ -86,12 +86,25 @@ FLOW11_REQUIRED_BOB_USDC=$((FLOW11_BUY_COUNT * FLOW11_PRICE_MICRO_USDC)) mkdir -p "$FLOW11_ARTIFACT_DIR" # Always reclaim leaked Docker networks on exit so the next run doesn't run -# into "all predefined address pools have been fully subnetted". Each k3d -# cluster create reserves a /16 from Docker's 172.16/12 pool; if a cluster -# crashes mid-create or is force-removed without `obol stack down`, the -# network is orphaned. Targeted to k3d-obol-stack-* and skips networks -# with active endpoints, so it never disturbs a live cluster. -trap cleanup_k3d_obol_networks EXIT +# into "all predefined address pools have been fully subnetted". If the flow +# exits after creating Alice/Bob stacks but before the explicit cleanup section, +# tear those scoped stacks down as well. +flow11_cleanup() { + local ec=$? + set +e + if [ "$ec" -ne 0 ]; then + if type alice >/dev/null 2>&1; then + alice stack down >/dev/null 2>&1 || true + fi + if type bob >/dev/null 2>&1; then + bob stack down >/dev/null 2>&1 || true + fi + fi + cleanup_k3d_obol_networks + set -e + return $ec +} +trap flow11_cleanup EXIT # Proactive: also reclaim leaked networks at start so the new cluster can # allocate even if a prior aborted run left orphans behind. cleanup_k3d_obol_networks @@ -290,7 +303,7 @@ except Exception as e: bob_buy_skill_balance() { bob kubectl exec \ -n "$BOB_AGENT_NS" "deploy/$BOB_AGENT_DEPLOY" -c "$BOB_AGENT_CONTAINER" -- \ - python3 "$BOB_OBOL_SKILLS_DIR/buy-inference/scripts/buy.py" balance 2>&1 || true + python3 "$BOB_OBOL_SKILLS_DIR/buy-x402/scripts/buy.py" balance 2>&1 || true } bob_remote_signer_address() { @@ -1214,7 +1227,7 @@ buy_response=$(curl -sf --max-time 300 \ \"messages\": [ {\"role\": \"user\", \"content\": \"Search the ERC-8004 registry on Base Sepolia for the agent named 'Dual-Stack Test Inference'. Report its endpoint.\"}, {\"role\": \"assistant\", \"content\": \"I found the agent. Its endpoint is $TUNNEL_URL/services/alice-inference\"}, - {\"role\": \"user\", \"content\": \"Now use the buy-inference skill to buy $FLOW11_BUY_COUNT inference tokens from Alice. Run exactly: python3 $BOB_OBOL_SKILLS_DIR/buy-inference/scripts/buy.py buy alice-inference --endpoint $TUNNEL_URL/services/alice-inference/v1/chat/completions --model ${OBOL_LLM_MODEL:-qwen3.5:9b} --count $FLOW11_BUY_COUNT\"} + {\"role\": \"user\", \"content\": \"Now use the buy-x402 skill to buy $FLOW11_BUY_COUNT inference tokens from Alice. Run exactly: python3 $BOB_OBOL_SKILLS_DIR/buy-x402/scripts/buy.py buy alice-inference --endpoint $TUNNEL_URL/services/alice-inference/v1/chat/completions --model ${OBOL_LLM_MODEL:-qwen3.5:9b} --count $FLOW11_BUY_COUNT\"} ], \"max_tokens\": 4000, \"stream\": false @@ -1225,7 +1238,11 @@ buy_content=$(extract_assistant_content "$buy_response" 2>/dev/null || true) # structurally by the next step's PurchaseRequest CR Ready=True poll. Natural-language # matching has been brittle across runtime versions (OpenClaw vs Hermes). echo "${buy_content:0:500}" -pass "Agent buy command issued (success will be confirmed by PurchaseRequest CR)" +if printf '%s' "$buy_content" | agent_response_refused; then + fail "Agent refused to run buy.py" + emit_metrics; exit 1 +fi +pass "Agent accepted buy request (success will be confirmed by PurchaseRequest CR)" poll_step_grep "Bob: PurchaseRequest Ready" "True" 24 5 purchase_request_status pr_status=$(purchase_request_status) @@ -1411,3 +1428,4 @@ echo " Bob: $BOB_WALLET" echo " Tunnel: $TUNNEL_URL" echo " Artifacts: $FLOW11_ARTIFACT_DIR" echo "════════════════════════════════════════════════════════════" +exit_if_failed diff --git a/flows/flow-12-obol-payment.sh b/flows/flow-12-obol-payment.sh index ec9227f0..90ab8f6c 100755 --- a/flows/flow-12-obol-payment.sh +++ b/flows/flow-12-obol-payment.sh @@ -8,126 +8,9 @@ # # Requires: # - A running obol stack with the agent initialized. -# - X402_FACILITATOR_BIN or X402_RS_DIR pointing to a current x402-rs build -# with eip2612GasSponsoring support. +# - Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7. source "$(dirname "$0")/lib.sh" -resolve_facilitator_bin() { - if [ -n "${X402_FACILITATOR_BIN:-}" ] && [ -x "$X402_FACILITATOR_BIN" ]; then - if [ -z "${X402_RS_DIR:-}" ]; then - case "$X402_FACILITATOR_BIN" in - */target/release/*) - X402_RS_DIR=$(cd "$(dirname "$X402_FACILITATOR_BIN")/../.." && pwd) - ;; - esac - fi - printf '%s\n' "$X402_FACILITATOR_BIN" - return 0 - fi - - local rs_dir="${X402_RS_DIR:-}" - if [ -z "$rs_dir" ] && [ -d "$HOME/Development/R&D/x402-rs" ]; then - rs_dir="$HOME/Development/R&D/x402-rs" - fi - if [ -n "$rs_dir" ]; then - for candidate in \ - "$rs_dir/target/release/x402-facilitator" \ - "$rs_dir/target/release/facilitator"; do - if [ -x "$candidate" ]; then - X402_RS_DIR="$rs_dir" - printf '%s\n' "$candidate" - return 0 - fi - done - fi - - return 1 -} - -validate_x402_rs_source() { - local rs_dir="${X402_RS_DIR:-}" - local expect_remote="${FLOW12_EXPECT_X402_RS_REMOTE:-x402-rs/x402-rs}" - local expect_version="${FLOW12_EXPECT_X402_RS_VERSION:-1.4.7}" - local remote version - - [ -n "$rs_dir" ] || return 0 - [ -d "$rs_dir/.git" ] || return 0 - - remote=$(git -C "$rs_dir" remote get-url origin 2>/dev/null || true) - if [ -n "$expect_remote" ] && [ "$expect_remote" != "any" ]; then - case "$remote" in - *"$expect_remote"*) - ;; - *) - fail "x402-rs origin mismatch: expected remote containing '$expect_remote', got '${remote:-unknown}'" - emit_metrics - exit 1 - ;; - esac - fi - - if [ -n "$expect_version" ] && [ "$expect_version" != "any" ]; then - version=$(python3 - "$rs_dir" <<'PY' -import pathlib -import re -import sys - -root = pathlib.Path(sys.argv[1]) -workspace_version = "" -root_manifest = root / "Cargo.toml" -if root_manifest.exists(): - in_workspace_package = False - for line in root_manifest.read_text().splitlines(): - stripped = line.strip() - if stripped == "[workspace.package]": - in_workspace_package = True - continue - if stripped.startswith("[") and stripped != "[workspace.package]": - in_workspace_package = False - if in_workspace_package: - match = re.match(r'version\s*=\s*"([^"]+)"', stripped) - if match: - workspace_version = match.group(1) - break - -for path in (root / "facilitator" / "Cargo.toml", root / "crates" / "x402-facilitator-local" / "Cargo.toml"): - if path.exists(): - for line in path.read_text().splitlines(): - stripped = line.strip() - if re.match(r'version\s*\.workspace\s*=\s*true', stripped) and workspace_version: - print(workspace_version) - raise SystemExit(0) - match = re.match(r'version\s*=\s*"([^"]+)"', stripped) - if match: - print(match.group(1)) - raise SystemExit(0) - -for path in (root / "crates" / "x402-facilitator" / "Cargo.toml",): - if path.exists(): - for line in path.read_text().splitlines(): - stripped = line.strip() - match = re.match(r'version\s*=\s*"([^"]+)"', stripped) - if match: - print(match.group(1)) - raise SystemExit(0) -if workspace_version: - print(workspace_version) - raise SystemExit(0) -raise SystemExit(1) -PY - ) || version="" - if [ "$version" != "$expect_version" ]; then - fail "x402-rs facilitator version mismatch: expected $expect_version, got ${version:-unknown}" - emit_metrics - exit 1 - fi - fi - - echo " x402-rs origin: ${remote:-unknown}" - echo " x402-rs head: $(git -C "$rs_dir" rev-parse --short HEAD 2>/dev/null || echo unknown)" - echo " x402-rs facilitator version: ${version:-not-checked}" -} - step "local stack context is isolated" assert_local_stack_context pass "KUBECONFIG=$KUBECONFIG" @@ -165,21 +48,16 @@ else exit 1 fi -step "x402-rs facilitator binary available for OBOL Permit2" -FACILITATOR_BIN=$(resolve_facilitator_bin || true) -if [ -n "$FACILITATOR_BIN" ]; then - export X402_FACILITATOR_BIN="$FACILITATOR_BIN" - pass "X402_FACILITATOR_BIN=$X402_FACILITATOR_BIN" +step "x402-rs facilitator image available for OBOL Permit2" +FACILITATOR_IMAGE=$(x402_facilitator_image || true) +if [ -n "$FACILITATOR_IMAGE" ]; then + pass "Facilitator image available: $FACILITATOR_IMAGE" else - fail "x402-rs facilitator binary not found — set X402_FACILITATOR_BIN or X402_RS_DIR" + fail "x402-rs facilitator image unavailable: ghcr.io/x402-rs/x402-facilitator:1.4.7" emit_metrics exit 1 fi -step "x402-rs facilitator source matches expected release line" -validate_x402_rs_source -pass "x402-rs facilitator source validated" - step "OBOL Permit2 sell->buy->settle integration test" ARTIFACT_DIR="${FLOW12_ARTIFACT_DIR:-$OBOL_ROOT/.tmp/flow-12-$(date +%Y%m%d-%H%M%S)}" mkdir -p "$ARTIFACT_DIR" @@ -229,3 +107,4 @@ else fi emit_metrics +exit_if_failed diff --git a/flows/flow-13-dual-stack-obol.sh b/flows/flow-13-dual-stack-obol.sh index e144c377..26317738 100755 --- a/flows/flow-13-dual-stack-obol.sh +++ b/flows/flow-13-dual-stack-obol.sh @@ -7,8 +7,7 @@ # # - One Anvil fork of Base Sepolia (chain 84532) shared by Alice's and Bob's # obol stacks via the Docker-managed alias `host.k3d.internal:$ANVIL_PORT`. -# - One x402-rs facilitator process pointing at that Anvil. We require an -# ObolNetwork/x402-rs build with eip2612GasSponsoring support. +# - One x402-rs facilitator container pointing at that Anvil. # - A fork-local OBOL ERC20Permit contract (contracts/fork-obol/src/ForkObolToken.sol) # deployed via `forge create` against the same Anvil. The same address is # visible from both clusters because they share the fork. @@ -22,8 +21,7 @@ # - forge on PATH (used to compile ForkObolToken.sol) # - Docker running with the configured Alice/Bob ingress ports + Anvil port free # - Ollama running (Alice serves local model inference) -# - X402_FACILITATOR_BIN or X402_RS_DIR pointing at an x402-rs build with -# eip2612GasSponsoring; the flow skips with a single PASS if neither is set. +# - Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7 # # Use this flow when you want to validate the OBOL Permit2 path end-to-end # without depending on the public Obol facilitator or any USDC contract. @@ -32,8 +30,6 @@ # ./flows/flow-13-dual-stack-obol.sh # # Override defaults via shell env or repo-root .env: -# X402_FACILITATOR_BIN path to x402-facilitator (preferred) -# X402_RS_DIR directory of an x402-rs checkout (fallback) # FLOW13_ANVIL_PORT host port for Anvil (default: auto-pick) # FLOW13_FACILITATOR_PORT host port for x402-rs (default: auto-pick) # FLOW13_ALICE_HTTP_PORT, _ALT, _HTTPS_PORT, _HTTPS_ALT_PORT @@ -97,7 +93,7 @@ BOB_AGENT_LABEL="app.kubernetes.io/name=hermes" BOB_AGENT_RUNTIME="hermes" ANVIL_PID="" -FACILITATOR_PID="" +FACILITATOR_CONTAINER="" PF_AGENT="" PF_AGENT_LOG="" @@ -120,9 +116,17 @@ flow13_cleanup() { if [ -d "$BOB_DIR/config" ]; then bob network remove base-sepolia >/dev/null 2>&1 || true fi - if [ -n "$FACILITATOR_PID" ] && kill -0 "$FACILITATOR_PID" 2>/dev/null; then - kill "$FACILITATOR_PID" 2>/dev/null || true - wait "$FACILITATOR_PID" 2>/dev/null || true + if [ "$ec" -ne 0 ]; then + if type alice >/dev/null 2>&1; then + alice stack down >/dev/null 2>&1 || true + fi + if type bob >/dev/null 2>&1; then + bob stack down >/dev/null 2>&1 || true + fi + fi + if [ -n "$FACILITATOR_CONTAINER" ]; then + write_x402_facilitator_logs "$FACILITATOR_CONTAINER" "$FACILITATOR_LOG" + docker rm -f "$FACILITATOR_CONTAINER" >/dev/null 2>&1 || true fi if [ -n "$ANVIL_PID" ] && kill -0 "$ANVIL_PID" 2>/dev/null; then kill "$ANVIL_PID" 2>/dev/null || true @@ -453,24 +457,6 @@ except Exception as e: " 2>&1 || true } -resolve_facilitator_bin() { - if [ -n "${X402_FACILITATOR_BIN:-}" ] && [ -x "$X402_FACILITATOR_BIN" ]; then - printf '%s\n' "$X402_FACILITATOR_BIN"; return 0 - fi - local rs_dir="${X402_RS_DIR:-}" - if [ -z "$rs_dir" ] && [ -d "$HOME/Development/R&D/x402-rs" ]; then - rs_dir="$HOME/Development/R&D/x402-rs" - fi - if [ -n "$rs_dir" ]; then - for candidate in \ - "$rs_dir/target/release/x402-facilitator" \ - "$rs_dir/target/release/facilitator"; do - if [ -x "$candidate" ]; then printf '%s\n' "$candidate"; return 0; fi - done - fi - return 1 -} - # ═════════════════════════════════════════════════════════════════ # 1-5. PREFLIGHT # ═════════════════════════════════════════════════════════════════ @@ -486,15 +472,14 @@ if [ -n "$missing" ]; then fi pass "Foundry tools available" -step "Preflight: x402-rs facilitator binary resolvable" -FACILITATOR_BIN=$(resolve_facilitator_bin || true) -if [ -z "$FACILITATOR_BIN" ]; then - pass "Skipping flow-13 — set X402_FACILITATOR_BIN or X402_RS_DIR to a current x402-rs build" +step "Preflight: x402-rs facilitator image available" +FACILITATOR_IMAGE=$(x402_facilitator_image || true) +if [ -z "$FACILITATOR_IMAGE" ]; then + skip "flow-13 requires Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7" emit_metrics exit 0 fi -export X402_FACILITATOR_BIN="$FACILITATOR_BIN" -pass "X402_FACILITATOR_BIN=$X402_FACILITATOR_BIN" +pass "Facilitator image available: $FACILITATOR_IMAGE" step "Preflight: .env signer key (Alice/Bob seed)" SIGNER_KEY=$(grep -E '^[[:space:]]*REMOTE_SIGNER_PRIVATE_KEY=' "$OBOL_ROOT/.env" 2>/dev/null | head -1 | cut -d= -f2-) @@ -579,7 +564,7 @@ fi # 9-10. x402-rs FACILITATOR # ═════════════════════════════════════════════════════════════════ -step "Facilitator: start x402-rs pointing at Anvil" +step "Facilitator: start x402-rs container pointing at Anvil" FACILITATOR_CONFIG="$FLOW13_ARTIFACT_DIR/facilitator-config.json" FAC_SIGNER_KEY=$(hh_key 0) FAC_SIGNER_KEY="${FAC_SIGNER_KEY#0x}" @@ -596,16 +581,11 @@ cat > "$FACILITATOR_CONFIG" << FEOF ] } FEOF -FACILITATOR_PID=$(FAC_LOG="$FACILITATOR_LOG" FAC_BIN="$FACILITATOR_BIN" FAC_CFG="$FACILITATOR_CONFIG" python3 - <<'PY' -import os, subprocess -log = open(os.environ["FAC_LOG"], "ab", buffering=0) -p = subprocess.Popen( - [os.environ["FAC_BIN"], "--config", os.environ["FAC_CFG"]], - stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, - start_new_session=True, close_fds=True) -print(p.pid) -PY -) +FACILITATOR_CONTAINER="obol-flow13-x402-facilitator-$$" +if ! start_x402_facilitator_container "$FACILITATOR_CONTAINER" "$FACILITATOR_CONFIG" "$FACILITATOR_LOG"; then + fail "Facilitator container failed to start — see $FACILITATOR_LOG" + emit_metrics; exit 1 +fi fac_ready=0 for _ in $(seq 1 30); do if curl -sf "$FACILITATOR_URL_HOST/supported" >/dev/null 2>&1; then @@ -614,8 +594,9 @@ for _ in $(seq 1 30); do sleep 1 done if [ "$fac_ready" -eq 1 ]; then - pass "Facilitator up at $FACILITATOR_URL_HOST (pid $FACILITATOR_PID)" + pass "Facilitator container up at $FACILITATOR_URL_HOST ($FACILITATOR_CONTAINER)" else + write_x402_facilitator_logs "$FACILITATOR_CONTAINER" "$FACILITATOR_LOG" fail "Facilitator did not become reachable — see $FACILITATOR_LOG" emit_metrics; exit 1 fi @@ -1121,7 +1102,7 @@ buy_response=$(curl -sf --max-time 300 \ \"model\": \"$BOB_AGENT_RUNTIME-agent\", \"messages\": [ {\"role\": \"user\", \"content\": \"I need to buy 5 inference tokens from the OBOL-priced agent 'Dual-Stack OBOL Test Inference'. Its endpoint is $TUNNEL_URL/services/alice-obol-inference\"}, - {\"role\": \"user\", \"content\": \"Run exactly: python3 $BOB_OBOL_SKILLS_DIR/buy-inference/scripts/buy.py buy alice-obol --endpoint $TUNNEL_URL/services/alice-obol-inference/v1/chat/completions --model qwen3.5:9b --count 5\"} + {\"role\": \"user\", \"content\": \"Run exactly: python3 $BOB_OBOL_SKILLS_DIR/buy-x402/scripts/buy.py buy alice-obol --endpoint $TUNNEL_URL/services/alice-obol-inference/v1/chat/completions --model qwen3.5:9b --count 5\"} ], \"max_tokens\": 4000, \"stream\": false @@ -1130,7 +1111,11 @@ buy_content=$(extract_assistant_content "$buy_response" 2>/dev/null || true) echo "${buy_content:0:500}" # Don't grep buy_content for natural-language confirmation; structural success # is the PurchaseRequest CR Ready=True poll below. -pass "Agent buy command issued (success confirmed by PurchaseRequest CR)" +if printf '%s' "$buy_content" | agent_response_refused; then + fail "Agent refused to run buy.py" + emit_metrics; exit 1 +fi +pass "Agent accepted buy request (success confirmed by PurchaseRequest CR)" # ═════════════════════════════════════════════════════════════════ # 36-39. PR Ready / LiteLLM rollout / sidecar auths / paid call @@ -1239,11 +1224,11 @@ pass "Alice stack down issued" step "Cleanup: Bob stack down + kill anvil + facilitator" bob stack down 2>&1 | tail -1 || true -if [ -n "$FACILITATOR_PID" ] && kill -0 "$FACILITATOR_PID" 2>/dev/null; then - kill "$FACILITATOR_PID" 2>/dev/null || true - wait "$FACILITATOR_PID" 2>/dev/null || true +if [ -n "$FACILITATOR_CONTAINER" ]; then + write_x402_facilitator_logs "$FACILITATOR_CONTAINER" "$FACILITATOR_LOG" + docker rm -f "$FACILITATOR_CONTAINER" >/dev/null 2>&1 || true fi -FACILITATOR_PID="" +FACILITATOR_CONTAINER="" if [ -n "$ANVIL_PID" ] && kill -0 "$ANVIL_PID" 2>/dev/null; then kill "$ANVIL_PID" 2>/dev/null || true wait "$ANVIL_PID" 2>/dev/null || true @@ -1310,3 +1295,4 @@ echo " Anvil: $ANVIL_RPC_HOST" echo " Facilitator: $FACILITATOR_URL_HOST" echo " Artifacts: $FLOW13_ARTIFACT_DIR" echo "════════════════════════════════════════════════════════════" +exit_if_failed diff --git a/flows/flow-14-live-obol-base-sepolia.sh b/flows/flow-14-live-obol-base-sepolia.sh index 43dfba64..9659b8be 100755 --- a/flows/flow-14-live-obol-base-sepolia.sh +++ b/flows/flow-14-live-obol-base-sepolia.sh @@ -125,6 +125,14 @@ flow14_cleanup() { OBOL_DATA_DIR="$BOB_DIR/data" \ "$BOB_DIR/bin/obol" network remove base-sepolia >/dev/null 2>&1 || true fi + if [ "$ec" -ne 0 ]; then + if type alice >/dev/null 2>&1; then + alice stack down >/dev/null 2>&1 || true + fi + if type bob >/dev/null 2>&1; then + bob stack down >/dev/null 2>&1 || true + fi + fi # Reclaim leaked Docker networks from k3d clusters that crashed mid- # create. Targeted to k3d-obol-stack-* and skips networks with active # endpoints, so it never kills a live cluster's network. @@ -413,7 +421,7 @@ PY bob_buy_skill_balance() { bob kubectl exec \ -n "$BOB_AGENT_NS" "deploy/$BOB_AGENT_DEPLOY" -c "$BOB_AGENT_CONTAINER" -- \ - python3 "$BOB_OBOL_SKILLS_DIR/buy-inference/scripts/buy.py" balance 2>&1 || true + python3 "$BOB_OBOL_SKILLS_DIR/buy-x402/scripts/buy.py" balance 2>&1 || true } # bob_obol_balance_via_erpc directly queries OBOL `balanceOf(signer)` against @@ -1143,14 +1151,18 @@ buy_response=$(curl -sf --max-time 300 \ \"model\": \"$BOB_AGENT_RUNTIME-agent\", \"messages\": [ {\"role\": \"user\", \"content\": \"I need to buy 5 inference tokens from the OBOL-priced agent 'Live OBOL Base Sepolia Test Inference'. Its endpoint is $TUNNEL_URL/services/alice-obol-inference\"}, - {\"role\": \"user\", \"content\": \"Run exactly: python3 $BOB_OBOL_SKILLS_DIR/buy-inference/scripts/buy.py buy alice-obol --endpoint $TUNNEL_URL/services/alice-obol-inference/v1/chat/completions --model ${OBOL_LLM_MODEL:-qwen3.5:9b} --count 5\"} + {\"role\": \"user\", \"content\": \"Run exactly: python3 $BOB_OBOL_SKILLS_DIR/buy-x402/scripts/buy.py buy alice-obol --endpoint $TUNNEL_URL/services/alice-obol-inference/v1/chat/completions --model ${OBOL_LLM_MODEL:-qwen3.5:9b} --count 5\"} ], \"max_tokens\": 4000, \"stream\": false }" 2>&1 || true) buy_content=$(extract_assistant_content "$buy_response" 2>/dev/null || true) echo "${buy_content:0:500}" -pass "Agent buy command issued (success confirmed by PurchaseRequest CR)" +if printf '%s' "$buy_content" | agent_response_refused; then + fail "Agent refused to run buy.py" + emit_metrics; exit 1 +fi +pass "Agent accepted buy request (success confirmed by PurchaseRequest CR)" # ═════════════════════════════════════════════════════════════════ # 31-34. PR Ready / LiteLLM rollout / sidecar auths / paid call @@ -1335,3 +1347,4 @@ fi emit_metrics echo "" echo "════════════════════════════════════════════════════════════" +exit_if_failed diff --git a/flows/lib.sh b/flows/lib.sh index b6d3fc89..e0b8a304 100755 --- a/flows/lib.sh +++ b/flows/lib.sh @@ -32,6 +32,7 @@ OBOL="${OBOL:-$OBOL_BIN_DIR/obol}" STEP_COUNT=0 PASS_COUNT=0 +SKIP_COUNT=0 FAIL_COUNT=0 _flow_exit_status() { @@ -186,6 +187,11 @@ pass() { echo "PASS: [$STEP_COUNT] $1" } +skip() { + SKIP_COUNT=$((SKIP_COUNT + 1)) + echo "SKIP: [$STEP_COUNT] $1" +} + fail() { FAIL_COUNT=$((FAIL_COUNT + 1)) echo "FAIL: [$STEP_COUNT] $1" @@ -373,10 +379,17 @@ route_llm_via_obol_cli() { emit_metrics() { echo "METRIC steps_passed=$PASS_COUNT" + echo "METRIC steps_skipped=$SKIP_COUNT" echo "METRIC steps_failed=$FAIL_COUNT" echo "METRIC total_steps=$STEP_COUNT" } +exit_if_failed() { + if [ "${FAIL_COUNT:-0}" -gt 0 ]; then + exit 1 + fi +} + canonical_path() { python3 - "$1" <<'PY' import os @@ -395,6 +408,53 @@ require_tool() { fi } +x402_facilitator_image() { + local image="ghcr.io/x402-rs/x402-facilitator:1.4.7" + + command -v docker >/dev/null 2>&1 || { + echo "docker is required to fetch $image" >&2 + return 1 + } + + if ! docker pull "$image" >/dev/null 2>&1; then + echo "x402 facilitator image not available: $image" >&2 + return 1 + fi + + printf '%s\n' "$image" +} + +start_x402_facilitator_container() { + local name="$1" + local config="$2" + local log="$3" + local image config_abs + + image=$(x402_facilitator_image) || return 1 + config_abs=$(canonical_path "$config") + + docker rm -f "$name" >/dev/null 2>&1 || true + : > "$log" + docker run -d \ + --name "$name" \ + --network host \ + -v "$config_abs:/config.json:ro" \ + "$image" \ + --config /config.json >/dev/null +} + +write_x402_facilitator_logs() { + local name="$1" + local log="$2" + + [ -n "$name" ] || return 0 + docker logs "$name" > "$log" 2>&1 || true +} + +agent_response_refused() { + grep -qiE "cannot execute|can't execute|cannot run|can't run|do not have the ability|don't have the ability|not able to run arbitrary|as an AI model|I don't have access|I do not have access" +} + assert_obol_kubeconfig() { local expected actual @@ -468,7 +528,12 @@ ensure_payment_python_deps() { } remote_signer_chart_version() { - awk -F'"' '/remoteSignerChartVersion =/ {print $2; exit}' \ + awk -F'"' ' + /RemoteSignerChartVersion =/ {print $2; found=1; exit} + /remoteSignerChartVersion =/ {print $2; found=1; exit} + END {exit found ? 0 : 1} + ' \ + "$OBOL_ROOT/internal/agentruntime/charts.go" \ "$OBOL_ROOT/internal/openclaw/openclaw.go" } diff --git a/flows/release-smoke.sh b/flows/release-smoke.sh index f64bc0ee..9a5cbb58 100755 --- a/flows/release-smoke.sh +++ b/flows/release-smoke.sh @@ -38,8 +38,8 @@ Date: $(date -u +"%Y-%m-%dT%H:%M:%SZ") Commit: $(git -C "$OBOL_ROOT" rev-parse HEAD) Artifacts: $ARTIFACT_DIR -| Flow | Result | FAIL lines | Exit code | -| --- | --- | ---: | ---: | +| Flow | Result | FAIL lines | SKIP lines | Exit code | +| --- | --- | ---: | ---: | ---: | EOF } @@ -47,8 +47,9 @@ append_report_row() { local flow="$1" local result="$2" local fail_count="$3" - local rc="$4" - printf '| `%s` | %s | %s | %s |\n' "$flow" "$result" "$fail_count" "$rc" >> "$REPORT" + local skip_count="$4" + local rc="$5" + printf '| `%s` | %s | %s | %s | %s |\n' "$flow" "$result" "$fail_count" "$skip_count" "$rc" >> "$REPORT" } append_report_footer() { @@ -58,6 +59,7 @@ append_report_footer() { - The runner uses the real \`obol\` CLI and the flow scripts as black-box release checks. - Any \`FAIL:\` line is release-gating, even when a child script exits zero. +- A \`SKIP:\` line records an intentionally optional prerequisite path and does not count as release-gating. - \`flow-11-dual-stack.sh\` writes on-chain receipt artifacts under \`$ARTIFACT_DIR/flow-11-receipts\`. - Set \`RELEASE_SMOKE_INCLUDE_OBOL=true\` to run \`flow-14-live-obol-base-sepolia.sh\`. - Set \`RELEASE_SMOKE_INCLUDE_OBOL_FORK=true\` to run \`flow-13-dual-stack-obol.sh\`. @@ -90,7 +92,7 @@ prepare_workspace() { run_flow() { local flow="$1" - local name log rc fail_count result + local name log rc fail_count skip_count result name=$(basename "$flow" .sh) log="$ARTIFACT_DIR/$name.log" @@ -110,15 +112,20 @@ run_flow() { set -e fail_count=$(grep -c '^FAIL:' "$log" 2>/dev/null || true) + skip_count=$(grep -c '^SKIP:' "$log" 2>/dev/null || true) if [ "$rc" -eq 0 ] && [ "$fail_count" -eq 0 ]; then - result="PASS" + if [ "$skip_count" -gt 0 ]; then + result="SKIP" + else + result="PASS" + fi else result="FAIL" fi - append_report_row "$name" "$result" "$fail_count" "$rc" - echo "===== END $name result=$result rc=$rc fails=$fail_count =====" + append_report_row "$name" "$result" "$fail_count" "$skip_count" "$rc" + echo "===== END $name result=$result rc=$rc fails=$fail_count skips=$skip_count =====" - [ "$result" = "PASS" ] + [ "$result" != "FAIL" ] } cleanup_default_stack_before_dual() { diff --git a/go.mod b/go.mod index 8f759dcf..98f67795 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/prometheus/client_golang v1.15.0 github.com/prometheus/client_model v0.3.0 github.com/prometheus/common v0.42.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/shopspring/decimal v1.3.1 github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v3 v3.6.2 diff --git a/go.sum b/go.sum index 0c833da6..c451ad0f 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= @@ -274,6 +276,8 @@ github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= diff --git a/internal/agentruntime/charts.go b/internal/agentruntime/charts.go index 316a1bec..60d73549 100644 --- a/internal/agentruntime/charts.go +++ b/internal/agentruntime/charts.go @@ -5,12 +5,12 @@ package agentruntime // deployments. It MUST be updated as a single edit; bumping it here // updates every consumer in lockstep. // -// Chart 0.3.1 ships remote-signer image `v0.2.0`, which accepts the -// canonical-string signer contract (chain_id, value, etc. serialized -// as JSON strings) introduced by PR #359. Chart 0.3.0 ships `v0.1.0`, -// which only accepts the legacy u64 contract and breaks `obol sell -// register` for current obol-stack with HTTP 422 "chain_id: invalid -// type: string \"84532\", expected u64". +// Chart 0.3.2 ships remote-signer image `v0.3.0`, which emits canonical +// Ethereum recovery-id signatures (`v=27/28`) from `/sign/.../message`, +// `/sign/.../typed-data`, and `/sign/.../hash`. Earlier images returned +// `v=0/1` (alloy y-parity), which was rejected by EIP-712 / ERC-3009 +// verifiers like USDC `transferWithAuthorization` and forced the buy.py +// caller to renormalize. // // renovate: datasource=helm depName=remote-signer registryUrl=https://obolnetwork.github.io/helm-charts/ -const RemoteSignerChartVersion = "0.3.1" +const RemoteSignerChartVersion = "0.3.2" diff --git a/internal/embed/embed_skills_test.go b/internal/embed/embed_skills_test.go index fb09f116..d6249e04 100644 --- a/internal/embed/embed_skills_test.go +++ b/internal/embed/embed_skills_test.go @@ -17,7 +17,7 @@ func TestGetEmbeddedSkillNames(t *testing.T) { // Core skills that must always be present coreSkills := []string{ - "addresses", "building-blocks", "buy-inference", "concepts", "discovery", + "addresses", "building-blocks", "buy-x402", "concepts", "discovery", "distributed-validators", "ethereum-networks", "ethereum-local-wallet", "gas", "indexing", "l2s", "sell", "obol-stack", "standards", "wallets", "why", } @@ -101,10 +101,10 @@ func TestCopySkills(t *testing.T) { } } - // buy-inference must have references/ + // buy-x402 must have references/ for _, sub := range []string{ - "buy-inference/references/purchase-request-spec.md", - "buy-inference/references/x402-buyer-api.md", + "buy-x402/references/purchase-request-spec.md", + "buy-x402/references/x402-buyer-api.md", } { if _, err := os.Stat(filepath.Join(destDir, sub)); err != nil { t.Errorf("missing %s: %v", sub, err) diff --git a/internal/embed/skills/autoresearch-coordinator/SKILL.md b/internal/embed/skills/autoresearch-coordinator/SKILL.md index 449686c1..11181a26 100644 --- a/internal/embed/skills/autoresearch-coordinator/SKILL.md +++ b/internal/embed/skills/autoresearch-coordinator/SKILL.md @@ -20,7 +20,7 @@ Coordinate distributed autoresearch experiments across GPU workers discovered on ## When NOT to Use - Selling your own GPU as a worker -- use `autoresearch-worker` (then monetize it with `obol sell http`) -- Buying generic inference (chat completions) -- use `buy-inference` +- Buying generic inference (chat completions) -- use `buy-x402` - Discovering agents without running experiments -- use `discovery` - Signing transactions directly -- use `ethereum-local-wallet` - Cluster diagnostics -- use `obol-stack` @@ -96,7 +96,7 @@ From the registration document it extracts: ## How Payment Works -Experiment submission uses the same x402 payment flow as `buy-inference`: +Experiment submission uses the same x402 payment flow as `buy-x402`: 1. **Probe** -- Send unauthenticated POST to worker endpoint, receive `402 Payment Required` with pricing 2. **Sign** -- Pre-sign an ERC-3009 `TransferWithAuthorization` voucher via the current remote-signer API (`GET /api/v1/keys`, `POST /api/v1/sign/
/typed-data`) @@ -167,7 +167,7 @@ coordinate.py - **Requires remote-signer** -- must have agent wallet provisioned via `obol openclaw onboard` - **Requires network access** -- 8004scan API and worker endpoints must be reachable - **Python stdlib only** -- uses `urllib`; no third-party Python dependencies required -- **Per-experiment payment** -- each submission costs one x402 payment; monitor balance via `buy-inference balance` +- **Per-experiment payment** -- each submission costs one x402 payment; monitor balance via `buy-x402 balance` - **Worker availability** -- workers may go offline between discovery and submission; coordinator retries automatically - **Result storage is local** -- provenance metadata stored on-disk, not on-chain (future: IPFS/on-chain attestations) @@ -175,5 +175,5 @@ coordinate.py - `references/coordination-protocol.md` -- Ensue-to-obol mapping, discovery flow, payment flow, leaderboard format - See also: `discovery` skill for raw ERC-8004 registry queries -- See also: `buy-inference` skill for the x402 buyer sidecar architecture +- See also: `buy-x402` skill for the x402 buyer sidecar architecture - See also: `sell` skill for running a GPU worker (sell-side) diff --git a/internal/embed/skills/autoresearch/SKILL.md b/internal/embed/skills/autoresearch/SKILL.md index da4d8b09..4b265993 100644 --- a/internal/embed/skills/autoresearch/SKILL.md +++ b/internal/embed/skills/autoresearch/SKILL.md @@ -18,7 +18,7 @@ Autonomous LLM optimization: the agent iterates on `train.py`, runs 5-minute GPU ## When NOT to Use - Selling an existing model without optimization — use `sell` -- Buying remote inference — use `buy-inference` +- Buying remote inference — use `buy-x402` - Cluster diagnostics — use `obol-stack` ## Quick Start diff --git a/internal/embed/skills/buy-inference/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md similarity index 60% rename from internal/embed/skills/buy-inference/SKILL.md rename to internal/embed/skills/buy-x402/SKILL.md index 845568c1..46d8c803 100644 --- a/internal/embed/skills/buy-inference/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -1,25 +1,73 @@ --- -name: buy-inference -description: "Buy remote inference from x402-gated endpoints via a risk-isolated payment sidecar. Pre-signs bounded payment authorizations, declares them through `PurchaseRequest`, and exposes purchased models through the static LiteLLM namespace `paid/`. Zero signer access at runtime — spending is capped by design." +name: buy-x402 +description: "Buy from any x402-gated endpoint. Two flows: `pay` for one-shot HTTP services (single auth, no sidecar), and `buy` for long-running paid inference budgets (pre-signed batch via PurchaseRequest, exposed as `paid/`). Supports USDC (EIP-3009) and OBOL (Permit2). Zero signer access at runtime — spending is capped by design." metadata: { "openclaw": { "emoji": "\ud83d\uded2", "requires": { "bins": ["python3"] } } } --- -# Buy Inference - -Purchase access to remote x402-gated inference endpoints using a risk-isolated sidecar architecture. The agent pre-signs a bounded batch of payment authorizations (USDC via EIP-3009 or OBOL via Permit2, auto-detected from the seller's 402 response), embeds them in a `PurchaseRequest` CR in its own namespace, and lets the controller publish buyer config/auth files into `llm`. A lean Go proxy (`x402-buyer`) handles payments at runtime with zero signer access — max loss = N x price. The buyer validates the token contract exists on-chain before signing. +# Buy x402 + +Purchase access to remote x402-gated services. There are two flows, picked by usage shape: + +- **`pay `** — single-shot. Probe the URL, sign **one** payment authorization, attach `X-PAYMENT`, send the request, return the response. Stateless. Use for `type:http` services and any one-off purchase. Max loss = price of one request. +- **`buy `** — pre-payment budget. Pre-sign **N** authorizations, declare them in a `PurchaseRequest` CR, let the `x402-buyer` sidecar spend them transparently as the agent calls the model through LiteLLM at `paid/`. Use for long-running paid inference. Max loss = N × price; runtime path holds zero signer access. + +Both flows auto-detect the token + transfer method from the seller's 402 response. Currently supported: **USDC via EIP-3009** (Base Sepolia, Base Mainnet, Ethereum) and **OBOL via Permit2** (Ethereum Mainnet). + +## Gasless Payments + +x402 payments do **NOT** require ETH for gas. The agent signs an EIP-3009 +`TransferWithAuthorization` (or Permit2 witness) off-chain. The seller's +**facilitator** submits the on-chain settlement transaction and pays gas. +The agent only needs a balance of the settlement token (USDC or OBOL). Zero +ETH is fine. + +## Facilitator (server-side, agents do not call it) + +The facilitator is the seller-side service that settles payments on-chain. +The agent does **not** call it directly — there is no facilitator URI flag +in any of these commands. The seller's `x402-verifier` middleware +coordinates with the facilitator after verifying your `X-PAYMENT` header. +The default Obol-operated facilitator at `https://x402.gcp.obol.tech` +covers `eip155:1`, `eip155:8453`, and `eip155:84532`. + +## Pitfalls + +- **`extra.name` is NOT the EIP-712 signing domain name.** The 402 response + echoes the token contract's on-chain `name()` getter as `extra.name`. For + Base Sepolia USDC this is `"USD Coin"`, but the EIP-712 domain `name` + baked into the contract's domain separator is `"USDC"`. Sign with the + value advertised in `extra.eip712Domain.name` (or read the canonical + signing domain from `/api/services.json`). Treat + `extra.name`/`extra.version` as human-readable display only. `buy.py + probe` prints both fields; `buy` and `pay` resolve the signing domain + automatically. +- **Endpoint shape differs by service type.** Inference services expect + `POST /v1/chat/completions`; HTTP services typically expect `GET /` (or + a service-specific path). Pass `--type http` to `probe` for HTTP services + so the CLI does not append `/v1/chat/completions` to the URL. `pay` + defaults to `--type http` and `--method GET`. +- **`pay` is stateless; `buy` is persistent.** Do not use `buy` for a + `type:http` endpoint — its pipeline is inference-shaped (creates a + PurchaseRequest, expects a model name, publishes a `paid/` + route). Use `pay` instead. +- **Prefer `/api/services.json` over parsing markdown.** The seller's + storefront publishes machine-readable metadata at + `/api/services.json` with full asset, EIP-712 signing domain, + transfer method, and atomic-unit price for every offered service. ## When to Use -- Probing an endpoint to check pricing before buying -- Purchasing access to a remote model (pre-signs auths, creates a `PurchaseRequest`, exposes `paid/`) -- Manually topping up an existing purchase by re-running `buy ` -- Listing purchased providers and remaining auth counts -- Checking token balance before buying -- Inspecting the live sidecar status for remaining/spent auths +- Probing an endpoint to check pricing before buying — `probe` +- One-shot paid HTTP request (e.g. `demo-hello`, sponsored API endpoints) — `pay` +- Long-running paid model access (pre-signed batch) — `buy` +- Manually topping up an existing inference purchase by re-running `buy ` +- Listing purchased providers and remaining auth counts — `list` +- Checking token balance before buying — `balance` +- Inspecting the live sidecar status for remaining/spent auths — `status` ## When NOT to Use -- Selling your own services — use `monetize` +- Selling your own services — use `sell` - Discovering agents without buying — use `discovery` - Signing transactions directly — use `ethereum-local-wallet` - Cluster diagnostics — use `obol-stack` @@ -27,19 +75,28 @@ Purchase access to remote x402-gated inference endpoints using a risk-isolated s ## Quick Start ```bash -# Probe an endpoint to see its pricing -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py probe https://seller.example.com/services/my-model/v1/chat/completions +# Probe an inference endpoint to see its pricing (default --type inference) +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py probe https://seller.example.com/services/my-model/v1/chat/completions + +# Probe an HTTP service (no /v1/chat/completions append, GET by default) +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py probe https://seller.example.com/services/demo-hello --type http + +# One-shot paid HTTP request (sign 1 auth, attach X-PAYMENT, send GET, print response) +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py pay https://seller.example.com/services/demo-hello + +# One-shot paid POST with a JSON body +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py pay https://seller.example.com/services/echo --method POST --data '{"hello":"world"}' # Probe with the concrete remote model when the seller validates model IDs -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py probe https://seller.example.com/services/my-model/v1/chat/completions --model qwen3.5:35b +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py probe https://seller.example.com/services/my-model/v1/chat/completions --model qwen3.5:35b # Buy access (probes, pre-signs auths, creates/updates a PurchaseRequest) -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py buy remote-qwen \ +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py buy remote-qwen \ --endpoint https://seller.example.com/services/my-model \ --model qwen3.5:35b # Buy with agent-managed auto-refill intent -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py buy remote-qwen \ +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py buy remote-qwen \ --endpoint https://seller.example.com/services/my-model \ --model qwen3.5:35b \ --count 100 \ @@ -48,34 +105,35 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py --refill-count 50 # Manual top-up on the same purchase name -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py buy remote-qwen \ +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py buy remote-qwen \ --endpoint https://seller.example.com/services/my-model \ --model qwen3.5:35b \ --count 25 # List purchased providers + remaining auths -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py list +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py list # Check sidecar health + remaining auths -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py status remote-qwen +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py status remote-qwen # Reconcile auto-refill policies (heartbeat / cron entrypoint) -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py process --all +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py process --all # Check your USDC balance -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py balance +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py balance # Compatibility alias for the same reconcile loop -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py maintain +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py maintain ``` ## Commands | Command | Description | |---------|-------------| -| `probe [--model ]` | Send request without payment, parse 402 response for pricing | +| `probe [--model ] [--type http\|inference] [--method GET\|POST]` | Send request without payment, parse 402 response for pricing | +| `pay [--type http\|inference] [--method GET\|POST] [--data ]` | Single-shot paid request: sign 1 auth, attach X-PAYMENT, send | | `buy --endpoint --model [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/` | -| `process | --all` | Reconcile `autoRefill` policies against live `x402-buyer` status | +| `process \| --all` | Reconcile `autoRefill` policies against live `x402-buyer` status | | `list` | List purchased providers + remaining auth counts | | `status ` | Check sidecar pod status + remaining auths | | `balance [--chain ]` | Check agent's USDC balance via eRPC | @@ -116,7 +174,7 @@ Use the absolute script path inside the pod. Do not rely on `cd ... && ...` shell wrapping. ```bash -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py process --all +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py process --all ``` Tell the agent to schedule that as its maintenance loop only when at least one @@ -125,15 +183,15 @@ Tell the agent to schedule that as its maintenance loop only when at least one ### Hermes cron recipe Hermes already has a cron scheduler. The maintenance job should load the -buy-inference skill and run the same reconcile primitive on a schedule. +buy-x402 skill and run the same reconcile primitive on a schedule. CLI example: ```bash hermes cron create "every 5m" \ - "Reconcile existing x402 PurchaseRequests. Use the buy-inference skill and run python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py process --all. Report only errors or state changes." \ + "Reconcile existing x402 PurchaseRequests. Use the buy-x402 skill and run python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py process --all. Report only errors or state changes." \ --name "x402 buy reconcile" \ - --skill buy-inference + --skill buy-x402 ``` Python API example: @@ -142,10 +200,10 @@ Python API example: from cron.jobs import create_job create_job( - prompt="Reconcile existing x402 PurchaseRequests. Use the buy-inference skill and run python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py process --all. Report only errors or state changes.", + prompt="Reconcile existing x402 PurchaseRequests. Use the buy-x402 skill and run python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py process --all. Report only errors or state changes.", schedule="every 5m", name="x402 buy reconcile", - skills=["buy-inference"], + skills=["buy-x402"], ) ``` @@ -309,7 +367,7 @@ Look for agents with `"x402Support": true` and a `"web"` service endpoint. ```bash # Send an unauthenticated request to get 402 pricing -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py probe --model +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py probe --model ``` This returns the seller's pricing: `payTo`, `network`, `price`, and `asset` (USDC contract). @@ -318,10 +376,10 @@ This returns the seller's pricing: `payTo`, `network`, `price`, and `asset` (USD ```bash # Check USDC balance -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py balance --chain base-sepolia +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py balance --chain base-sepolia # Buy access (pre-sign auths, create PurchaseRequest, wait for controller reconciliation) -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py buy \ +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py buy \ --endpoint \ --model \ --count 20 @@ -344,13 +402,13 @@ The `paid/` prefix routes through the x402-buyer sidecar, which transparently at ```bash # Check remaining auths -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py list +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py list # Check one purchased upstream in detail -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py status +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py status # Reconcile auto-refill intent (what the heartbeat should run) -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py process --all +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py process --all ``` Manual `refill` and `remove` commands are still not available in the current diff --git a/internal/embed/skills/buy-inference/references/purchase-request-spec.md b/internal/embed/skills/buy-x402/references/purchase-request-spec.md similarity index 100% rename from internal/embed/skills/buy-inference/references/purchase-request-spec.md rename to internal/embed/skills/buy-x402/references/purchase-request-spec.md diff --git a/internal/embed/skills/buy-inference/references/x402-buyer-api.md b/internal/embed/skills/buy-x402/references/x402-buyer-api.md similarity index 75% rename from internal/embed/skills/buy-inference/references/x402-buyer-api.md rename to internal/embed/skills/buy-x402/references/x402-buyer-api.md index f09567a8..2f705ad5 100644 --- a/internal/embed/skills/buy-inference/references/x402-buyer-api.md +++ b/internal/embed/skills/buy-x402/references/x402-buyer-api.md @@ -30,9 +30,48 @@ Content-Type: application/json | `accepts` | array | List of payment options (usually one) | | `accepts[].scheme` | string | Payment scheme (always "exact") | | `accepts[].network` | string | CAIP-2 chain id, e.g. `eip155:84532` for Base Sepolia | -| `accepts[].amount` | string | Price in USDC micro-units (6 decimals). `"1000000"` = 1.0 USDC | -| `accepts[].asset` | address | USDC contract address on the chain | -| `accepts[].payTo` | address | Seller's USDC receiving address | +| `accepts[].amount` | string | Price in atomic units of `asset` (6 decimals for USDC, 18 for OBOL). `"1000000"` = 1.0 USDC | +| `accepts[].asset` | address | Token contract address on the chain | +| `accepts[].payTo` | address | Seller's receiving address | +| `accepts[].extra` | object | Asset metadata. Includes `name`, `version`, `assetTransferMethod`, and (when set) `eip712Domain`. See pitfall below. | + +> **Pitfall — `extra.name` is NOT the EIP-712 signing domain name.** The +> verifier echoes the token contract's on-chain `name()` getter as +> `extra.name`. For Base Sepolia USDC that is `"USD Coin"`, but the +> EIP-712 domain `name` baked into the contract's domain separator is +> `"USDC"`. Signing with `"USD Coin"` produces a signature the facilitator +> rejects. **Always** read the signing domain from +> `accepts[].extra.eip712Domain` (when the seller publishes it) or from +> `/api/services.json → services[].asset.eip712Domain`. Treat +> `extra.name` / `extra.version` as human-readable display only. + +> **Tip — prefer `/api/services.json` for machine-readable metadata.** +> The seller's Traefik storefront exposes a stable JSON catalog at +> `/api/services.json`. Each entry carries the full +> `asset.eip712Domain`, `asset.transferMethod`, `asset.decimals`, +> `priceAtomicUnits`, `chainId`, and `caip2Network` — agents do not need +> to parse the markdown `/skill.md` table to discover these. + +## Facilitator (server-side, agents do not call it) + +> **An agent never calls the facilitator directly.** The facilitator is the +> server-side component that submits the on-chain settlement transaction +> on the seller's behalf and pays gas — it is what makes x402 payments +> gasless for buyers. The buyer signs an EIP-3009 (or Permit2) auth +> off-chain, attaches it as `X-PAYMENT`, and the seller's `x402-verifier` +> middleware coordinates with the facilitator. There is no +> facilitator-URI flag for the agent. + +Default Obol-operated facilitator: `https://x402.gcp.obol.tech`. + +| CAIP-2 chain | Network | EIP-3009 settlement | Permit2 (incl. `eip2612GasSponsoring`) | +|--------------|---------|---------------------|----------------------------------------| +| `eip155:1` | Ethereum Mainnet | yes | yes | +| `eip155:8453` | Base Mainnet | yes | yes | +| `eip155:84532` | Base Sepolia | yes | yes | + +(See the in-cluster facilitator config for chain-specific RPC and signer +wiring; that is operator-side state, not buyer-side.) ## Sidecar Config Format (`x402-buyer-config` ConfigMap) diff --git a/internal/embed/skills/buy-inference/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py similarity index 89% rename from internal/embed/skills/buy-inference/scripts/buy.py rename to internal/embed/skills/buy-x402/scripts/buy.py index 9d480824..1958a164 100644 --- a/internal/embed/skills/buy-inference/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -28,6 +28,7 @@ remove Not yet available in controller mode """ +import base64 import json import os import secrets @@ -110,19 +111,6 @@ def _normalize_endpoint(url): return base -def _normalize_signature_recovery(sig): - """Convert 65-byte signatures from v=0/1 to Ethereum v=27/28.""" - if not isinstance(sig, str) or not sig.startswith("0x") or len(sig) != 132: - return sig - try: - v = int(sig[-2:], 16) - except ValueError: - return sig - if v in (0, 1): - return sig[:-2] + f"{v + 27:02x}" - return sig - - def _normalize_chain_name(network): """Map facilitator/network identifiers to the local eRPC network name.""" return CAIP2_TO_CHAIN.get(network, network) @@ -783,7 +771,6 @@ def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count, payme print(f"Error: remote-signer returned no signature for auth {i+1}", file=sys.stderr) sys.exit(1) - sig = _normalize_signature_recovery(sig) payload = { "x402Version": 2, @@ -957,23 +944,30 @@ def _reconcile_purchase_autorefill(pr, live_status, signer_address): # Probe # --------------------------------------------------------------------------- -def _probe_endpoint(endpoint_url, model_id="test"): - """Probe an endpoint for x402 pricing. Returns parsed 402 body or None.""" - base = _normalize_endpoint(endpoint_url) - chat_url = f"{base}/v1/chat/completions" - - payload = json.dumps({ - "model": model_id or "test", - "messages": [{"role": "user", "content": "ping"}], - }).encode() +def _probe_endpoint(endpoint_url, model_id="test", kind="inference", method=None): + """Probe an endpoint for x402 pricing. Returns parsed 402 body or None. - req = urllib.request.Request( - chat_url, data=payload, method="POST", - headers={"Content-Type": "application/json"}, - ) + kind="inference" appends /v1/chat/completions and POSTs a chat-completions + body (the inference contract). kind="http" sends the URL as-is using `method` + (default GET) with no body — appropriate for `type:http` ServiceOffers. + """ + if kind == "http": + url = endpoint_url.rstrip("/") + request = urllib.request.Request(url, method=(method or "GET").upper()) + else: + base = _normalize_endpoint(endpoint_url) + url = f"{base}/v1/chat/completions" + body = json.dumps({ + "model": model_id or "test", + "messages": [{"role": "user", "content": "ping"}], + }).encode() + request = urllib.request.Request( + url, data=body, method="POST", + headers={"Content-Type": "application/json"}, + ) try: - with urllib.request.urlopen(req, timeout=15) as resp: + with urllib.request.urlopen(request, timeout=15) as resp: return None except urllib.error.HTTPError as e: if e.code != 402: @@ -1000,17 +994,21 @@ def _probe_endpoint(endpoint_url, model_id="test"): return None -def cmd_probe(endpoint_url, model_id=None): +def cmd_probe(endpoint_url, model_id=None, kind="inference", method=None): """Probe an endpoint for x402 pricing and print results.""" - pricing = _probe_endpoint(endpoint_url, model_id) + pricing = _probe_endpoint(endpoint_url, model_id, kind=kind, method=method) if not pricing: print("Endpoint did not return valid x402 pricing.") return None - base = _normalize_endpoint(endpoint_url) - chat_url = f"{base}/v1/chat/completions" + if kind == "http": + printed_url = endpoint_url.rstrip("/") + else: + base = _normalize_endpoint(endpoint_url) + printed_url = f"{base}/v1/chat/completions" - print(f"Endpoint: {chat_url}") + print(f"Endpoint: {printed_url}") + print(f"Type: {kind}") print(f"x402 Version: {pricing.get('x402Version', '?')}") print() for i, acc in enumerate(pricing.get("accepts", [])): @@ -1029,7 +1027,15 @@ def cmd_probe(endpoint_url, model_id=None): if extra.get("assetTransferMethod"): print(f" transfer:{extra.get('assetTransferMethod')}") if extra.get("name") or extra.get("version"): - print(f" eip712: {extra.get('name', '?')} / {extra.get('version', '?')}") + # NOTE: extra.name is the token's human-readable display name from + # the contract's name() getter, NOT the EIP-712 signing domain + # name. For USDC on Base Sepolia these differ ("USD Coin" vs + # "USDC"). Use extra.eip712Domain when present; otherwise read the + # canonical domain from the contract on-chain. + print(f" token: {extra.get('name', '?')} / version {extra.get('version', '?')} (display only)") + if extra.get("eip712Domain"): + domain = extra.get("eip712Domain") or {} + print(f" eip712: {domain.get('name', '?')} / {domain.get('version', '?')} (signing domain)") print() return pricing @@ -1364,6 +1370,88 @@ def cmd_balance(chain=None): print(f"USDC: {usdc:.6f} ({balance} micro-units)") +# --------------------------------------------------------------------------- +# Pay (single-shot HTTP/x402 purchase) +# --------------------------------------------------------------------------- + +def cmd_pay(url, method="GET", data=None, kind="http"): + """Single-shot paid HTTP request: probe → pre-sign one auth → send with X-PAYMENT. + + Stateless. Does not create a PurchaseRequest, does not touch the buyer + sidecar, and is bounded to one auth (max loss = price). Use this for + `type:http` services and any one-off purchase that doesn't need persistent + pre-payment. For long-running paid inference budgets, use `buy`. + """ + method = (method or "GET").upper() + + print(f"Probing {url} ...") + pricing = _probe_endpoint(url, kind=kind, method=method) + if not pricing: + print("Failed to get x402 pricing.", file=sys.stderr) + sys.exit(1) + + accepts = pricing.get("accepts", []) + if not accepts: + print("No payment options in 402 response.", file=sys.stderr) + sys.exit(1) + + payment = accepts[0] + pay_to = payment.get("payTo", "") + chain = _normalize_chain_name(payment.get("network", DEFAULT_CHAIN)) + price = str(payment.get("amount", payment.get("maxAmountRequired", "0"))) + asset = payment.get("asset", USDC_CONTRACTS.get(chain, "")) + + if not pay_to: + print("Error: 402 response missing payTo.", file=sys.stderr) + sys.exit(1) + + print("Getting agent wallet ...") + signer_address = _get_signer_address() + print(f" Wallet: {signer_address}") + + usdc_addr = asset or USDC_CONTRACTS.get(chain, USDC_CONTRACTS["base-sepolia"]) + if not _validate_contract_exists(usdc_addr, chain): + print(f"Error: token contract {usdc_addr} not found on {chain}.", file=sys.stderr) + sys.exit(1) + + print(f"Pre-signing 1 payment authorization for {price} micro-units on {chain} ...") + auths = _presign_auths(signer_address, pay_to, price, chain, usdc_addr, 1, payment=payment) + if not auths: + print("Failed to pre-sign payment.", file=sys.stderr) + sys.exit(1) + + envelope = auths[0]["payment"] + x_payment_header = base64.b64encode(json.dumps(envelope).encode()).decode() + + request_data = data.encode() if data else None + headers = {"X-PAYMENT": x_payment_header} + if request_data: + headers.setdefault("Content-Type", "application/json") + + target_url = url.rstrip("/") if kind == "http" else f"{_normalize_endpoint(url)}/v1/chat/completions" + print(f"Sending paid {method} {target_url} ...") + req = urllib.request.Request(target_url, data=request_data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + body = resp.read().decode(errors="replace") + print(f"HTTP {resp.status}") + settle = resp.headers.get("X-PAYMENT-RESPONSE") + if settle: + print(f"X-PAYMENT-RESPONSE: {settle}") + print() + print(body) + return 0 + except urllib.error.HTTPError as e: + body = e.read().decode(errors="replace") if e.fp else "" + print(f"HTTP {e.code}", file=sys.stderr) + if body: + print(body, file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print(f"Connection error: {e.reason}", file=sys.stderr) + sys.exit(1) + + # --------------------------------------------------------------------------- # Remove # --------------------------------------------------------------------------- @@ -1415,7 +1503,10 @@ def usage(): print("Usage: python3 scripts/buy.py [args]") print() print("Commands:") - print(" probe [--model ] Probe x402 pricing") + print(" probe [--model ] [--type http|inference] [--method GET|POST]") + print(" Probe x402 pricing (default --type inference)") + print(" pay [--type http|inference] [--method GET|POST] [--data '']") + print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)") print(" buy --endpoint --model Pre-sign + configure paid/") print(" [--budget ] [--count ]") print(" [--auto-refill[=true|false]] [--refill-threshold ]") @@ -1441,9 +1532,24 @@ def usage(): if cmd == "probe": positional, opts = parse_flags(rest) if not positional: - print("Usage: probe [--model ]", file=sys.stderr) + print("Usage: probe [--model ] [--type http|inference]", file=sys.stderr) + sys.exit(1) + kind = opts.get("type", "inference") + if kind not in ("http", "inference"): + print(f"Error: --type must be 'http' or 'inference', got '{kind}'", file=sys.stderr) + sys.exit(1) + cmd_probe(positional[0], opts.get("model"), kind=kind, method=opts.get("method")) + + elif cmd == "pay": + positional, opts = parse_flags(rest) + if not positional: + print("Usage: pay [--type http|inference] [--method GET|POST] [--data '']", file=sys.stderr) + sys.exit(1) + kind = opts.get("type", "http") + if kind not in ("http", "inference"): + print(f"Error: --type must be 'http' or 'inference', got '{kind}'", file=sys.stderr) sys.exit(1) - cmd_probe(positional[0], opts.get("model")) + cmd_pay(positional[0], method=opts.get("method", "GET"), data=opts.get("data"), kind=kind) elif cmd == "buy": positional, opts = parse_flags(rest) diff --git a/internal/embed/skills/discovery/SKILL.md b/internal/embed/skills/discovery/SKILL.md index ee6c2b8f..c2c7a24e 100644 --- a/internal/embed/skills/discovery/SKILL.md +++ b/internal/embed/skills/discovery/SKILL.md @@ -119,20 +119,20 @@ When you fetch an agent's URI, the registration JSON follows this schema: ## After Discovery: Buying Inference -Once you find an agent with `"x402Support": true` and a service endpoint, use the `buy-inference` skill to purchase access: +Once you find an agent with `"x402Support": true` and a service endpoint, use the `buy-x402` skill to purchase access: ```bash # 1. Probe the endpoint for pricing -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py probe --model +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py probe --model # 2. Buy access (pre-signs payment auths, configures sidecar) -python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-inference/scripts/buy.py buy \ +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py buy \ --endpoint --model # 3. Use via LiteLLM as paid/ ``` -See the `buy-inference` skill for the full buy flow. +See the `buy-x402` skill for the full buy flow. ## Constraints diff --git a/internal/embed/skills/ethereum-networks/references/common-contracts.md b/internal/embed/skills/ethereum-networks/references/common-contracts.md index bb548706..95838ee3 100644 --- a/internal/embed/skills/ethereum-networks/references/common-contracts.md +++ b/internal/embed/skills/ethereum-networks/references/common-contracts.md @@ -43,6 +43,25 @@ | IdentityRegistry | `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` | | ReputationRegistry | `0x8004BAa17C55a88189AE136b182e5fdA19dE9b63` | +## Base Mainnet (Chain ID: 8453) + +### Tokens + +| Token | Address | Decimals | +|-------|---------|----------| +| USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | 6 | +| WETH | `0x4200000000000000000000000000000000000006` | 18 | + +## Base Sepolia Testnet (Chain ID: 84532) + +### Tokens + +| Token | Address | Decimals | +|-------|---------|----------| +| USDC | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | 6 | + +> **EIP-712 signing pitfall** (USDC, all chains): the `name` used in the EIP-712 domain separator is **not** always equal to what the contract's `name()` view returns. On Base Sepolia USDC, the EIP-712 domain `name` is `"USDC"` while `name()` returns `"USD Coin"`. The x402 verifier echoes `name()` back as `extra.name` in the 402 response — that field is for human display. For signing, prefer the domain advertised in the seller's 402 response under `extra.eip712Domain` (when present) or read the contract's EIP-712 separator on-chain. Do not feed the human-readable token name into the signing domain. + ## Hoodi Testnet (Chain ID: 560048) Hoodi is a newer testnet. Contract addresses may differ from mainnet. Use `eth_chainId` to confirm you're on the right network before querying. diff --git a/internal/embed/skills/monetize-guide/SKILL.md b/internal/embed/skills/monetize-guide/SKILL.md index 01cfca6e..bb342107 100644 --- a/internal/embed/skills/monetize-guide/SKILL.md +++ b/internal/embed/skills/monetize-guide/SKILL.md @@ -17,7 +17,7 @@ Step-by-step guide to expose local GPU resources or HTTP services as x402 paymen ## When NOT to Use - Managing existing offers (list/status/delete) — use `sell` directly -- Buying inference from others — use `buy-inference` +- Buying inference from others — use `buy-x402` - Cluster diagnostics — use `obol-stack` ## Workflow diff --git a/internal/openclaw/monetize_integration_test.go b/internal/openclaw/monetize_integration_test.go index dc27e9bf..64a215be 100644 --- a/internal/openclaw/monetize_integration_test.go +++ b/internal/openclaw/monetize_integration_test.go @@ -610,7 +610,7 @@ func waitForBuyerReportedBalance(t *testing.T, cfg *config.Config, want *big.Int var last string for time.Now().Before(deadline) { out, err := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "balance", "--chain", "base-sepolia") last = out if err == nil { @@ -662,7 +662,7 @@ func waitForBuyerAuthCount(t *testing.T, cfg *config.Config, name string, want i var last string for time.Now().Before(deadline) { out, err := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "status", name) last = out if err == nil && parseAuthsRemaining(t, out) == want { @@ -681,7 +681,7 @@ func waitForBuyerLiveAuthCount(t *testing.T, cfg *config.Config, name string, wa script := ` import json import sys -sys.path.insert(0, "/data/.openclaw/skills/buy-inference/scripts") +sys.path.insert(0, "/data/.openclaw/skills/buy-x402/scripts") import buy print(json.dumps(buy._buyer_status() or {})) ` @@ -735,7 +735,7 @@ func waitForBuyerProbePricing(t *testing.T, cfg *config.Config, timeout time.Dur var lastOut string for time.Now().Before(deadline) { out, _ := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "probe", endpointURL, "--model", model) lastOut = out if strings.Contains(out, "402") && strings.Contains(out, "payTo:") { @@ -791,7 +791,7 @@ func waitForBuyerUpstreamMissing(t *testing.T, cfg *config.Config, name string, script := ` import json import sys -sys.path.insert(0, "/data/.openclaw/skills/buy-inference/scripts") +sys.path.insert(0, "/data/.openclaw/skills/buy-x402/scripts") import buy print(json.dumps(buy._buyer_status() or {})) ` @@ -885,7 +885,7 @@ func waitForNoBuySideArtifacts(t *testing.T, cfg *config.Config, timeout time.Du out, err := execInAgentErr(cfg, "python3", "-c", ` import json import sys -sys.path.insert(0, "/data/.openclaw/skills/buy-inference/scripts") +sys.path.insert(0, "/data/.openclaw/skills/buy-x402/scripts") import buy print(json.dumps(buy._buyer_status() or {})) `) @@ -919,7 +919,7 @@ func syncAgentSkillsForBuySideTests(t *testing.T, cfg *config.Config) { t.Fatal("runtime.Caller failed for monetize integration test") } repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) - sourcePath := filepath.Join(repoRoot, "internal", "embed", "skills", "buy-inference", "scripts", "buy.py") + sourcePath := filepath.Join(repoRoot, "internal", "embed", "skills", "buy-x402", "scripts", "buy.py") data, err := os.ReadFile(sourcePath) if err != nil { t.Fatalf("read current buy.py: %v", err) @@ -930,7 +930,7 @@ func syncAgentSkillsForBuySideTests(t *testing.T, cfg *config.Config) { import base64 import pathlib -target = pathlib.Path("/data/.openclaw/skills/buy-inference/scripts/buy.py") +target = pathlib.Path("/data/.openclaw/skills/buy-x402/scripts/buy.py") target.write_bytes(base64.b64decode(%q)) `, encoded)) if err != nil { @@ -975,7 +975,7 @@ func bestEffortDrainDeletingPurchase(t *testing.T, cfg *config.Config, buyerName out, err := execInAgentErr(cfg, "python3", "-c", ` import json import sys -sys.path.insert(0, "/data/.openclaw/skills/buy-inference/scripts") +sys.path.insert(0, "/data/.openclaw/skills/buy-x402/scripts") import buy print(json.dumps(buy._buyer_status() or {})) `) @@ -2735,7 +2735,7 @@ spec: // // Unlike TestIntegration_Fork_FullPaymentFlow (which uses a mock facilitator // that always returns isValid:true), this test: -// 1. Starts the real x402-rs facilitator binary +// 1. Starts the real x402-rs facilitator container // 2. Funds a buyer wallet with USDC on the Anvil fork // 3. Signs a real EIP-712 TransferWithAuthorization (ERC-3009) // 4. Proves the facilitator validates the real signature @@ -2744,7 +2744,7 @@ spec: // Prerequisites: // - Running k3d cluster with CRD, agent, and x402-verifier // - Anvil (Foundry) installed -// - x402-rs source or binary (set X402_RS_DIR or X402_FACILITATOR_BIN) +// - Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7 func TestIntegration_Fork_RealFacilitatorPayment(t *testing.T) { cfg := requireCluster(t) requireCRD(t, cfg) @@ -2892,7 +2892,7 @@ func TestIntegration_Fork_RealFacilitatorPayment(t *testing.T) { // - Running k3d cluster with CRD, agent, x402-verifier, CF quick tunnel // - Ollama with a cached model (any model — qwen3.5:4b, qwen3.5:9b, etc.) // - Anvil (Foundry) installed -// - x402-rs source or binary (set X402_RS_DIR or X402_FACILITATOR_BIN) +// - Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7 func TestIntegration_Tunnel_RealFacilitatorOllama(t *testing.T) { cfg := requireCluster(t) requireCRD(t, cfg) @@ -3402,7 +3402,7 @@ func verifyOwnerRef(t *testing.T, resource map[string]interface{}, ownerName, ow // - Running k3d cluster with CRD, agent, x402-verifier, LiteLLM // - Anthropic API key configured in LiteLLM (for agent tool calling) // - Anvil (Foundry) installed -// - x402-rs facilitator binary (set X402_FACILITATOR_BIN or X402_RS_DIR) +// - Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7 // --------------------------------------------------------------------------- func TestIntegration_SellDiscoverBuySettle(t *testing.T) { @@ -3548,7 +3548,7 @@ spec: serviceURL := fmt.Sprintf("http://traefik.traefik.svc.cluster.local/services/%s/v1/chat/completions", name) probeOut, probeErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "probe", serviceURL) t.Logf("probe output:\n%s", probeOut) if probeErr != nil { @@ -3563,7 +3563,7 @@ spec: // Buy: pre-sign auths + deploy sidecar + wire LiteLLM sellerEndpoint := fmt.Sprintf("http://traefik.traefik.svc.cluster.local/services/%s", name) buyOut, buyErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", "self-loop", "--endpoint", sellerEndpoint, "--model", "claude-sonnet-4-5-20250929", @@ -3686,7 +3686,7 @@ func TestIntegration_Tunnel_SellDiscoverBuySidecar_QuotaAndBalance(t *testing.T) applyServiceOffer(t, cfg, offerYAML) t.Cleanup(func() { _, _ = execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "remove", buyerName) _, _ = execInAgentErr(cfg, "python3", monetizePy, @@ -3741,7 +3741,7 @@ func TestIntegration_Tunnel_SellDiscoverBuySidecar_QuotaAndBalance(t *testing.T) } buyOut, buyErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", buyerName, "--endpoint", localBaseURL, "--model", model, @@ -3761,7 +3761,7 @@ func TestIntegration_Tunnel_SellDiscoverBuySidecar_QuotaAndBalance(t *testing.T) t.Fatalf("buyer live status spent=%d before inference, want 0:\n%s", liveBeforeStatus.Spent, liveBefore) } statusBefore := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "status", buyerName) t.Logf("buyer status before inference:\n%s", statusBefore) if !strings.Contains(statusBefore, "Alias: paid/"+model) { @@ -3772,7 +3772,7 @@ func TestIntegration_Tunnel_SellDiscoverBuySidecar_QuotaAndBalance(t *testing.T) } balanceBeforeOut := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "balance", "--chain", "base-sepolia") t.Logf("buyer balance before:\n%s", balanceBeforeOut) if !strings.Contains(balanceBeforeOut, agentWallet) { @@ -3811,12 +3811,12 @@ func TestIntegration_Tunnel_SellDiscoverBuySidecar_QuotaAndBalance(t *testing.T) t.Fatalf("buyer live status spent=%d after one inference, want 1:\n%s", liveAfterStatus.Spent, liveAfter) } statusAfter := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "status", buyerName) t.Logf("buyer status after inference:\n%s", statusAfter) balanceAfterOut := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "balance", "--chain", "base-sepolia") t.Logf("buyer balance after:\n%s", balanceAfterOut) @@ -3850,10 +3850,6 @@ func TestIntegration_Tunnel_SellDiscoverBuySidecar_QuotaAndBalance(t *testing.T) // - automatic EIP-2612 gas sponsoring attachment // - x402-buyer replay of a full signed x402 payload func TestIntegration_SellBuySidecar_OBOLPermit2(t *testing.T) { - if os.Getenv("X402_FACILITATOR_BIN") == "" { - t.Skip("set X402_FACILITATOR_BIN to an ObolNetwork/x402-rs main build with eip2612GasSponsoring support") - } - cfg := requireCluster(t) requireCRD(t, cfg) requireAgent(t, cfg) @@ -3935,7 +3931,7 @@ spec: applyServiceOffer(t, cfg, offerYAML) t.Cleanup(func() { _, _ = execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "remove", buyerName) _, _ = execInAgentErr(cfg, "python3", monetizePy, @@ -3969,7 +3965,7 @@ spec: } buyOut, buyErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", buyerName, "--endpoint", localBaseURL, "--model", model, @@ -4123,7 +4119,7 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi t.Logf("probe output:\n%s", probeOut) buyOut, buyErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", buyerName, "--endpoint", localBaseURL, "--model", model, @@ -4152,7 +4148,7 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi waitForPurchaseRequestState(t, cfg, buyerName, 3, 1, 2, 90*time.Second) processBuyOut, processBuyErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "process", "--all") t.Logf("buy-side process --all output:\n%s", processBuyOut) if processBuyErr != nil { @@ -4168,7 +4164,7 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi waitForPurchaseRequestState(t, cfg, buyerName, 3, 3, 2, 90*time.Second) topUpOut, topUpErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", buyerName, "--endpoint", localBaseURL, "--model", model, @@ -4187,7 +4183,7 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi waitForPurchaseRequestState(t, cfg, buyerName, 5, 5, 2, 90*time.Second) conflictOut, conflictErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", conflictName, "--endpoint", localBaseURL, "--model", model, @@ -4205,14 +4201,14 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi liveWhileDeleting := waitForBuyerLiveAuthCount(t, cfg, buyerName, 5, 90*time.Second) t.Logf("buyer live status after delete request:\n%s", liveWhileDeleting) listDuringDrain := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "list") t.Logf("buyer list while draining:\n%s", listDuringDrain) if !strings.Contains(listDuringDrain, buyerName) { t.Fatalf("draining purchase disappeared from user-facing list too early:\n%s", listDuringDrain) } statusDuringDrain := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "status", buyerName) t.Logf("buyer status while draining:\n%s", statusDuringDrain) if !strings.Contains(statusDuringDrain, "Auths remaining: 5") { @@ -4220,7 +4216,7 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi } drainingConflictOut, drainingConflictErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "buy", conflictName, "--endpoint", localBaseURL, "--model", model, @@ -4253,14 +4249,14 @@ func TestIntegration_BuySideLifecycle_AutorefillTopUpDuplicateAndDelete(t *testi waitForBuyerUpstreamMissing(t, cfg, buyerName, 90*time.Second) listAfterDrain := execInAgent(t, cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "list") t.Logf("buyer list after drain:\n%s", listAfterDrain) if strings.Contains(listAfterDrain, buyerName) { t.Fatalf("drained purchase still present in user-facing list:\n%s", listAfterDrain) } statusAfterDrain, statusAfterDrainErr := execInAgentErr(cfg, "python3", - "/data/.openclaw/skills/buy-inference/scripts/buy.py", + "/data/.openclaw/skills/buy-x402/scripts/buy.py", "status", buyerName) t.Logf("buyer status after drain:\n%s", statusAfterDrain) if statusAfterDrainErr == nil { @@ -4388,7 +4384,7 @@ const monetizePy = "/data/.openclaw/skills/sell/scripts/monetize.py" // - Running k3d cluster with CRD, agent, x402-verifier, LiteLLM // - Ollama with qwen3.5:9b available locally // - Anvil (Foundry) installed -// - x402-rs facilitator binary (set X402_FACILITATOR_BIN or X402_RS_DIR) +// - Docker access to ghcr.io/x402-rs/x402-facilitator:1.4.7 func TestIntegration_SellBuyRoundtrip_LiteLLM(t *testing.T) { cfg := requireCluster(t) requireCRD(t, cfg) diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 0ed58079..aab93f0b 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -648,7 +648,7 @@ func copyWorkspaceToVolume(cfg *config.Config, id, workspaceDir string, u *ui.UI // ConfigMap during doSync — no pod readiness required. // // Always re-stages embedded skills so that new skills added to the binary -// (e.g. buy-inference, discovery) reach existing deployments on the next +// (e.g. buy-x402, discovery) reach existing deployments on the next // sync. CopySkills only writes files from the embedded FS — user-added // skills with different names are preserved. func stageDefaultSkills(deploymentDir string, u *ui.UI) { diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json new file mode 100644 index 00000000..edc2d099 --- /dev/null +++ b/internal/schemas/service-catalog.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://obol.org/schemas/service-catalog.schema.json", + "title": "Obol Service Catalog", + "description": "Public /api/services.json catalog served by x402 sellers.", + "type": "array", + "items": { + "$ref": "#/$defs/service" + }, + "$defs": { + "evmAddress": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "decimalString": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)(\\.[0-9]+)?$" + }, + "atomicUnits": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$" + }, + "eip712Domain": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + } + }, + "asset": { + "type": "object", + "additionalProperties": false, + "properties": { + "address": { + "$ref": "#/$defs/evmAddress" + }, + "symbol": { + "type": "string", + "minLength": 1 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "transferMethod": { + "type": "string", + "enum": [ + "eip3009", + "permit2" + ] + }, + "eip712Domain": { + "$ref": "#/$defs/eip712Domain" + } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "namespace", + "type", + "endpoint", + "price", + "payTo", + "network", + "description", + "isDemo" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": [ + "inference", + "fine-tuning", + "http" + ] + }, + "model": { + "type": "string", + "minLength": 1 + }, + "endpoint": { + "type": "string", + "format": "uri" + }, + "price": { + "type": "string", + "minLength": 1 + }, + "priceRaw": { + "$ref": "#/$defs/decimalString" + }, + "priceUnit": { + "type": "string", + "enum": [ + "perRequest", + "perMTok", + "perHour", + "perEpoch" + ] + }, + "priceAtomicUnits": { + "$ref": "#/$defs/atomicUnits" + }, + "payTo": { + "$ref": "#/$defs/evmAddress" + }, + "network": { + "type": "string", + "minLength": 1 + }, + "caip2Network": { + "type": "string", + "pattern": "^[a-z0-9]+:[0-9]+$" + }, + "chainId": { + "type": "integer", + "minimum": 1 + }, + "asset": { + "$ref": "#/$defs/asset" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "isDemo": { + "type": "boolean" + } + } + } + } +} diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go new file mode 100644 index 00000000..cc6e411f --- /dev/null +++ b/internal/schemas/service_catalog.go @@ -0,0 +1,52 @@ +package schemas + +import _ "embed" + +// ServiceCatalogJSONSchema is the JSON Schema for the public +// /api/services.json catalog served by sellers. +// +//go:embed service-catalog.schema.json +var ServiceCatalogJSONSchema string + +// ServiceCatalogEntry is the JSON representation of a ServiceOffer for the +// public storefront and for machine consumers constructing x402 payments. +// +// Stable wire schema: agents rely on asset.eip712Domain.name and +// asset.eip712Domain.version to construct ERC-3009 or Permit2 signatures. +// Do not rename fields without coordinating with buy.py and downstream agents. +type ServiceCatalogEntry struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Type string `json:"type"` + Model string `json:"model,omitempty"` + Endpoint string `json:"endpoint"` + Price string `json:"price"` + PriceRaw string `json:"priceRaw,omitempty"` + PriceUnit string `json:"priceUnit,omitempty"` + PriceAtomicUnits string `json:"priceAtomicUnits,omitempty"` + PayTo string `json:"payTo"` + Network string `json:"network"` + CAIP2Network string `json:"caip2Network,omitempty"` + ChainID int64 `json:"chainId,omitempty"` + Asset *ServiceCatalogAsset `json:"asset,omitempty"` + Description string `json:"description"` + IsDemo bool `json:"isDemo"` +} + +// ServiceCatalogAsset describes the settlement token resolved for a catalog +// entry. It mirrors the x402 asset metadata consumers need for signing. +type ServiceCatalogAsset struct { + Address string `json:"address,omitempty"` + Symbol string `json:"symbol,omitempty"` + Decimals int64 `json:"decimals,omitempty"` + TransferMethod string `json:"transferMethod,omitempty"` + EIP712Domain *ServiceCatalogEIP712Domain `json:"eip712Domain,omitempty"` +} + +// ServiceCatalogEIP712Domain is the signing domain agents must use when +// pre-signing payment authorizations. This is not always the same as the +// human-readable token name returned by the token contract. +type ServiceCatalogEIP712Domain struct { + Name string `json:"name"` + Version string `json:"version"` +} diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index d40c3975..e45c839c 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -4,6 +4,7 @@ import ( "crypto/md5" "encoding/json" "fmt" + "math/big" "net/url" "sort" "strconv" @@ -12,6 +13,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" @@ -216,9 +218,9 @@ func buildSkillCatalogConfigMap(content, servicesJSON string) *unstructured.Unst }, }, "data": map[string]any{ - "skill.md": content, - "services.json": servicesJSON, - "httpd.conf": ".md:text/markdown\n.json:application/json\n", + "skill.md": content, + "services.json": servicesJSON, + "httpd.conf": ".md:text/markdown\n.json:application/json\n", }, }, } @@ -768,21 +770,6 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin return strings.Join(lines, "\n") } -// ServiceJSON is the JSON representation of a ServiceOffer for the public storefront. -type ServiceJSON struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Type string `json:"type"` - Model string `json:"model,omitempty"` - Endpoint string `json:"endpoint"` - Price string `json:"price"` - PriceRaw string `json:"priceRaw,omitempty"` - PayTo string `json:"payTo"` - Network string `json:"network"` - Description string `json:"description"` - IsDemo bool `json:"isDemo"` -} - // buildServiceCatalogJSON returns a JSON array of ready ServiceOffers for the public storefront. func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) string { baseURL = strings.TrimRight(baseURL, "/") @@ -800,13 +787,13 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) return ready[i].Name < ready[j].Name }) - services := make([]ServiceJSON, 0, len(ready)) + services := make([]schemas.ServiceCatalogEntry, 0, len(ready)) for _, offer := range ready { desc := offer.Spec.Registration.Description if desc == "" { desc = fmt.Sprintf("x402 payment-gated %s service", fallbackOfferType(offer)) } - svc := ServiceJSON{ + svc := schemas.ServiceCatalogEntry{ Name: offer.Name, Namespace: offer.Namespace, Type: fallbackOfferType(offer), @@ -818,9 +805,23 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) Description: desc, IsDemo: offer.Namespace == "demo", } - if offer.Spec.Payment.Price.PerRequest != "" { - svc.PriceRaw = offer.Spec.Payment.Price.PerRequest + + raw, unit := offerPriceRawAndUnit(offer) + svc.PriceRaw = raw + svc.PriceUnit = unit + + caip2, chainID := caip2ForNetwork(offer.Spec.Payment.Network) + svc.CAIP2Network = caip2 + svc.ChainID = chainID + + asset := offerAssetJSON(offer) + if asset != nil { + svc.Asset = asset + if raw != "" && asset.Decimals > 0 { + svc.PriceAtomicUnits = decimalToAtomicString(raw, int(asset.Decimals)) + } } + services = append(services, svc) } @@ -831,6 +832,164 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) return string(out) } +// offerPriceRawAndUnit returns the raw decimal price string and which slot it +// occupies in the price table. Only one of perRequest / perMTok / perHour is +// expected to be set on a given offer. +func offerPriceRawAndUnit(offer *monetizeapi.ServiceOffer) (string, string) { + switch { + case offer.Spec.Payment.Price.PerRequest != "": + return offer.Spec.Payment.Price.PerRequest, "perRequest" + case offer.Spec.Payment.Price.PerMTok != "": + return offer.Spec.Payment.Price.PerMTok, "perMTok" + case offer.Spec.Payment.Price.PerHour != "": + return offer.Spec.Payment.Price.PerHour, "perHour" + default: + return "", "" + } +} + +// offerAssetJSON resolves the settlement asset block. If the offer carries an +// explicit asset, it is used verbatim. If only the network is set, defaults +// for USDC on that chain are filled in (this matches the verifier's behavior +// when the seller did not pass --token). +func offerAssetJSON(offer *monetizeapi.ServiceOffer) *schemas.ServiceCatalogAsset { + a := offer.Spec.Payment.Asset + if a.Address == "" && a.Symbol == "" && a.EIP712Name == "" { + // No explicit asset — fall back to the chain's default USDC entry. + if def, ok := defaultUSDCForNetwork(offer.Spec.Payment.Network); ok { + return &def + } + return nil + } + out := &schemas.ServiceCatalogAsset{ + Address: a.Address, + Symbol: a.Symbol, + Decimals: a.Decimals, + TransferMethod: a.TransferMethod, + } + if a.EIP712Name != "" || a.EIP712Version != "" { + out.EIP712Domain = &schemas.ServiceCatalogEIP712Domain{Name: a.EIP712Name, Version: a.EIP712Version} + } + if def, ok := defaultUSDCForNetwork(offer.Spec.Payment.Network); ok { + // Backfill any unset fields from chain defaults so consumers always + // see a complete asset block when the network is known. + if out.Address == "" { + out.Address = def.Address + } + if out.Symbol == "" { + out.Symbol = def.Symbol + } + if out.Decimals == 0 { + out.Decimals = def.Decimals + } + if out.TransferMethod == "" { + out.TransferMethod = def.TransferMethod + } + if out.EIP712Domain == nil { + out.EIP712Domain = def.EIP712Domain + } + } + return out +} + +// caip2ForNetwork maps a chain name (or CAIP-2 string) to (CAIP-2, chainID). +// Returns ("", 0) when the network is unrecognized — the catalog still +// publishes the offer, just without these convenience fields. +func caip2ForNetwork(network string) (string, int64) { + if strings.HasPrefix(network, "eip155:") { + parts := strings.SplitN(network, ":", 2) + if len(parts) == 2 { + id, err := strconv.ParseInt(parts[1], 10, 64) + if err == nil { + return network, id + } + } + } + switch strings.ToLower(strings.TrimSpace(network)) { + case "base", "base-mainnet": + return "eip155:8453", 8453 + case "base-sepolia": + return "eip155:84532", 84532 + case "ethereum", "ethereum-mainnet", "mainnet": + return "eip155:1", 1 + case "polygon", "polygon-mainnet": + return "eip155:137", 137 + case "polygon-amoy": + return "eip155:80002", 80002 + case "avalanche", "avalanche-mainnet": + return "eip155:43114", 43114 + case "avalanche-fuji": + return "eip155:43113", 43113 + case "arbitrum", "arbitrum-one": + return "eip155:42161", 42161 + case "arbitrum-sepolia": + return "eip155:421614", 421614 + default: + return "", 0 + } +} + +// defaultUSDCForNetwork returns the canonical USDC settlement asset for a +// chain when the seller did not specify an explicit asset. Mirrors the +// verifier's chain → asset defaults so /api/services.json stays consistent +// with what the 402 response advertises. +func defaultUSDCForNetwork(network string) (schemas.ServiceCatalogAsset, bool) { + switch strings.ToLower(strings.TrimSpace(network)) { + case "base", "base-mainnet": + return schemas.ServiceCatalogAsset{ + Address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Symbol: "USDC", + Decimals: 6, + TransferMethod: "eip3009", + EIP712Domain: &schemas.ServiceCatalogEIP712Domain{Name: "USD Coin", Version: "2"}, + }, true + case "base-sepolia": + return schemas.ServiceCatalogAsset{ + Address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Symbol: "USDC", + Decimals: 6, + TransferMethod: "eip3009", + // Empirically Base Sepolia USDC's signing domain name is "USDC", + // while the contract's name() returns "USD Coin". Keep "USDC" + // here — buy.py signs with this and the facilitator settles. + EIP712Domain: &schemas.ServiceCatalogEIP712Domain{Name: "USDC", Version: "2"}, + }, true + case "ethereum", "ethereum-mainnet", "mainnet": + return schemas.ServiceCatalogAsset{ + Address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + Symbol: "USDC", + Decimals: 6, + TransferMethod: "eip3009", + EIP712Domain: &schemas.ServiceCatalogEIP712Domain{Name: "USD Coin", Version: "2"}, + }, true + default: + return schemas.ServiceCatalogAsset{}, false + } +} + +// decimalToAtomicString converts a decimal token amount (e.g. "0.001") to +// atomic units using big.Float to avoid floating-point truncation. Returns +// "" on parse error so callers can omit the field. +func decimalToAtomicString(amount string, decimals int) string { + if amount == "" || decimals < 0 { + return "" + } + parsed, _, err := big.ParseFloat(amount, 10, 128, big.ToNearestEven) + if err != nil || parsed == nil { + return "" + } + multiplier := new(big.Float).SetPrec(128).SetInt( + new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil), + ) + atomic := new(big.Float).SetPrec(128).Mul(parsed, multiplier) + atomic.Add(atomic, new(big.Float).SetPrec(128).SetFloat64(0.5)) + out, _ := atomic.Int(nil) + if out == nil { + return "" + } + return out.String() +} + func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { switch { case offer.Spec.Payment.Price.PerRequest != "": diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 15ce0d48..d77ed603 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -7,10 +7,36 @@ import ( "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/santhosh-tekuri/jsonschema/v6" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) +func assertServiceCatalogSchema(t *testing.T, jsonStr string) { + t.Helper() + + schemaDoc, err := jsonschema.UnmarshalJSON(strings.NewReader(schemas.ServiceCatalogJSONSchema)) + if err != nil { + t.Fatalf("service catalog schema is invalid JSON: %v", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("service-catalog.schema.json", schemaDoc); err != nil { + t.Fatalf("failed to register service catalog schema: %v", err) + } + schema, err := compiler.Compile("service-catalog.schema.json") + if err != nil { + t.Fatalf("service catalog schema failed to compile: %v", err) + } + payload, err := jsonschema.UnmarshalJSON(strings.NewReader(jsonStr)) + if err != nil { + t.Fatalf("service catalog JSON is invalid: %v\n%s", err, jsonStr) + } + if err := schema.Validate(payload); err != nil { + t.Fatalf("service catalog JSON violates schema: %v\n%s", err, jsonStr) + } +} + func TestBuildHTTPRoute(t *testing.T) { offer := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "llm", UID: types.UID("demo-uid")}, @@ -435,7 +461,7 @@ func TestBuildServiceCatalogJSON(t *testing.T) { }, Payment: monetizeapi.ServiceOfferPayment{ Network: "base", - PayTo: "0xabc", + PayTo: "0x1111111111111111111111111111111111111111", Price: monetizeapi.ServiceOfferPriceTable{ PerRequest: "0.00001", }, @@ -456,8 +482,9 @@ func TestBuildServiceCatalogJSON(t *testing.T) { } jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) - var services []ServiceJSON + var services []schemas.ServiceCatalogEntry if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) } @@ -485,6 +512,7 @@ func TestBuildServiceCatalogJSON(t *testing.T) { func TestBuildServiceCatalogJSON_Empty(t *testing.T) { jsonStr := buildServiceCatalogJSON(nil, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) if jsonStr != "[]" { t.Errorf("expected empty array, got %q", jsonStr) } @@ -521,7 +549,7 @@ func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { Type: "http", Payment: monetizeapi.ServiceOfferPayment{ Network: "base", - PayTo: "0xabc", + PayTo: "0x1111111111111111111111111111111111111111", Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, }, }, @@ -531,7 +559,7 @@ func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { jsonStr := buildServiceCatalogJSON(offers, "https://example.com") - var services []ServiceJSON + var services []schemas.ServiceCatalogEntry if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) } @@ -568,7 +596,7 @@ func TestBuildServiceCatalogJSON_SortOrder(t *testing.T) { jsonStr := buildServiceCatalogJSON(offers, "https://example.com") - var services []ServiceJSON + var services []schemas.ServiceCatalogEntry if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { t.Fatalf("invalid JSON: %v", err) } @@ -581,20 +609,19 @@ func TestBuildServiceCatalogJSON_SortOrder(t *testing.T) { } } -// TestBuildServiceCatalogJSON_PerMTokPricing verifies that per-mtok-only -// offers render a non-empty Price string (via describeOfferPrice) but leave -// PriceRaw empty — PriceRaw is only populated from PerRequest. Without this -// test, a per-mtok seller could show up on the storefront with an empty -// price label on refactor. +// TestBuildServiceCatalogJSON_PerMTokPricing verifies that per-mtok offers +// render a non-empty Price string AND a populated PriceRaw + PriceUnit so +// agents can disambiguate the unit. Without this test a refactor could +// silently drop the unit metadata. func TestBuildServiceCatalogJSON_PerMTokPricing(t *testing.T) { offer := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "mtok-svc", Namespace: "llm"}, Spec: monetizeapi.ServiceOfferSpec{ - Type: "inference", + Type: "inference", Model: monetizeapi.ServiceOfferModel{Name: "qwen3.5:9b"}, Payment: monetizeapi.ServiceOfferPayment{ Network: "base", - PayTo: "0xabc", + PayTo: "0x1111111111111111111111111111111111111111", Price: monetizeapi.ServiceOfferPriceTable{PerMTok: "5.00"}, }, }, @@ -604,8 +631,9 @@ func TestBuildServiceCatalogJSON_PerMTokPricing(t *testing.T) { } jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) - var services []ServiceJSON + var services []schemas.ServiceCatalogEntry if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { t.Fatalf("invalid JSON: %v", err) } @@ -613,8 +641,11 @@ func TestBuildServiceCatalogJSON_PerMTokPricing(t *testing.T) { t.Fatalf("expected 1 service, got %d", len(services)) } got := services[0] - if got.PriceRaw != "" { - t.Errorf("PriceRaw = %q, want empty for per-mtok pricing", got.PriceRaw) + if got.PriceRaw != "5.00" { + t.Errorf("PriceRaw = %q, want %q", got.PriceRaw, "5.00") + } + if got.PriceUnit != "perMTok" { + t.Errorf("PriceUnit = %q, want perMTok", got.PriceUnit) } if got.Price == "" { t.Error("Price must not be empty for per-mtok pricing") @@ -636,7 +667,9 @@ func TestBuildServiceCatalogJSON_FallbackDescription(t *testing.T) { Spec: monetizeapi.ServiceOfferSpec{ Type: "inference", Payment: monetizeapi.ServiceOfferPayment{ - Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + Network: "base", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, }, // Spec.Registration.Description intentionally omitted. }, @@ -646,8 +679,9 @@ func TestBuildServiceCatalogJSON_FallbackDescription(t *testing.T) { } jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) - var services []ServiceJSON + var services []schemas.ServiceCatalogEntry if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { t.Fatalf("invalid JSON: %v", err) } @@ -676,7 +710,7 @@ func TestBuildServiceCatalogJSON_BaseURLTrailingSlash(t *testing.T) { jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com/") - var services []ServiceJSON + var services []schemas.ServiceCatalogEntry if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { t.Fatalf("invalid JSON: %v", err) } @@ -691,6 +725,148 @@ func TestBuildServiceCatalogJSON_BaseURLTrailingSlash(t *testing.T) { } } +// TestBuildServiceCatalogJSON_AssetAndCAIP2Defaults locks in the wire schema +// agents rely on: when the seller did not specify --token, the controller +// must backfill the chain's default USDC asset block (address, decimals, +// transferMethod, signing-domain), the CAIP-2 network, the chain id, and +// the price in atomic units. Buyers (buy.py and external agents) construct +// EIP-712 typed data straight from these fields without re-deriving them. +func TestBuildServiceCatalogJSON_AssetAndCAIP2Defaults(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-hello", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) + + var services []schemas.ServiceCatalogEntry + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + got := services[0] + + if got.CAIP2Network != "eip155:84532" { + t.Errorf("CAIP2Network = %q, want eip155:84532", got.CAIP2Network) + } + if got.ChainID != 84532 { + t.Errorf("ChainID = %d, want 84532", got.ChainID) + } + if got.PriceAtomicUnits != "1000" { + t.Errorf("PriceAtomicUnits = %q, want 1000 (0.001 USDC × 1e6)", got.PriceAtomicUnits) + } + if strings.Contains(jsonStr, "priceMicroUnits") { + t.Fatalf("services.json must not expose legacy priceMicroUnits field: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"priceAtomicUnits"`) { + t.Fatalf("services.json missing priceAtomicUnits field: %s", jsonStr) + } + if got.PriceUnit != "perRequest" { + t.Errorf("PriceUnit = %q, want perRequest", got.PriceUnit) + } + if got.Asset == nil { + t.Fatalf("Asset is nil; expected USDC default backfill for base-sepolia") + } + if got.Asset.Address != "0x036CbD53842c5426634e7929541eC2318f3dCF7e" { + t.Errorf("Asset.Address = %q, want base-sepolia USDC", got.Asset.Address) + } + if got.Asset.Symbol != "USDC" { + t.Errorf("Asset.Symbol = %q, want USDC", got.Asset.Symbol) + } + if got.Asset.Decimals != 6 { + t.Errorf("Asset.Decimals = %d, want 6", got.Asset.Decimals) + } + if got.Asset.TransferMethod != "eip3009" { + t.Errorf("Asset.TransferMethod = %q, want eip3009", got.Asset.TransferMethod) + } + if got.Asset.EIP712Domain == nil { + t.Fatalf("Asset.EIP712Domain is nil") + } + // Base Sepolia USDC empirically signs with domain name "USDC", not + // "USD Coin" (the contract's name() getter). Locking in that the + // catalog publishes the SIGNING domain, not the display name. + if got.Asset.EIP712Domain.Name != "USDC" { + t.Errorf("EIP712Domain.Name = %q, want USDC (signing domain on Base Sepolia)", got.Asset.EIP712Domain.Name) + } + if got.Asset.EIP712Domain.Version != "2" { + t.Errorf("EIP712Domain.Version = %q, want 2", got.Asset.EIP712Domain.Version) + } +} + +// TestBuildServiceCatalogJSON_ExplicitOBOLToken verifies that a seller who +// chose --token OBOL (Permit2 transfer method) sees their explicit asset +// fields preserved on the storefront, not silently overwritten by USDC +// defaults. +func TestBuildServiceCatalogJSON_ExplicitOBOLToken(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "obol-svc", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Payment: monetizeapi.ServiceOfferPayment{ + Network: "ethereum", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.5"}, + Asset: monetizeapi.ServiceOfferAsset{ + Address: "0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7", + Symbol: "OBOL", + Decimals: 18, + TransferMethod: "permit2", + EIP712Name: "Obol Network", + EIP712Version: "1", + }, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) + + var services []schemas.ServiceCatalogEntry + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + got := services[0] + + if got.CAIP2Network != "eip155:1" || got.ChainID != 1 { + t.Errorf("CAIP-2/chainID = %s/%d, want eip155:1/1", got.CAIP2Network, got.ChainID) + } + if got.Asset == nil { + t.Fatalf("Asset must be present for OBOL") + } + if got.Asset.Symbol != "OBOL" || got.Asset.TransferMethod != "permit2" { + t.Errorf("OBOL fields drifted: symbol=%q transfer=%q", got.Asset.Symbol, got.Asset.TransferMethod) + } + if got.Asset.Decimals != 18 { + t.Errorf("OBOL decimals = %d, want 18", got.Asset.Decimals) + } + if got.Asset.EIP712Domain == nil || got.Asset.EIP712Domain.Name != "Obol Network" { + t.Errorf("OBOL signing domain dropped, got %+v", got.Asset.EIP712Domain) + } + // 0.5 OBOL × 1e18 = 500_000_000_000_000_000. + if got.PriceAtomicUnits != "500000000000000000" { + t.Errorf("PriceAtomicUnits = %q, want 500000000000000000", got.PriceAtomicUnits) + } +} + func TestBuildServicesJSONHTTPRoute(t *testing.T) { route := buildServicesJSONHTTPRoute() if route.GetName() != servicesJSONRouteName { diff --git a/internal/testutil/facilitator_real.go b/internal/testutil/facilitator_real.go index 08b0bf31..fdee9e1b 100644 --- a/internal/testutil/facilitator_real.go +++ b/internal/testutil/facilitator_real.go @@ -9,12 +9,13 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "strconv" "testing" "time" ) +const x402FacilitatorImage = "ghcr.io/x402-rs/x402-facilitator:1.4.7" + // RealFacilitator wraps a running x402-rs facilitator process. // Unlike MockFacilitator, this validates real EIP-712 signatures against // an Anvil fork of Base Sepolia. @@ -22,25 +23,18 @@ type RealFacilitator struct { Port int ClusterURL string // e.g. "http://host.docker.internal:4040" - cmd *exec.Cmd - cancel context.CancelFunc + cmd *exec.Cmd + cancel context.CancelFunc + containerName string } type RealFacilitatorOptions struct { EnableEIP2612GasSponsoring bool } -// StartRealFacilitator discovers/builds the x402-rs facilitator binary, +// StartRealFacilitator runs the pinned x402-rs facilitator image, // generates a config pointing at the given Anvil fork, starts the facilitator // on a free port, and waits for it to become ready. -// -// Binary discovery order: -// 1. X402_FACILITATOR_BIN env var (explicit path to binary) -// 2. Pre-built binary at $X402_RS_DIR/target/release/x402-facilitator -// (or the legacy $X402_RS_DIR/target/release/facilitator) -// 3. cargo build --release in $X402_RS_DIR (if Cargo.toml exists) -// 4. Skip test -// // Registers t.Cleanup to kill the process and remove temp config. func StartRealFacilitator(t *testing.T, anvil *AnvilFork) *RealFacilitator { return StartRealFacilitatorWithOptions(t, anvil, RealFacilitatorOptions{}) @@ -49,7 +43,7 @@ func StartRealFacilitator(t *testing.T, anvil *AnvilFork) *RealFacilitator { func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFacilitatorOptions) *RealFacilitator { t.Helper() - bin := discoverFacilitatorBinary(t) + requireFacilitatorImage(t) // Find a free port. l, err := net.Listen("tcp", "0.0.0.0:0") @@ -68,8 +62,16 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa configPath := writeRealFacilitatorConfig(t, port, anvilLocalURL, anvil.Accounts[0].PrivateKey, opts) ctx, cancel := context.WithCancel(context.Background()) + containerName := fmt.Sprintf("obol-test-x402-facilitator-%d", time.Now().UnixNano()) - cmd := exec.CommandContext(ctx, bin, "--config", configPath) + cmd := exec.CommandContext(ctx, + "docker", "run", "--rm", + "--name", containerName, + "--network", "host", + "-v", configPath+":/config.json:ro", + x402FacilitatorImage, + "--config", "/config.json", + ) var stderr bytes.Buffer @@ -82,14 +84,16 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa } rf := &RealFacilitator{ - Port: port, - ClusterURL: "http://" + net.JoinHostPort(clusterHostURL(), strconv.Itoa(port)), - cmd: cmd, - cancel: cancel, + Port: port, + ClusterURL: "http://" + net.JoinHostPort(clusterHostURL(), strconv.Itoa(port)), + cmd: cmd, + cancel: cancel, + containerName: containerName, } t.Cleanup(func() { cancel() + _ = exec.Command("docker", "rm", "-f", containerName).Run() _ = cmd.Wait() @@ -106,87 +110,21 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa return rf } -// discoverFacilitatorBinary finds or builds the x402-rs facilitator binary. -func discoverFacilitatorBinary(t *testing.T) string { +// requireFacilitatorImage verifies the pinned facilitator image is available. +// Local facilitator experiments should be packaged as a Docker image instead of +// depending on host checkout paths. +func requireFacilitatorImage(t *testing.T) { t.Helper() - // 1. Explicit binary path. - if bin := os.Getenv("X402_FACILITATOR_BIN"); bin != "" { - if _, err := os.Stat(bin); err == nil { - t.Logf("using X402_FACILITATOR_BIN=%s", bin) - return bin - } - - t.Fatalf("X402_FACILITATOR_BIN=%s does not exist", bin) - } - - // Resolve x402-rs directory. - rsDir := os.Getenv("X402_RS_DIR") - if rsDir == "" { - // Default local checkout path. - home, _ := os.UserHomeDir() - rsDir = filepath.Join(home, "Development", "R&D", "x402-rs") - } - - // 2. Pre-built binary. - prebuiltCandidates := []string{ - filepath.Join(rsDir, "target", "release", "x402-facilitator"), - filepath.Join(rsDir, "target", "release", "facilitator"), + if _, err := exec.LookPath("docker"); err != nil { + t.Fatalf("docker not installed; cannot run %s", x402FacilitatorImage) } - for _, prebuilt := range prebuiltCandidates { - if _, err := os.Stat(prebuilt); err == nil { - t.Logf("using pre-built facilitator at %s", prebuilt) - return prebuilt - } - } - - // 3. Build from source. - cargoToml := filepath.Join(rsDir, "Cargo.toml") - if _, err := os.Stat(cargoToml); err == nil { - if _, err := exec.LookPath("cargo"); err != nil { - t.Skip("x402-rs source found but cargo not installed") - } - - t.Logf("building x402-rs facilitator from %s (this may take a while)...", rsDir) - - buildCommands := [][]string{ - {"build", "--release", "-p", "x402-facilitator"}, - {"build", "--release", "-p", "facilitator"}, - } - - var buildErr error - for _, args := range buildCommands { - build := exec.Command("cargo", args...) - build.Dir = rsDir - build.Stdout = os.Stderr - - build.Stderr = os.Stderr - if err := build.Run(); err == nil { - buildErr = nil - break - } else { - buildErr = err - } - } - - if buildErr != nil { - t.Fatalf("cargo build --release failed: %v", buildErr) - } - - for _, prebuilt := range prebuiltCandidates { - if _, err := os.Stat(prebuilt); err == nil { - return prebuilt - } - } - - t.Fatalf("cargo build succeeded but binary not found at any expected path: %v", prebuiltCandidates) + pull := exec.Command("docker", "pull", x402FacilitatorImage) + if out, err := pull.CombinedOutput(); err != nil { + t.Fatalf("pull %s: %v\n%s", x402FacilitatorImage, err, out) } - - t.Skip("x402-rs facilitator not available — set X402_FACILITATOR_BIN or X402_RS_DIR, " + - "or clone https://github.com/x402-rs/x402-rs to ~/Development/R&D/x402-rs") - - return "" + t.Logf("using x402 facilitator image %s", x402FacilitatorImage) } // writeRealFacilitatorConfig writes a temporary config-test.json for the facilitator. diff --git a/plansold/obol-sell-demo.md b/plansold/obol-sell-demo.md new file mode 100644 index 00000000..96c4405d --- /dev/null +++ b/plansold/obol-sell-demo.md @@ -0,0 +1,31 @@ +We have tech for selling things in the obol + stack, for now, its selling access to your + litellm wrapper around a local ai model, + and arbitrary HTTP services. we may invest + in more in future. These services get a + super basic tunnel website, particularly + for the human on / path. I want us to make + an `obol sell demo` command, that deploys a + simple http server that makes it aware to + the buy side that they have gotten past an + x402 protected gate. our landing page of + the tunnel should be improved to explain + what services can be interacted with (e.g. + as we do in /skill.md), and we should maybe + plan specifically for this demo skill to + e.g. take place on a second path/page. here + we show a user how they might ask their + agent to pay for the demo skill and return + the answer, as well as showing them how + they can see the skill in their terminal, + maybe by running a python snippet you + proffer to them or some bash command maybe. + \ + \ + Research the arch here, come up with some + sane choices for how we do this repeatadly + (e.g. in /Users/oisinkyne/code/ObolNetwork/helm-charts/ there's an obol-app chart, which may be a reliable way for you to spawn a http backend, going so far as to persist it to your helmfile so its kept configured properly through restarts/updates etc? open to suggestions though). + + Give me a couple example demo's we can try. We can consider demo's of simpler/more impressive combos for the plan, and we can build a couple of the best. consider we have inference, http, an eth rpc, a wallet address. Make an absolute hello world, then maybe one that can programatically hit the rpc and show basic full node capabilities, then one that also touches the signer, and maybe multiple options where we get inference or agent(openclaw) invocations behind the paywall. ultimately some 'here's an example of your ai agent selling a (on chain read/write related interactions based) skill, here's how to call it via code/agent' is what i'm getting at, giving a first time user a wow-factor for the types of paid services they could sell with the obol stack as a framework. + + ask me any clarifiying questions / arch calls / scope settings and lets get planning. \ No newline at end of file diff --git a/plansold/pr386-review-2026-04-29.md b/plansold/pr386-review-2026-04-29.md new file mode 100644 index 00000000..f8f39da6 --- /dev/null +++ b/plansold/pr386-review-2026-04-29.md @@ -0,0 +1,374 @@ +# PR #386 review and surgery — 2026-04-29 + +This file captures the review process for `integration/pr377-pr381` (PR #386, +"Integrate PR #377 OBOL Permit2 + PR #381 Hermes runtime, harden flows, add +flow-13 dual-stack OBOL"), what got cut, what stayed, and why. It is not +intended to live in the repo long-term; CLAUDE.md says plan/PR-review docs +shouldn't be committed. Keeping it under `plans/` (gitignored) so it survives +the work session and can be pasted into the PR body or archived. + +## Branch state at start of review + +- Tip: `1804d43 fix(model): unify LiteLLM model_name contract, remove double-strip (#389)` + (this commit landed *during* the review; was 64bbe22 when the review started). +- 79 commits ahead of `main`. +- Composed of these stacked PRs: + - **#377** (`integration/pr339-375-376-obol-usdc`) — OBOL Permit2 buy/sell support. Bottom of stack. + - **#381** (`feature/hermes-agent-runtime-refresh`) — Hermes as default agent runtime. + - **#380** (`fix/flow11-injected-buyer-wallet`) — flow-11 buyer-wallet reuse fix. + - **#387** (`fix/erc8004-setmetadata-stale-read`) — `WaitForAgent` for read-side staleness. Already merged into 386. + - **#388** (`feat/obol-x402-hardening-flow-14`) — flow-14, model rank fix, network probe. Already merged into 386. + - **#389** (`fix/model-name-strip-complexity`) — round-trip model_name contract. Already merged into 386. +- Related but **not** on the branch: + - **#379** (`feature/agent-provider-smokes-model-prefer`) — adds `obol model prefer`. Will need to rebase against this branch. + +## Oisin's bottom-of-PR concerns (the starting point) + +From the GitHub PR thread: + +1. **SKILL.md "supplies a signing key to the controller" (lines 464–467)** — + "is this really the best way to do this? would supplying a signature be + more sane?" + "is this key in any way privileged?" +2. **probe.go (line 1)** — "I think the commit that brings this in (and a + lot of the focus of the defensive programming of this pr) is for an AI + fuck up in a clean down of an automated test... 1) chainID is an + insufficient probe for a status of an upstream. 2) erpc does the status + management already, can't we parse it?" +3. **setMetadata diagnosis** — "wrong diagnosis, probably right fix? i can + imagine our erpc returning stale reverts unless we wait a block/request + more exactly a fresh one" +4. **Flow private key in env** — "i dont know why it needs the private key + outside in an env as an example, maybe i'll know when i look" + +Plus three GitHub-Advanced-Security CodeQL hits (the int conversion at +`probe.go:144` was the only legitimate one; vanished with the file). + +## Layered-on findings from my own audit (pre-edit) + +- The controller's `loadRegistrationSigningKey()` codepath (controller.go:1383 + pre-edit) was fully wired — 9 call sites for `SubmitRegister` / + `SetMetadata` / `SetAgentURI` — but **never triggered**: the + `ERC8004_PRIVATE_KEY` / `_FILE` env vars are not set in the deployment + template (`internal/embed/infrastructure/base/templates/x402.yaml`), no + flow sets them, no test sets them, no GitHub Actions sets them. Pure dead + code unless someone manually `kubectl set env`s the controller. +- The CLI's `--private-key-file` flag was real — used by `flow-11-dual-stack.sh` + and `flow-14-live-obol-base-sepolia.sh` for bootstrap. It was redundant + with `obol wallet import` (which exists for Hermes since the Hermes + default-agent landing). +- `model.Rank` is **not** on a hot path. Called only from `Hermes.SetupDefault`, + `Onboard`, `writeDeploymentFiles`, `Sync` (deploy/sync time). Not per chat + request. Oisin's "called all the time?" worry was unfounded. +- `model.Rank` vs the `obol model prefer` branch (#379) — real conflict. + Prefer reorders the LiteLLM ConfigMap so the user's choice sits at index 0; + Rank reorders again on next sync. These two branches will collide and need + reconciling when #379 rebases. Deferred to that PR. +- Hermes 9119 vs 80 — 9119 is the dashboard *container* port; users hit it + through Traefik on port 80 (8080 on macOS due to the privileged port + caveat already in CLAUDE.md). Pre-existing UX, not introduced here. +- `9ef8cda harden existing stack refresh` was a bundle of (a) frontend + v0.1.17-rc.3 → rc.5 bump (already shipped via #383), (b) hardcoded-resource + Helm-ownership migration, (c) kubectl-patch field-manager migration. (b) + and (c) target a one-off transition seen on dev laptops. +- `4323dd3 Preserve LiteLLM config across defaults refresh` is a clean + refactor with real user value — the base helmfile template only contains + `paid/*`, so without preserve+restore every `obol stack up` would wipe + user-added cloud providers. +- `c08c873`/`34e62e5` Docker-network reclaim only fixes the leak inside flow + scripts. `obol stack purge` calls `k3d cluster delete` which leaks the + network when dev-mode pull-through registry-mirror containers are + attached, exhausting the predefined CIDR pool after ~16 cycles. +- The `qwen3:8b` laptop default and the scattered `qwen3:0.6b` examples were + pre-dating the qwen3.5 / qwen3.6 generations. Cross-checked against + ollama.com/library at review time: + - `qwen3.6` smallest is `27b` (17GB); no small variants exist. + - `qwen3.5` has `4b`/`9b`/`27b`/`35b`/`122b`. Validated test baseline is + `qwen3.5:9b` (6.6GB). + - `qwen3:0.6b` does still exist (523MB) but is too small to be a sane + default in 2026. + - The 026d30d commit message claim "qwen3.6:27b-coding-mxfp8 ~13 GB" was + wrong; actual is 31GB. + +## Decisions and dialog, group by group + +### Group A — Controller signing key + `--private-key-file` flag + +The most architecturally consequential cut. Tied directly to memory record: +*"Never extract private keys from remote-signer; use its REST API for all +signing."* + +Two questions surfaced before any edits: + +> **Bullet 1:** what does the flag do? and if its called only on the +> bootstrapping of the flows test, do we need it? +> **Bullet 2:** [the controller env path] — okay delete. (not even in the +> flows nor actions nor anywhere?) +> **Bullet 3:** [the env-var on the CLI flag] sounds like what we want +> instead of the flag in bullet 1, right? + +I presented two options for the flag: + +- **A**: delete the flag and rewrite flow-11/flow-14 to call `obol wallet + import` once at bootstrap, then run `obol sell http` / `obol sell register` + with no flag. One signing surface. +- **B**: keep the flag, fix the misleading Usage and SKILL.md text, keep + the escape hatch. + +Oisin chose **A** explicitly: +> "it might have been written before we got obol hermes wallet import. I +> think remove it all and call import in the flows. (option a)" + +### What got cut + +- `internal/serviceoffercontroller/controller.go`: + - `registrationKey *ecdsa.PrivateKey` field + - `registrationOwnerAddress string` field + - `loadRegistrationSigningKey()` (read of `ERC8004_PRIVATE_KEY`/`_FILE`) + - `syncRegistrationMetadata()` and the metadata-sync block in + `reconcileRegistrationActive` + - The `c.registrationKey != nil` self-register branch and on-chain + tombstone branch — controller now only publishes the registration + document and watches for the externally-driven register tx. + - `crypto/ecdsa` and `crypto` imports +- `cmd/obol/sell.go`: + - `--private-key-file` flag from `obol sell http` and `obol sell register` + - `readPrivateKeyMaterial()` helper + - `registerDirectWithKey()` helper + - `PrivateKeyFile` / `PrivateKeyInput` struct fields + - The "fallback to private key file if no remote-signer" branch on both + paths. Now: remote-signer required; clear error if missing. +- `cmd/obol/sell_test.go`: tests for `readPrivateKeyMaterial` and the + `--private-key-file` flag presence. + +### What stayed + +- Remote-signer-only registration (`registerSponsored`, + `registerDirectViaSigner`) — already correct, untouched. +- `obol wallet import` — already existed (`cmd/obol/wallet.go:44`, + `internal/hermes/wallet_import.go`), untouched. +- `MetadataSynced`, `RegistrationTxHash`, `RegistrationSearchFromBlock` + fields on the CRD — kept for compat; no longer written by the controller. + +### Flow rewrites + +- `flow-11-dual-stack.sh`: insert `alice wallet import --instance obol-agent + --private-key-file --force` before `alice sell http`. Drop + `--private-key-file` from the `sell http` call. Bob already had this + pattern; Alice now matches. +- `flow-14-live-obol-base-sepolia.sh`: same pattern before `obol sell + register`. Drop `--private-key-file` from the call. +- `flow-13-dual-stack-obol.sh`: comment refreshed to stop referencing the + deleted flag. +- `.agents/skills/obol-stack-dev/SKILL.md`: ERC-8004 prerequisites section + rewritten to describe the remote-signer-only path. Setmetadata-revert + section credits #387's `WaitForAgent` as the actual fix and demotes the + "stale Anvil fork" hypothesis from the original PR description to a + less-likely cause. + +### Group B — Drop `internal/network/probe.go` + `obol network status` chainId column + +Oisin: +> "for the probe.go and setMetadata stuff, it sounds like we should be +> taking most of the slop out here and pointing out that the later fix +> actually improved it, and were we to make an `obol network status` +> command again, it should parse erpc." + +Reasoning kept in commit: + +- `eth_chainId` is a poor liveness probe — can't catch lagging upstreams. +- An Anvil fork of base-sepolia returns the same chain id (84532) as the + real chain — exactly the false-positive case the probe was built for. +- eRPC has its own per-upstream health metrics; the right column would + parse those, not redo the work badly in-process. +- The actual symptom that motivated the probe (`ERC721NonexistentToken` + on a freshly-minted agent ID) was eRPC read-side staleness, fixed by + #387's `WaitForAgent`. + +### What got cut + +- `internal/network/probe.go` (deleted) +- `internal/network/probe_test.go` (deleted) +- `cmd/obol/network.go`: `--no-probe` and `--probe-timeout` flags, + `renderUpstreamProbes()`, `uiPrinter` interface, `time` import. + +flow-02 and flow-05 only grep for `eRPC|Pod|Upstream` headers, which +remain — no flow regressions. + +### Group C — Strip Helm-ownership migration code + +Oisin: +> "if we can't see a normal way `kubectl-patch` could be writing the +> llmconfig, and we're happy this was on the fly edits, maybe we strip +> all the rest of this code and tell the dev to not code for hacks unless +> we really need to add resilience for some reason." + +- `kubectl-patch` field manager getting on the ConfigMap was a *historical* + case (pre-`--field-manager=helm` model.go). Current code uses + `--field-manager=helm` so the trigger is unreachable. +- The hardcoded-namespace migration (`migrateBaseHelmOwnership`) had no + resilience case — pure one-off. +- BUT: the preserve+restore+merge mechanism IS a real-resilience case. + `internal/embed/infrastructure/base/templates/llm.yaml:60–77` shows + the base ConfigMap only contains `paid/*`. Without preserve+restore, + every `obol stack up` would wipe user-added cloud providers and custom + endpoints. Kept. + +### What got cut + +- `migrateBaseHelmOwnership()` + `baseHelmResource` + `namespaceArgs()` + (whole block) +- `needsLiteLLMConfigHelmMigration()` and the conditional delete-and-restore + branch in `preserveLiteLLMConfigForHelm` +- `TestNeedsLiteLLMConfigHelmMigration` + +### What stayed (justified resilience) + +- `preserveLiteLLMConfigForHelm` — simplified to always snapshot +- `restoreLiteLLMConfig`, `mergeLiteLLMConfig`, `configMapFieldOwnershipManifest` +- `kubectl.ApplyServerSideForceConflicts` (used by restore) +- `--field-manager=helm` patches in `internal/model/model.go` (one-line + good hygiene; matches helm's manager so future patches don't conflict) +- `TestMergeLiteLLMConfigPreservesChartDefaultsAndPreviousModels` +- The frontend image bump (rc.3 → rc.5) — Oisin: "leave the frontend bump, + i'm not worried about history neatness" +- The "Existing Dev Stack Refresh" section in + `.agents/skills/obol-stack-dev/SKILL.md` — Oisin: "the obol-stack-dev + skill is dev focused tbf so maybe okay" + +### Group D — Reclaim leaked dev k3d networks on `obol stack purge` + +Oisin: +> "yes lets do in purge, maybe down if up will gracefully recreate them, +> idk if that makes sense. only in development mode." + +`obol stack down` calls `k3d cluster stop` which preserves the network +for `Up` to resume; cleaning on Down would break the stop/resume workflow. +Only Purge is wired up. The rare graceful-stop-fails-fall-through-to-delete +path inside Down is left to the next Purge to clean. + +Implementation: lifted `cleanup_k3d_obol_networks` from `flows/lib.sh` +into Go (`reclaimLeakedDevK3dNetworks` in `internal/stack/stack.go`). +Filters to `k3d-obol-stack-*` networks, skips any with a live `*-serverlb` +or `*-server-N` attachment, force-disconnects mirror containers, removes. +Logs `"Reclaimed N leaked dev registry network(s)"` only when a network +was actually freed. + +Added `TestHasLiveK3dCluster` to pin the live-cluster heuristic. + +### Group E — Qwen recommendation cleanup + +Oisin: +> "verify, and if there's a small 3.6, that can be recommended, else, +> 3.5:4b (for the laptop case). Do a little sanity check we're leaving +> behind no qwen3:0.6b's, which are far too old and small these days." + +Web-checked ollama.com/library: + +| Family | Small variants | Notes | +|---|---|---| +| qwen3.6 | none under 27b | 27b = 17 GB | +| qwen3.5 | 0.8b/2b/4b/9b/27b/35b/122b | 4b = 3.4 GB; 9b = 6.6 GB | +| qwen3 | 0.6b/1.7b/4b/8b/14b/30b/32b/235b | older generation | + +Decision tree: +- Capable host (≥32GB): `qwen3.6:27b` 17 GB +- Coding (capable host): `qwen3.6:27b-coding-mxfp8` 31 GB *(was wrongly listed as ~13 GB in 026d30d's commit message)* +- Validated baseline: `qwen3.5:9b` 6.6 GB +- Low-RAM laptop: `qwen3.5:4b` 3.4 GB *(replaces every `qwen3:0.6b` user-facing reference)* +- Reasoning: `deepseek-r1:8b` 4.9 GB +- Lightweight: `gemma3:4b` 3.3 GB + +`internal/model/rank.go` and `internal/model/rank_test.go` deliberately +untouched — the `qwen3:0.6b` references there are regression guards for +users who happen to have it pulled, not recommendations. + +### Group F — Delete `docs/plans/obol-x402-path-comparison.md` + +CLAUDE.md says plan/report docs don't belong in the repo. The 2026-04-11 +benchmark write-up belongs in PR #386's body or a Linear thread, not +`docs/plans/`. Empty `docs/plans/` directory removed too. + +### Group G — This file + +Per Oisin: "Add to your task list that you'll need to write to a file a +transcript of this review process, including stuff like this dialog and +why its being removed." + +Lives under `plans/` (untracked) so it doesn't violate the no-plan-doc +rule. + +## Things deliberately not changed + +- **Rank vs prefer interaction (with #379)** — Real conflict, but #379 is + not on this branch and will need to rebase. Ownership of fixing it sits + with whoever rebases #379. Note from Oisin: *"okay, we'll fix this when + we tackle the obol model prefer branch, which isn't in this at all."* +- **Hermes port 9119** — No action; this is an internal container port, + user lands on port 80 via Traefik. UX issue with `:8080` on macOS + pre-exists, covered already in CLAUDE.md. +- **/etc/hosts URL print** — Oisin: "deal with that later, iirc we print + the right url if we fail to bind." +- **The frontend image bump in 9ef8cda** — left in. Oisin: "leave the + frontend bump, i'm not worried about history neatness." +- **Existing-Dev-Stack-Refresh section in obol-stack-dev SKILL.md** — + kept since the skill is dev-focused. +- **`internal/model/rank.go` / `rank_test.go`** — left intact. Regression + guards for users with `qwen3:0.6b` pulled, not recommendations. +- **`x402-verifier`'s `verifyOnly: true`** invariant — unchanged. Group A + did not touch the payment verification path. +- **Tunnel HTTPRoute hostname restrictions** — verified intact during + review; Group A did not change. + +## Carry-overs / follow-up PRs + +1. **Reconcile `obol model prefer` (#379) with `model.Rank`** when #379 + rebases. Either Rank respects the user's explicit preference, or + Prefer writes a separate `preferred:` field the agent reads first. +2. **`go vet` warnings on `internal/enclave/enclave_darwin.go`** about + `unsafe.Pointer` usage — pre-existing CGo Secure Enclave code, + untouched here. Worth a separate cleanup pass. +3. **Network reclaim on `obol stack down` fallback path** — the rare + `k3d cluster stop` failure → fallback to `k3d cluster delete` could + leak a network in dev mode. Currently relies on the next Purge to + clean up. If this becomes a real problem, add a call there. +4. **Future `obol network status` reachability column** — if we want one, + parse eRPC's existing health metrics rather than redo the chainId + probe in-process. + +## Final status + +`go build ./... && go test ./...` clean after every group. Pre-existing +`go vet` complaints on enclave Darwin code only. + +Files touched across all groups: + +| Group | File | Change | +|---|---|---| +| A | `internal/serviceoffercontroller/controller.go` | strip registration signing path | +| A | `cmd/obol/sell.go` | strip `--private-key-file` flag + helpers | +| A | `cmd/obol/sell_test.go` | drop tests for removed code | +| A | `flows/flow-11-dual-stack.sh` | call `wallet import` before `sell http` | +| A | `flows/flow-13-dual-stack-obol.sh` | comment refresh | +| A | `flows/flow-14-live-obol-base-sepolia.sh` | call `wallet import` before `sell register` | +| A | `.agents/skills/obol-stack-dev/SKILL.md` | rewrite ERC-8004 + setMetadata sections | +| B | `internal/network/probe.go` | deleted | +| B | `internal/network/probe_test.go` | deleted | +| B | `cmd/obol/network.go` | drop probe rendering + flags | +| C | `internal/stack/stack.go` | drop migration helpers | +| C | `internal/stack/stack_test.go` | drop migration test | +| D | `internal/stack/stack.go` | add `reclaimLeakedDevK3dNetworks` (purge) | +| D | `internal/stack/stack_test.go` | add `TestHasLiveK3dCluster` | +| E | `cmd/obol/model.go` | refresh recommendations | +| E | `internal/openclaw/openclaw.go` | no-models hint | +| E | `internal/embed/skills/{sell,monetize-guide}/SKILL.md` | examples | +| E | `docs/getting-started.md` | pull example | +| E | `docs/guides/monetize-inference.md` | every `qwen3:0.6b` updated | +| E | `CLAUDE.md` | example with two ollama-detected models | +| E | `flows/lib.sh` | model removal loop | +| E | `flows/flow-03-inference.sh` | comment cleanup | +| E | `internal/openclaw/monetize_integration_test.go` | comment | +| F | `docs/plans/obol-x402-path-comparison.md` | deleted | +| F | `docs/plans/` | directory removed | + +Six commits planned, one per group A–F. Group G (this transcript) is +intentionally not committed. diff --git a/tests/test_buy_autorefill.py b/tests/test_buy_autorefill.py index 4e99df66..02e38c78 100644 --- a/tests/test_buy_autorefill.py +++ b/tests/test_buy_autorefill.py @@ -7,11 +7,11 @@ from pathlib import Path from unittest import mock -MODULE_PATH = Path(__file__).resolve().parents[1] / "internal" / "embed" / "skills" / "buy-inference" / "scripts" / "buy.py" +MODULE_PATH = Path(__file__).resolve().parents[1] / "internal" / "embed" / "skills" / "buy-x402" / "scripts" / "buy.py" def load_buy_module(): - spec = importlib.util.spec_from_file_location("buy_inference_buy", MODULE_PATH) + spec = importlib.util.spec_from_file_location("buy_x402", MODULE_PATH) module = importlib.util.module_from_spec(spec) assert spec.loader is not None sys.modules[spec.name] = module @@ -598,5 +598,33 @@ def test_cmd_status_reflects_live_remaining_and_spent(self): self.assertIn("Auths spent: 9", rendered) +class SignerCompatRegressionTest(unittest.TestCase): + """Locks in the contract between buy.py and the remote-signer chart pin. + + From remote-signer chart 0.3.2 (image v0.2.1) onward, /sign/.../typed-data + returns canonical Ethereum signatures with v in {0x1b, 0x1c}. buy.py used + to defensively renormalize v=0/1 → v=27/28 via + _normalize_signature_recovery, but that workaround was removed once the + signer started emitting canonical v at the source. + + Re-introducing the workaround on top of a v0.2.1+ signer would double-add + 27 (producing v=54) and corrupt every payment authorization. If this test + fires, either: + a) the workaround was reintroduced — delete it; or + b) the remote-signer chart pin in internal/agentruntime/charts.go was + downgraded below 0.3.2 — in that case, restore both the chart pin + AND the workaround as a single atomic change. + """ + + def test_no_signature_normalization_workaround(self): + mod = load_buy_module() + self.assertFalse( + hasattr(mod, "_normalize_signature_recovery"), + "buy.py must not define _normalize_signature_recovery; the " + "remote-signer chart 0.3.2+ emits canonical v=27/28 and a second " + "+27 would produce v=54.", + ) + + if __name__ == "__main__": unittest.main()