Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 287 additions & 50 deletions cmd/obol/sell.go

Large diffs are not rendered by default.

104 changes: 103 additions & 1 deletion cmd/obol/sell_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
x402verifier "github.com/ObolNetwork/obol-stack/internal/x402"
"github.com/ethereum/go-ethereum/crypto"
"github.com/urfave/cli/v3"
)

Expand Down Expand Up @@ -204,7 +209,7 @@ func TestSellHTTP_Flags(t *testing.T) {
"wallet", "chain", "price", "per-request", "per-mtok", "per-hour",
"namespace", "upstream", "port", "health-path", "path",
"max-timeout",
"register", "register-name", "register-description", "register-image",
"register", "no-register", "register-name", "register-description", "register-image", "private-key-file",
)

assertStringDefault(t, flags, "chain", "base")
Expand All @@ -214,6 +219,103 @@ func TestSellHTTP_Flags(t *testing.T) {
assertIntDefault(t, flags, "max-timeout", 300)
}

func TestBuildSellHTTPRegistrationConfig_DefaultEnabled(t *testing.T) {
reg, enabled, err := buildSellHTTPRegistrationConfig("demo", sellHTTPRegistrationInput{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !enabled {
t.Fatal("registration should be enabled by default")
}
if reg["enabled"] != true {
t.Fatalf("registration.enabled = %v, want true", reg["enabled"])
}
if reg["name"] != "demo" {
t.Fatalf("registration.name = %v, want demo", reg["name"])
}
}

func TestBuildSellHTTPRegistrationConfig_NoRegisterConflicts(t *testing.T) {
_, _, err := buildSellHTTPRegistrationConfig("demo", sellHTTPRegistrationInput{
NoRegister: true,
Name: "custom",
})
if err == nil {
t.Fatal("expected error for --no-register with registration-specific flags")
}
}

func TestReadPrivateKeyMaterial_RawKey(t *testing.T) {
key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
raw := "0x" + fmt.Sprintf("%x", crypto.FromECDSA(key))
gotKey, gotAddr, err := readPrivateKeyMaterial(raw)
if err != nil {
t.Fatalf("readPrivateKeyMaterial: %v", err)
}
if gotKey != raw {
t.Fatalf("got key = %q, want %q", gotKey, raw)
}
if gotAddr != crypto.PubkeyToAddress(key.PublicKey).Hex() {
t.Fatalf("got addr = %q, want %q", gotAddr, crypto.PubkeyToAddress(key.PublicKey).Hex())
}
}

func TestReadPrivateKeyMaterial_File(t *testing.T) {
key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
raw := "0x" + fmt.Sprintf("%x", crypto.FromECDSA(key))
path := filepath.Join(t.TempDir(), "key.txt")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
gotKey, gotAddr, err := readPrivateKeyMaterial(path)
if err != nil {
t.Fatalf("readPrivateKeyMaterial: %v", err)
}
if gotKey != raw {
t.Fatalf("got key = %q, want %q", gotKey, raw)
}
if gotAddr != crypto.PubkeyToAddress(key.PublicKey).Hex() {
t.Fatalf("got addr = %q, want %q", gotAddr, crypto.PubkeyToAddress(key.PublicKey).Hex())
}
}

func TestReadPrivateKeyMaterial_Invalid(t *testing.T) {
if _, _, err := readPrivateKeyMaterial("0xdeadbeef"); err == nil {
t.Fatal("expected error for invalid private key")
}
}

func TestServiceOfferStatusLines(t *testing.T) {
offer := monetizeapi.ServiceOffer{
Status: monetizeapi.ServiceOfferStatus{
Endpoint: "/services/demo",
AgentID: "5008",
RegistrationTxHash: "0xabc",
Conditions: []monetizeapi.Condition{
{Type: "Registered", Status: "True", Reason: "Registered", Message: "Published registration document and recorded agent 5008"},
},
},
}
lines := serviceOfferStatusLines("llm", "demo", offer)
joined := strings.Join(lines, "\n")
for _, want := range []string{
"ServiceOffer: llm/demo",
"Agent ID: 5008",
"Registration Tx: 0xabc",
"type: Registered",
} {
if !strings.Contains(joined, want) {
t.Fatalf("status lines missing %q\n%s", want, joined)
}
}
}

func TestSellStop_Structure(t *testing.T) {
cfg := newTestConfig(t)
cmd := sellCommand(cfg)
Expand Down
19 changes: 11 additions & 8 deletions docs/guides/monetize-inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ obol sell http my-qwen \
--port 11434
```

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:
Expand All @@ -177,14 +181,14 @@ That stores both values in the pricing config:
- enforced phase-1 charge: `price = 0.00125 USDC / request`
- approximation input: `approxTokensPerRequest = 1000`

The agent automatically reconciles the offer through six stages:
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] Creates Middleware x402-my-qwen + adds pricing route
RoutePublished [check] Creates HTTPRoute so-my-qwen -> ollama backend
Registered -- Skipped (--register not set)
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
```

Expand All @@ -196,7 +200,6 @@ obol sell status my-qwen --namespace llm

# Verify Kubernetes resources
obol kubectl get serviceoffer my-qwen -n llm
obol kubectl get middleware -n llm # x402-my-qwen
obol kubectl get httproute -n llm # so-my-qwen
```

Expand Down Expand Up @@ -616,7 +619,7 @@ Traefik Gateway
+---------+----------+
|
+---------v----------+
| Registered | (ERC-8004, optional)
| Registered | (ERC-8004, default unless --no-register)
+---------+----------+
|
+-----v-----+
Expand Down Expand Up @@ -781,13 +784,13 @@ Replace `openclaw-obol-agent` with your actual OpenClaw namespace if different.
| Command | Description |
|---------|-------------|
| `obol sell pricing --wallet ... --chain ...` | Configure x402 payment settings |
| `obol sell http <name> --wallet ... --chain ... --per-request ... --upstream ... --port ...` | Create a ServiceOffer |
| `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>` | Pause an offer (remove pricing route) |
| `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 ...` | Register on ERC-8004 |
| `obol sell register --private-key-file ...` | Advanced/manual registration or repair path |

### Key Kubernetes Resources

Expand Down
2 changes: 2 additions & 0 deletions flows/flow-06-sell-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ run_step_grep "sell http flow-qwen" \
"$OBOL" sell http flow-qwen \
--wallet "$SELLER_WALLET" \
--chain "$CHAIN" \
--no-register \
--per-request 0.001 \
--namespace llm \
--upstream ollama \
Expand All @@ -164,6 +165,7 @@ run_step_grep "sell http flow-qwen" \
step "sell http idempotent: re-run shows 'updated' not 'created'"
rerun_out=$("$OBOL" sell http flow-qwen \
--wallet "$SELLER_WALLET" --chain "$CHAIN" \
--no-register \
--per-request 0.001 --namespace llm \
--upstream ollama --port 11434 2>&1) || true
if echo "$rerun_out" | grep -q "ServiceOffer.*updated"; then
Expand Down
45 changes: 18 additions & 27 deletions flows/flow-11-dual-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,16 @@ else
fail "CA bundle empty or too small: $ca_size bytes"
fi

step "Alice: add Base Sepolia RPC to eRPC (for registration + metadata sync)"
alice network add base-sepolia --endpoint https://sepolia.base.org --allow-writes 2>&1 | tail -2
# eRPC needs a restart to pick up the new chain config
alice kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true
alice kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true
pass "Base Sepolia RPC added to eRPC (with write access)"

step "Alice: create ServiceOffer"
KEY_FILE=$(mktemp)
echo "$SIGNER_KEY" > "$KEY_FILE"
alice sell http alice-inference \
--wallet "$ALICE_WALLET" \
--chain base-sepolia \
Expand All @@ -353,11 +362,12 @@ alice sell http alice-inference \
--upstream litellm \
--port 4000 \
--health-path /health/readiness \
--register \
--register-name "Dual-Stack Test Inference" \
--register-description "Integration test: local model inference via x402" \
--register-skills natural_language_processing/text_generation \
--register-domains technology/artificial_intelligence 2>&1 | tail -3
--register-domains technology/artificial_intelligence \
--private-key-file "$KEY_FILE" 2>&1 | tail -8
rm -f "$KEY_FILE"
pass "ServiceOffer created"

poll_step_grep "Alice: ServiceOffer Ready" "True" 24 5 \
Expand All @@ -377,33 +387,14 @@ poll_step_grep "Alice: 402 gate works" "402" 12 5 \
"$TUNNEL_URL/services/alice-inference/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}],"max_tokens":5}'

step "Alice: add Base Sepolia RPC to eRPC (for on-chain registration)"
alice network add base-sepolia --endpoint https://sepolia.base.org --allow-writes 2>&1 | tail -2
# eRPC needs a restart to pick up the new chain config
alice kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true
alice kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true
pass "Base Sepolia RPC added to eRPC (with write access)"

step "Alice: register on ERC-8004 (Base Sepolia)"
# Use the .env private key for on-chain registration (has ETH for gas)
KEY_FILE=$(mktemp)
echo "$SIGNER_KEY" > "$KEY_FILE"
set +e
register_out=$(alice sell register \
--chain base-sepolia \
--name "Dual-Stack Test Inference" \
--description "Integration test: local model inference via x402" \
--private-key-file "$KEY_FILE" 2>&1)
register_rc=$?
set -e
rm -f "$KEY_FILE"
echo "$register_out" | tail -5
if [ "$register_rc" -eq 0 ] && echo "$register_out" | grep -q "Agent ID:\|registered"; then
AGENT_ID=$(echo "$register_out" | grep -o 'Agent ID: [0-9]*' | grep -o '[0-9]*' | head -1)
step "Alice: ERC-8004 registration reflected in ServiceOffer"
reg_out=$(alice sell status alice-inference -n llm 2>&1) || true
echo "$reg_out" | tail -12
if echo "$reg_out" | grep -q "Agent ID:"; then
AGENT_ID=$(echo "$reg_out" | grep 'Agent ID:' | awk '{print $3}' | head -1)
pass "ERC-8004 registered: Agent ID $AGENT_ID"
else
fail "Registration failed: ${register_out:0:200}"
fail "Registration not reflected in sell status: ${reg_out:0:200}"
fi

# ═════════════════════════════════════════════════════════════════
Expand Down
3 changes: 1 addition & 2 deletions internal/embed/skills/autoresearch-worker/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ obol sell http autoresearch-worker \
--chain base-sepolia \
--per-hour 0.50 \
--path /services/autoresearch-worker \
--register \
--register-name "GPU Worker Alpha" \
--register-description "A GPU worker for paid autoresearch experiments" \
--register-skills devops_mlops/model_versioning \
Expand All @@ -83,7 +82,7 @@ obol sell http autoresearch-worker \
This creates a `ServiceOffer` that:
- health-checks the worker
- creates a payment-gated public route
- optionally registers the worker on ERC-8004 for discovery
- registers the worker on ERC-8004 for discovery by default

## Request Format

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ obol sell http autoresearch-worker \
--chain base-sepolia \
--per-hour 0.50 \
--path /services/autoresearch-worker \
--register \
--register-name "GPU Worker Alpha" \
--register-description "A GPU worker for paid autoresearch experiments" \
--register-skills devops_mlops/model_versioning \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ obol sell http autoresearch-worker \
--chain base-sepolia \
--per-hour 0.50 \
--path /services/autoresearch-worker \
--register \
--register-name "GPU Worker Alpha" \
--register-description "A GPU worker for paid autoresearch experiments" \
--register-skills devops_mlops/model_versioning \
Expand Down
6 changes: 3 additions & 3 deletions internal/embed/skills/monetize-guide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ Only proceed after the user has confirmed the price.
obol sell inference <name> \
--model <model_name> \
--price <confirmed_price> \
--register \
--register-name "<descriptive name>" \
--register-description "<what the model does>" \
--register-skills natural_language_processing/natural_language_generation/text_completion \
Expand Down Expand Up @@ -156,7 +155,6 @@ obol sell http <name> \
--wallet <wallet_address> \
--chain base-sepolia \
--health-path /health/liveliness \
--register \
--register-name "<descriptive name>" \
--register-description "<what the service does>" \
--register-skills natural_language_processing/natural_language_generation/text_completion \
Expand All @@ -177,7 +175,6 @@ obol sell http <name> \
--per-request <confirmed_price> \
--chain base-sepolia \
--health-path <health_endpoint> \
--register \
--register-name "<descriptive name>" \
--register-description "<what the service does>" \
--register-skills <oasf_skill_path> \
Expand All @@ -186,6 +183,9 @@ obol sell http <name> \

### Phase 5: Wait for Reconciliation

`obol sell http` now registers by default. Use `--no-register` only for local
or private-only flows where on-chain discovery is intentionally skipped.

The agent reconciler automatically processes the ServiceOffer through 6 stages.

```bash
Expand Down
2 changes: 1 addition & 1 deletion internal/embed/skills/sell/scripts/monetize.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ def main():
create_parser.add_argument("--pay-to", required=True, help="USDC recipient wallet")
create_parser.add_argument("--path", help="Public route path")
create_parser.add_argument("--max-timeout", type=int, default=300, help="Payment timeout seconds")
create_parser.add_argument("--register", action="store_true", help="Publish registration document")
create_parser.add_argument("--register", action="store_true", help="Legacy flag: publish registration metadata")
create_parser.add_argument("--register-name", help="Registration name")
create_parser.add_argument("--register-description", help="Registration description")
create_parser.add_argument("--register-image", help="Registration image URL")
Expand Down
Loading