This guide walks you through exposing a local LLM as a paid API endpoint using the Obol Stack. By the end, you'll have:
- A local Ollama model serving inference
- An x402 payment gate requiring USDC (default) or OBOL per request
- A public URL via Cloudflare tunnel
- An ERC-8004 agent registration document for discoverability
Note
--per-mtok is supported for inference pricing, but phase 1 still charges an
approximate flat request price derived as perMTok / 1000 using a fixed
1000 tok/request assumption. Exact token metering is not implemented yet.
Important
The monetize subsystem is alpha software. If you encounter an issue, please open a GitHub issue.
Important
ServiceOffer is the source of truth. serviceoffer-controller owns
reconciliation, RegistrationRequest isolates registration side effects, and
x402-verifier derives live routes directly from published ServiceOffers.
SELLER (obol stack cluster)
obol sell http --> ServiceOffer CR --> serviceoffer-controller reconciles:
1. ModelReady (pull model in Ollama)
2. UpstreamHealthy (health-check Ollama)
3. PaymentGateReady (create x402 Middleware)
4. RoutePublished (create HTTPRoute -> Traefik gateway)
5. Registered (ERC-8004 on-chain, optional)
6. Ready (all conditions True)
CF Quick Tunnel -----------> Traefik Gateway
https://<id>.trycloudflare.com
/services/<name>/* -> x402 -> Ollama
/.well-known/*.json -> ERC-8004 doc
/ -> obol-frontend
/rpc -> eRPC
BUYER (curl / blockrun-llm SDK)
1. GET /.well-known/agent-registration.json -> discover services
2. POST /services/<name>/v1/chat/completions -> 402 Payment Required
3. Sign EIP-712 payment + retry with header -> 200 + inference
- Docker -- Docker Engine (Linux) or Docker Desktop (macOS)
- Obol Stack -- installed via
bash <(curl -s https://stack.obol.org) - Ollama -- running on the host (
ollama serve) - Base Sepolia wallet -- with ETH for gas and USDC for testing payments
- USDC (Base Sepolia):
0x036CbD53842c5426634e7929541eC2318f3dCF7e - Faucets: docs.base.org/tools/faucets
- USDC (Base Sepolia):
Start from a clean state:
# Initialize and start (automatically deploys obol-agent, configures LiteLLM
# with Ollama models, and starts a Cloudflare tunnel — no manual setup needed)
obol stack init
obol stack up
# Wait for all pods to be ready
obol kubectl get pods -AVerify the key components:
| Check | Command | Expected |
|---|---|---|
| Cluster nodes | obol kubectl get nodes |
1 node Ready |
| Agent running | obol kubectl get pods -n hermes-obol-agent |
Running |
| CRD installed | obol kubectl get crd serviceoffers.obol.org |
Found |
| x402 verifier | obol kubectl get pods -n x402 |
2 replicas Running |
| Traefik gateway | obol kubectl get gateway -n traefik |
traefik-gateway |
| LiteLLM running | obol kubectl get pods -n llm |
Running |
| Ollama reachable | curl -s http://localhost:11434/api/tags |
JSON model list |
Make sure the model is available in your host Ollama:
# Pull a model (qwen3.5:9b is the default agent model)
ollama pull qwen3.5:9b
# Or a smaller model for quick testing
ollama pull qwen3.5:4b
# Verify it's available
curl -s http://localhost:11434/api/tags | python3 -m json.toolobol stack up automatically configures LiteLLM with all available Ollama models (no manual obol model setup needed). If you pull a new model after the cluster is running, restart LiteLLM to pick it up:
obol kubectl rollout restart deployment/litellm -n llmNote
The agent can also pull models automatically during reconciliation via the Ollama API, but pre-pulling avoids the wait when the ServiceOffer is created.
Configure the x402 verifier with your wallet and chain:
obol sell pricing \
--wallet 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--chain base-sepoliaThis patches the x402-pricing ConfigMap in the x402 namespace. The Stakater Reloader automatically restarts the verifier pod when the config changes.
Verify:
obol kubectl get cm x402-pricing -n x402 -o yaml
obol kubectl get pods -n x402 # verifier should have a recent restartSelf-hosted facilitator -- if you're running your own x402 facilitator (see Part 3), pass the URL:
obol sell pricing \
--wallet 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--chain base-sepolia \
--facilitator-url http://host.k3d.internal:4040Declare your inference service as a Kubernetes custom resource:
obol sell http my-qwen \
--wallet 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--chain base-sepolia \
--per-request 0.001 \
--namespace llm \
--upstream ollama \
--port 11434Ollama doesn't serve /health or /health/readiness — only / returns
2xx — so obol sell http automatically defaults --health-path to / when
--upstream ollama is left at its default /health. No extra flag is
needed for the example above; pass --health-path yourself only if you're
fronting a different health endpoint.
By default this also registers the seller agent on ERC-8004. Use
--no-register only for local or private-only testing where on-chain
discovery is intentionally skipped.
If you want to price by million tokens instead of explicitly setting a flat
request price, use --per-mtok. In phase 1, the verifier still enforces a
derived per-request price:
obol sell http my-qwen \
--wallet 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--chain base-sepolia \
--per-mtok 1.25 \
--namespace llm \
--upstream ollama \
--port 11434Both examples default to USDC payments. To accept OBOL token (Ethereum mainnet, Permit2) instead:
obol sell http my-qwen \
--wallet 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--token OBOL \
--per-request 0.001 \
--namespace llm \
--upstream ollama \
--port 11434When --token OBOL is used without --chain, the chain defaults to ethereum.
For the USDC examples above, the pricing config stores both values:
- source model:
perMTok = 1.25 USDC / 1M tokens - enforced phase-1 charge:
price = 0.00125 USDC / request - approximation input:
approxTokensPerRequest = 1000
For OBOL release smokes, verify the generated payment config carries the selected token metadata end to end instead of silently falling back to USDC wording or USDC's ERC-3009 domain. The 402 requirement should expose the OBOL token address, atomic OBOL amount, and Permit2/EIP-712 metadata needed by the buyer sidecar.
The stack now treats on-chain registration as part of the default selling flow:
ModelReady [check] Agent checks /api/tags, model already cached
UpstreamHealthy [check] Agent health-checks ollama:11434
PaymentGateReady [check] Shared x402 seller gateway available
RoutePublished [check] Creates HTTPRoute so-my-qwen -> x402-verifier backend
Registered [check] ERC-8004 registration completes on-chain
Ready [check] All required conditions True
Watch the progress:
# Check conditions
obol sell status my-qwen --namespace llm
# Verify Kubernetes resources
obol kubectl get serviceoffer my-qwen -n llm
obol kubectl get httproute -n llm # so-my-qwenobol stack up automatically starts a Cloudflare Quick Tunnel. Get the public URL:
obol tunnel status
# -> https://<id>.trycloudflare.comIf the tunnel isn't running or you want a fresh URL:
obol tunnel restartGet a permanent URL once you're selling. Quick-tunnel URLs change on every restart, so buyers who bookmarked the old one hit errors. When you're ready to attract buyers, create a stable hostname: create a tunnel in the Cloudflare dashboard (Networks → Tunnels), route its Public Hostname to
http://traefik.traefik.svc.cluster.local:80, then runobol tunnel setup --hostname stack.example.com <connector-token>(paste the wholecloudflared tunnel run --token …line — Obol extracts the token). This needs only a least-privilege connector token, not an account-wide API key.
Test each route to confirm everything is wired correctly:
export TUNNEL_URL="https://<id>.trycloudflare.com"
# Frontend (200)
curl -s -o /dev/null -w "%{http_code}" "$TUNNEL_URL/"
# eRPC (200 + JSON-RPC)
curl -s -X POST "$TUNNEL_URL/rpc" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' | jq .result
# Monetized endpoint (402 -- payment required!)
curl -s -w "\nHTTP %{http_code}" -X POST \
"$TUNNEL_URL/services/my-qwen/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"Hello"}]}'
# ERC-8004 registration document (200)
curl -s "$TUNNEL_URL/.well-known/agent-registration.json" | jq .You can also verify locally (bypasses Cloudflare):
curl -s -w "\nHTTP %{http_code}" -X POST \
"http://obol.stack:8080/services/my-qwen/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"Hello"}]}'A 402 Payment Required response confirms the x402 gate is working. The response body contains the payment requirements:
{
"x402Version": 2,
"error": "Payment required for this resource",
"accepts": [{
"scheme": "exact",
"network": "eip155:84532",
"amount": "1000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"description": "Payment required for /services/my-qwen/v1/chat/completions",
"maxTimeoutSeconds": 300,
"extra": {"name": "USDC", "version": "2"}
}]
}For this USDC example, amount is in USDC micro-units (6 decimals):
1000 = 0.001 USDC. For an OBOL/Permit2 route, asset should be the OBOL
token address, amount should be atomic OBOL units, and extra should carry
the token/Permit2 metadata the buyer uses for signing.
The seller-side verifier now exports Prometheus metrics on its existing Service:
obol kubectl get --raw /api/v1/namespaces/x402/services/x402-verifier:8080/proxy/metrics | headPrometheus scrapes it through a ServiceMonitor in x402. Key verifier metrics:
obol_x402_verifier_requests_totalobol_x402_verifier_payment_required_totalobol_x402_verifier_payment_verified_totalobol_x402_verifier_payment_failed_totalobol_x402_verifier_charged_requests_total
Buyers who open your tunnel URL (or hit a paid route in a browser) see your
storefront and checkout-style 402 pages. Brand them once and the identity
applies everywhere — storefront, paywall pages, wallet sign-in, /api docs,
per-offer landing pages:
obol sell info set \
--display-name "Acme Labs" \
--tagline "Paid inference, no middlemen." \
--logo-file ./logo.png \
--theme dark \
--description 'Frontier-quality open models, served from **our GPUs**.'| Default (light theme) | Branded |
|---|---|
![]() |
![]() |
Themes: light (default), dark, obol, plus --accent '#hex'. Descriptions
accept a safe markdown subset. --css-file injects a custom stylesheet against
the stable data-obol attributes, and offers bound to their own hostname can
override the identity per origin (obol sell info set --hostname <host> ...).
The seller's stack publishes an ERC-8004 agent registration document:
curl -s "$TUNNEL_URL/.well-known/agent-registration.json" | jq .This returns a JSON document describing the agent's services, supported payment methods, and endpoints.
Send a request without payment:
curl -s -X POST "$TUNNEL_URL/services/my-qwen/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"Hello"}]}' \
-D - 2>&1 | head -30The response is 402 Payment Required with a JSON body containing the payment requirements (wallet, chain, amount, facilitator URL).
Using the blockrun-llm Python SDK:
pip install blockrun-llmfrom blockrun_llm import LLMClient
import os
client = LLMClient(
private_key=os.environ["CONSUMER_PRIVATE_KEY"],
api_url=os.environ["TUNNEL_URL"]
)
# Automatically: 402 -> sign EIP-712 -> retry with payment header -> 200
response = client.chat("qwen3.5:9b", "Explain Ethereum in one sentence.")
print(f"Response: {response}")
print(f"Session cost: ${client._session_total_usd}")The SDK handles the full x402 flow:
- Sends the request
- Receives 402 with payment requirements
- Signs the token-specific EIP-712 payment payload: ERC-3009 for USDC or Permit2 for OBOL routes
- Retries with the
X-PAYMENTheader (base64-encoded x402 envelope) - The seller-owned x402 gateway verifies the payment with the facilitator
- The seller gateway forwards the request to the protected upstream
- After upstream success, the seller gateway settles the selected token on-chain
- Returns the inference response
Manual flow with curl -- for debugging or custom integrations:
# Step 1: Get payment requirements from the 402 response
curl -s -X POST "$TUNNEL_URL/services/my-qwen/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"Hello"}]}'
# Step 2: Sign the EIP-712 payment (requires SDK or custom code)
# The 402 body contains: payTo, amount, asset, network, extra.name, extra.version
# USDC routes sign TransferWithAuthorization (ERC-3009) against the USDC domain.
# OBOL routes sign the configured Permit2 payload against the OBOL/token metadata
# published in the payment requirement.
# Step 3: Retry with payment header
curl -s -X POST "$TUNNEL_URL/services/my-qwen/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "X-PAYMENT: <base64-encoded-x402-envelope>" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"Hello"}]}'
# -> 200 OK + inference responseAfter a successful paid request, verify the selected token transfer on-chain using
Foundry's cast. For the default USDC example:
USDC=0x036CbD53842c5426634e7929541eC2318f3dCF7e
BUYER=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720
PAYEE=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
# Check buyer balance (should have decreased by 1000 micro-units = 0.001 USDC)
cast call "$USDC" "balanceOf(address)(uint256)" "$BUYER" --rpc-url http://localhost:8545
# Check payee balance (should have increased by 1000 micro-units)
cast call "$USDC" "balanceOf(address)(uint256)" "$PAYEE" --rpc-url http://localhost:8545The same payment flow works through the public Cloudflare tunnel URL:
export TUNNEL_URL=$(obol tunnel status | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com')
# 402 through tunnel
curl -s -w "\nHTTP %{http_code}" -X POST \
"$TUNNEL_URL/services/my-qwen/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"Hello"}]}'
# Paid request through tunnel (supported production path)
# The buyer talks to LiteLLM, which routes paid models through the in-pod
# x402-buyer sidecar. The sidecar performs the paid retry. The seller-owned
# shared x402 gateway verifies the payment, forwards to the upstream, and
# settles only after upstream success.This proves the supported public paid path: Buyer → LiteLLM → x402-buyer → Cloudflare/Traefik → shared x402 gateway → upstream → seller-side settle → 200 + inference.
The x402 facilitator verifies and settles payments on-chain. The stack currently defaults to the Obol-operated facilitator, but the validated Base Sepolia flow in this guide uses https://facilitator.x402.rs because that endpoint currently advertises Base Sepolia exact support.
- Reliability -- no dependency on a third-party service
- Sovereignty -- payments settle through your infrastructure
- Testing -- use Base Sepolia without depending on external uptime
When testing with an Anvil fork of Base Sepolia, Anvil's deterministic test accounts (0xf39F..., 0x7099..., etc.) often have contracts deployed at their addresses on the live network. Before using them for x402 payments, clear the code so they behave as EOAs:
# Clear contract code at consumer address to make it an EOA
cast rpc anvil_setCode 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 0x --rpc-url http://localhost:8545Without this, the USDC SignatureChecker will attempt EIP-1271 contract signature verification instead of ecrecover, causing "FiatTokenV2: invalid signature" errors.
To fund the consumer with USDC on a forked chain, use anvil_setStorageAt to write the balance directly. This avoids relying on testnet faucets that may be unavailable on a local fork:
# Fund consumer with USDC (Base Sepolia USDC: 0x036CbD53842c5426634e7929541eC2318f3dCF7e)
# Storage slot for balanceOf mapping is slot 9 in FiatTokenV2
CONSUMER=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720
USDC=0x036CbD53842c5426634e7929541eC2318f3dCF7e
# Compute storage slot: keccak256(abi.encode(address, uint256(9)))
SLOT=$(cast index address "$CONSUMER" 9)
# Set balance to 1000 USDC (1000 * 10^6, 6 decimals)
cast rpc anvil_setStorageAt "$USDC" "$SLOT" \
"0x000000000000000000000000000000000000000000000000000000003B9ACA00" \
--rpc-url http://localhost:8545
# Verify
cast call "$USDC" "balanceOf(address)(uint256)" "$CONSUMER" --rpc-url http://localhost:8545The x402-rs project provides a Rust-based facilitator. Run it as a Docker container on the host:
# Clone and build
cd ~/Development/R&D
git clone https://github.com/x402-rs/x402-rs.git
cd x402-rs
cargo build --release
# Create config for Base Sepolia
# The facilitator wallet needs Base Sepolia ETH for gas when settling payments.
export FACILITATOR_PRIVATE_KEY="0x<your-funded-private-key>"
cat > config-sepolia.json << EOF
{
"port": 4040,
"host": "0.0.0.0",
"chains": {
"eip155:84532": {
"eip1559": true,
"flashblocks": false,
"signers": ["$FACILITATOR_PRIVATE_KEY"],
"rpc": [{"http": "https://sepolia.base.org", "rate_limit": 25}]
}
},
"schemes": [
{"id": "v1-eip155-exact", "chains": "eip155:*"},
{"id": "v2-eip155-exact", "chains": "eip155:*",
"config": {"eip2612_gas_sponsoring": true}}
]
}
EOF
# Start the facilitator
./target/release/x402-facilitator --config config-sepolia.jsonTip
For testing with Anvil, point the RPC at your local fork:
"rpc": [{"http": "http://127.0.0.1:8545", "rate_limit": 50}]Important
eip2612_gas_sponsoring: true shifts gas to the facilitator signer.
The OBOL Permit2 path settles permit + transferFrom against an ERC20Permit token in a single outer transaction; the facilitator pays gas for the permit step so the buyer never has to hold the chain's native asset. In practice the facilitator's signer wallet ($FACILITATOR_PRIVATE_KEY) bears that cost. If the signer balance drops below the gas needed for the next settlement, all OBOL settlements fail and paying buyers see opaque facilitator errors with no on-chain trace.
Operators promoting from RC to production must:
- Monitor the facilitator signer's native-asset balance on every chain it advertises (
eip155:1,eip155:8453,eip155:84532for the OBOL chart). - Alarm well above empty — at least
100 × max_settlement_gas_price × max_settlement_gasper chain, refilled before it trips. - Have a runbook for refilling without taking the facilitator down.
The chart-side change to expose this metric to Prometheus is tracked separately in obol-infrastructure. Until it lands, monitor by polling eth_getBalance against the signer address.
Verify it's running:
curl -s http://localhost:4040/supported | jq .Point the x402 verifier at your self-hosted facilitator:
obol sell pricing \
--wallet 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--chain base-sepolia \
--facilitator-url http://host.k3d.internal:4040The k3d cluster can reach the host via host.k3d.internal. The HTTPS exemption allowlist permits HTTP for this address.
Note
You can also set the facilitator URL via the X402_FACILITATOR_URL
environment variable.
# List all offers across namespaces
obol sell list --namespace llm
# Detailed status with conditions
obol sell status my-qwen --namespace llm
# Cluster-wide pricing and registration status
obol sell statusStop an offer gracefully so buyers can wind down before the route disappears:
obol sell stop my-qwen --namespace llm # default: 1h grace
obol sell stop my-qwen --namespace llm --grace 30m # custom grace
obol sell stop my-qwen --namespace llm --force # tear down immediatelyobol sell stop sets spec.drainAt on the ServiceOffer. While the offer is
draining:
/skill.mdand/.well-known/agent-registration.jsonadvertise the offer withavailable: falseanddrainEndsAt: <RFC3339>, so external discovery (and ERC-8004 reputation scorers) can react before traffic disappears.- The HTTPRoute and x402 payment gate stay up so in-flight buyers can complete payments.
- When the grace period elapses, the controller tears down the route and marks
Draining=Falsereason=Drained.
The ServiceOffer CR and any ERC-8004 registration remain intact. Use
obol sell delete to remove the offer entirely.
--force (alias: --now) skips the drain window — useful when you want the
abrupt-teardown behavior of the legacy obol.org/paused annotation, for
example to reclaim the path immediately. Note that abrupt teardown is a worse
reputation signal for on-chain buyers than a graceful drain.
# Delete with confirmation prompt
obol sell delete my-qwen --namespace llm
# Delete without confirmation
obol sell delete my-qwen --namespace llm --forceDeletion:
- Removes the ServiceOffer CR
- Cascades Middleware and HTTPRoute via OwnerReferences
- Deactivates the ERC-8004 registration (sets
active=false)
Verify cleanup:
obol kubectl get so my-qwen -n llm # NotFound
obol kubectl get httproute so-my-qwen -n llm # NotFoundThe x402 gateway now acts as the seller-owned resource server in front of the real upstream:
Client
|
POST /services/my-qwen/v1/chat/completions
|
v
Traefik Gateway
|
--> Route match /services/my-qwen/*
|
v
x402-verifier.x402.svc:8080
|
+-- No payment header? Return 402 + requirements
+-- Payment header? POST facilitator/verify
+-- Valid? Proxy to upstream
+-- Upstream success? POST facilitator/settle
+-- Return 200 + X-PAYMENT-RESPONSE
+------------+
| ModelReady | (pull model via Ollama API)
+-----+------+
|
+--------v---------+
| UpstreamHealthy | (health-check service)
+--------+---------+
|
+----------v-----------+
| PaymentGateReady | (create Middleware)
+----------+-----------+
|
+---------v----------+
| RoutePublished | (create HTTPRoute)
+---------+----------+
|
+---------v----------+
| Registered | (ERC-8004, default unless --no-register)
+---------+----------+
|
+-----v-----+
| Ready | (all conditions True)
+-----------+
When serviceoffer-controller reconciles a ServiceOffer named my-qwen in namespace llm:
| Resource | Kind | Namespace | Name |
|---|---|---|---|
| ServiceOffer | obol.org/v1alpha1 |
llm |
my-qwen |
| Middleware | traefik.io/v1alpha1 |
llm |
x402-my-qwen |
| HTTPRoute | gateway.networking.k8s.io/v1 |
llm |
so-my-qwen |
The Middleware and HTTPRoute have ownerReferences pointing at the ServiceOffer, so they are garbage-collected on deletion.
The x402 verifier reads cluster-wide payment defaults from the
x402-pricing ConfigMap:
wallet: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
chain: "base-sepolia"
facilitatorURL: "https://facilitator.x402.rs"
verifyOnly: true
routes:
- pattern: "/services/my-qwen/*"
price: "0.001"
description: "my-qwen inference"
payTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
network: "base-sepolia"Published offer routes are derived from ServiceOffer resources rather than
being maintained manually in this ConfigMap. Per-offer payTo and network
can still override the cluster defaults.
Check ServiceOffer conditions and controller logs:
obol sell status my-qwen --namespace llm
obol kubectl logs -n x402 -l app=serviceoffer-controller --tail=50The ServiceOffer may not be Ready, or the request path may not match the
published offer. Check the offer and the resources it owns:
obol sell status my-qwen --namespace llm
obol kubectl get middleware x402-my-qwen -n llm
obol kubectl get httproute so-my-qwen -n llm
obol kubectl logs -n x402 -l app=x402-verifier --tail=10If using a self-hosted facilitator on the host, verify the k3d bridge:
obol kubectl run -n x402 curl-test --rm -it --restart=Never \
--image=curlimages/curl -- \
curl -s http://host.k3d.internal:4040/healthVerify the model is available in your host Ollama:
curl -s http://localhost:11434/api/tags | python3 -c "import sys,json; [print(m['name']) for m in json.load(sys.stdin)['models']]"LiteLLM discovers models from the configured providers. If you pulled a model after the cluster started, you may need to restart LiteLLM:
obol kubectl rollout restart deployment/litellm -n llmCloudflare Quick Tunnels assign a random URL that changes on restart. Get the current URL:
obol tunnel statusThis error has two common causes:
1. Contract code at buyer address -- On Anvil forks, deterministic test accounts (0xf39F..., 0x7099..., 0xa0Ee..., etc.) often have contract code at their addresses from the live chain state. The USDC SignatureChecker tries EIP-1271 contract verification instead of ecrecover. Clear the code:
cast rpc anvil_setCode <buyer-address> 0x --rpc-url http://localhost:85452. Wrong EIP-712 domain name -- The USDC contract on Base Sepolia uses the domain name "USDC" (not "USD Coin" like on Ethereum mainnet). Verify:
cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e "name()(string)" --rpc-url http://localhost:8545
# -> "USDC"Ensure your EIP-712 signing code uses the correct domain: {name: "USDC", version: "2", chainId: 84532, verifyingContract: 0x036CbD53842c5426634e7929541eC2318f3dCF7e}.
See Part 3.2 for full Anvil setup details.
The x402 verifier returns 400 when the payment payload is malformed. Ensure the X-Payment header contains the full x402 envelope with all required fields:
x402Version(integer, e.g.,1)scheme(e.g.,"exact")network(e.g.,"base-sepolia")payload(the signed authorization data)resource(the URL path being paid for)
Missing any of these fields causes the facilitator to reject the payment before signature verification.
The default agent should be able to create and patch Obol resources without a manual RBAC patch. Verify the installed read and write bindings include the default Hermes/OpenClaw service accounts:
kubectl get clusterrolebinding openclaw-monetize-read-binding \
-o jsonpath='{.subjects}'
kubectl get clusterrolebinding openclaw-monetize-write-binding \
-o jsonpath='{.subjects}'If either binding is missing the expected subjects, treat it as a stack installation bug and rerun the smoke flow after fixing the embedded RBAC manifest. Do not paper over smoke-test failures with an ad hoc patch.
| Command | Description |
|---|---|
obol sell pricing --wallet ... --chain ... |
Configure x402 payment settings |
obol sell http <name> --wallet ... --chain ... --per-request ... --upstream ... --port ... |
Create a ServiceOffer and register by default |
obol sell list |
List all ServiceOffers |
obol sell status <name> -n <ns> |
Show conditions for an offer |
obol sell stop <name> -n <ns> [--grace 1h] [--force] |
Drain an offer (advertise wind-down via discovery, then tear down the route after the grace period). --force/--now skips the grace window. |
obol sell delete <name> -n <ns> |
Delete an offer and cleanup |
obol sell status |
Show cluster pricing and registration |
obol sell register --private-key-file ... |
Advanced/manual registration or repair path |
| Resource | Namespace | Purpose |
|---|---|---|
x402-pricing ConfigMap |
x402 |
Cluster-wide wallet, chain, and facilitator settings |
x402-secrets Secret |
x402 |
Wallet address |
x402-verifier Deployment |
x402 |
Shared seller-owned x402 gateway and legacy /verify endpoint |
serviceoffer-controller Deployment |
x402 |
Reconciles ServiceOffers into published resources |
serviceoffers.obol.org CRD |
(cluster) | ServiceOffer custom resource definition |
traefik-gateway Gateway |
traefik |
Main ingress gateway |
| Variable | Default | Description |
|---|---|---|
X402_WALLET |
(none) | USDC recipient wallet address |
X402_FACILITATOR_URL |
(none) | Override facilitator URL |
CONSUMER_PRIVATE_KEY |
(none) | Buyer wallet key (for SDK) |

