Skip to content

Commit 10d6082

Browse files
simonovic86claude
andcommitted
feat(runtime): implement x402/USDC wallet payment hostcall
Add wallet_pay hostcall enabling agents to pay for services from their budget. This completes the self-provisioning story: agents can now observe prices (HTTP), decide to pay (wallet_pay), and survive crashes mid-payment (effect lifecycle). Runtime: - wallet_pay ABI: amount, recipient, memo → signed payment receipt - Manifest-driven validation: allowed_recipients, max_payment_microcents - Budget deduction with Ed25519-signed receipt generation - Event log recording for deterministic replay (CM-4) SDK: - igor.WalletPay(amount, recipient, memo) wrapper with auto-retry - MockBackend.WalletPay + PaymentHandler for native testing Demo (agents/x402buyer): - Encounters HTTP 402 paywall, parses payment terms - Effect lifecycle: Record → checkpoint → Begin → Pay → Confirm - Crash recovery: unresolved payments reconciled, no duplicates - Mock paywall server (agents/x402buyer/cmd/paywall) - End-to-end demo script (scripts/demo-x402.sh) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a64d58 commit 10d6082

16 files changed

Lines changed: 1045 additions & 18 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ make agent # Build example WASM agent → agents/research/example/a
1919
make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm
2020
make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm
2121
make agent-sentinel # Build treasury sentinel WASM agent → agents/sentinel/agent.wasm
22+
make agent-x402buyer # Build x402 buyer WASM agent → agents/x402buyer/agent.wasm
2223
make test # Run tests: go test -v ./...
2324
make lint # golangci-lint (5m timeout)
2425
make vet # go vet
@@ -29,6 +30,7 @@ make demo # Build + run bridge reconciliation demo
2930
make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify)
3031
make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop → resume → verify)
3132
make demo-sentinel # Build + run treasury sentinel demo (effect lifecycle → crash → reconcile)
33+
make demo-x402 # Build + run x402 payment demo (pay for premium data → crash → reconcile)
3234
make clean # Remove bin/, checkpoints/, agent.wasm
3335
```
3436

@@ -66,7 +68,7 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi
6668
- `cmd/igord/` — CLI entry point, subcommand dispatch (`run`, `resume`, `verify`, `inspect`), tick loop
6769
- `internal/agent/` — Agent lifecycle: load WASM, init, tick, checkpoint, resume, budget deduction
6870
- `internal/runtime/` — wazero sandbox: 64MB memory limit, WASI with fs/net disabled
69-
- `internal/hostcall/``igor` host module: clock, rand, log, wallet hostcall implementations
71+
- `internal/hostcall/``igor` host module: clock, rand, log, wallet, http, x402 payment hostcall implementations
7072
- `internal/inspector/` — Checkpoint inspection and lineage chain verification (`chain.go`: `VerifyChain`)
7173
- `internal/storage/``CheckpointProvider` interface + filesystem impl + checkpoint history archival
7274
- `internal/eventlog/` — Per-tick observation event log for deterministic replay
@@ -81,11 +83,12 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi
8183
- `pkg/manifest/` — Capability manifest parsing and validation
8284
- `pkg/protocol/` — Message types: `AgentPackage`, `AgentTransfer`, `AgentStarted`
8385
- `pkg/receipt/` — Payment receipt data structure, Ed25519 signing, binary serialization
84-
- `sdk/igor/` — Agent SDK: hostcall wrappers (ClockNow, RandBytes, Log, WalletBalance), lifecycle plumbing (Agent interface), Encoder/Decoder with Raw/FixedBytes/ReadInto for checkpoint serialization, EffectLog for intent tracking across checkpoint/resume
86+
- `sdk/igor/` — Agent SDK: hostcall wrappers (ClockNow, RandBytes, Log, WalletBalance, WalletPay, HTTPRequest), lifecycle plumbing (Agent interface), Encoder/Decoder with Raw/FixedBytes/ReadInto for checkpoint serialization, EffectLog for intent tracking across checkpoint/resume
8587
- `sdk/igor/effects.go` — Effect lifecycle primitives: EffectLog, IntentState (Recorded→InFlight→Confirmed/Unresolved→Compensated), the resume rule (InFlight→Unresolved on Unmarshal)
8688
- `agents/heartbeat/` — Demo agent: logs heartbeat with tick count and age, milestones every 10 ticks
8789
- `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume
8890
- `agents/sentinel/` — Treasury sentinel: monitors simulated treasury balance, triggers refills with effect-safe intent tracking, demonstrates crash recovery and reconciliation
91+
- `agents/x402buyer/` — x402 payment demo: encounters HTTP 402 paywall, pays from budget via wallet_pay hostcall, receives premium data, crash-safe payment reconciliation
8992
- `agents/research/example/` — Original demo agent (Survivor) from research phases
9093
- `agents/research/reconciliation/` — Bridge reconciliation demo agent (research phase)
9194
- `scripts/demo-portable.sh` — End-to-end portable agent demo

Makefile

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel run-agent demo demo-portable demo-pricewatcher demo-sentinel gh-check gh-metadata gh-release
1+
.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel agent-x402buyer run-agent demo demo-portable demo-pricewatcher demo-sentinel demo-x402 gh-check gh-metadata gh-release
22

33
.DEFAULT_GOAL := help
44

@@ -10,6 +10,7 @@ HEARTBEAT_AGENT_DIR := agents/heartbeat
1010
RECONCILIATION_AGENT_DIR := agents/research/reconciliation
1111
PRICEWATCHER_AGENT_DIR := agents/pricewatcher
1212
SENTINEL_AGENT_DIR := agents/sentinel
13+
X402BUYER_AGENT_DIR := agents/x402buyer
1314

1415
# Go commands
1516
GOCMD := go
@@ -58,6 +59,7 @@ clean: ## Remove build artifacts
5859
rm -f agents/research/reconciliation/agent.wasm
5960
rm -f agents/pricewatcher/agent.wasm
6061
rm -f agents/sentinel/agent.wasm
62+
rm -f agents/x402buyer/agent.wasm
6163
@echo "Clean complete"
6264

6365
test: ## Run tests (with race detector)
@@ -136,6 +138,13 @@ agent-sentinel: ## Build treasury sentinel demo agent WASM
136138
cd $(SENTINEL_AGENT_DIR) && $(MAKE) build
137139
@echo "Agent built: $(SENTINEL_AGENT_DIR)/agent.wasm"
138140

141+
agent-x402buyer: ## Build x402 buyer demo agent WASM
142+
@echo "Building x402buyer agent..."
143+
@which tinygo > /dev/null || \
144+
(echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1)
145+
cd $(X402BUYER_AGENT_DIR) && $(MAKE) build
146+
@echo "Agent built: $(X402BUYER_AGENT_DIR)/agent.wasm"
147+
139148
demo: build agent-reconciliation ## Build and run reconciliation demo
140149
@echo "Building demo runner..."
141150
@mkdir -p $(BINARY_DIR)
@@ -158,6 +167,14 @@ demo-sentinel: build agent-sentinel ## Run the treasury sentinel demo (effect li
158167
@chmod +x scripts/demo-sentinel.sh
159168
@./scripts/demo-sentinel.sh
160169

170+
demo-x402: build agent-x402buyer ## Run the x402 payment demo (pay for premium data, crash recovery)
171+
@echo "Building paywall server..."
172+
@mkdir -p $(BINARY_DIR)
173+
$(GOBUILD) -o $(BINARY_DIR)/paywall ./agents/x402buyer/cmd/paywall
174+
@echo "Running x402 Payment Demo..."
175+
@chmod +x scripts/demo-x402.sh
176+
@./scripts/demo-x402.sh
177+
161178
check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests)
162179
@echo "All checks passed"
163180

agents/x402buyer/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.PHONY: build clean
2+
3+
build:
4+
tinygo build -target=wasi -no-debug -o agent.wasm .
5+
6+
clean:
7+
rm -f agent.wasm
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"capabilities": {
3+
"clock": { "version": 1 },
4+
"rand": { "version": 1 },
5+
"log": { "version": 1 },
6+
"http": {
7+
"version": 1,
8+
"options": {
9+
"allowed_hosts": ["localhost"],
10+
"timeout_ms": 5000,
11+
"max_response_bytes": 8192
12+
}
13+
},
14+
"x402": {
15+
"version": 1,
16+
"options": {
17+
"allowed_recipients": ["paywall-provider"],
18+
"max_payment_microcents": 1000000
19+
}
20+
}
21+
},
22+
"resource_limits": {
23+
"max_memory_bytes": 67108864
24+
}
25+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Mock x402 paywall server for the x402buyer demo.
4+
//
5+
// Endpoints:
6+
//
7+
// GET /api/premium-data
8+
// - No X-Payment header → 402 with payment terms
9+
// - X-Payment header present → 200 with premium data
10+
//
11+
// Payment terms are binary-encoded in the response body:
12+
//
13+
// [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo]
14+
package main
15+
16+
import (
17+
"encoding/binary"
18+
"fmt"
19+
"log"
20+
"math/rand/v2"
21+
"net/http"
22+
"os"
23+
"os/signal"
24+
"syscall"
25+
"time"
26+
)
27+
28+
const (
29+
paymentAmount int64 = 100_000 // 0.10 currency units (100,000 microcents)
30+
paymentRecipient string = "paywall-provider"
31+
paymentMemo string = "premium-data-access"
32+
)
33+
34+
func main() {
35+
addr := ":8402"
36+
if envAddr := os.Getenv("PAYWALL_ADDR"); envAddr != "" {
37+
addr = envAddr
38+
}
39+
40+
mux := http.NewServeMux()
41+
mux.HandleFunc("/api/premium-data", handlePremiumData)
42+
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
43+
w.WriteHeader(http.StatusOK)
44+
fmt.Fprint(w, "ok")
45+
})
46+
47+
srv := &http.Server{
48+
Addr: addr,
49+
Handler: mux,
50+
ReadHeaderTimeout: 5 * time.Second,
51+
}
52+
53+
// Graceful shutdown on SIGINT/SIGTERM.
54+
done := make(chan os.Signal, 1)
55+
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
56+
57+
go func() {
58+
log.Printf("[paywall] Listening on %s", addr)
59+
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
60+
log.Fatalf("[paywall] Server error: %v", err)
61+
}
62+
}()
63+
64+
<-done
65+
log.Println("[paywall] Shutting down...")
66+
srv.Close()
67+
}
68+
69+
func handlePremiumData(w http.ResponseWriter, r *http.Request) {
70+
payment := r.Header.Get("X-Payment")
71+
72+
if payment == "" {
73+
// No payment — return 402 with payment terms.
74+
log.Printf("[paywall] 402 → %s (no payment header)", r.RemoteAddr)
75+
w.Header().Set("Content-Type", "application/octet-stream")
76+
w.WriteHeader(http.StatusPaymentRequired)
77+
w.Write(encodePaymentTerms(paymentAmount, paymentRecipient, paymentMemo))
78+
return
79+
}
80+
81+
// Payment present — return premium data.
82+
log.Printf("[paywall] 200 → %s (payment received, %d bytes)", r.RemoteAddr, len(payment))
83+
w.Header().Set("Content-Type", "application/octet-stream")
84+
w.WriteHeader(http.StatusOK)
85+
86+
// Simulate premium data: a randomized market insight.
87+
insights := []string{
88+
"BTC dominance rising to 58.3%, altcoin rotation expected",
89+
"ETH gas fees averaging 12 gwei, L2 adoption accelerating",
90+
"DeFi TVL crossed $95B, new ATH since 2022",
91+
"Stablecoin supply expanding, USDC market cap up 15% MoM",
92+
"On-chain whale activity: 3 wallets accumulated 12,000 BTC this week",
93+
}
94+
insight := insights[rand.IntN(len(insights))]
95+
fmt.Fprintf(w, `{"insight":"%s","timestamp":%d,"tier":"premium"}`, insight, time.Now().Unix())
96+
}
97+
98+
// encodePaymentTerms encodes payment parameters for the 402 response body.
99+
// Format: [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo]
100+
func encodePaymentTerms(amount int64, recipient, memo string) []byte {
101+
size := 8 + 4 + len(recipient) + 4 + len(memo)
102+
buf := make([]byte, size)
103+
off := 0
104+
binary.LittleEndian.PutUint64(buf[off:], uint64(amount))
105+
off += 8
106+
binary.LittleEndian.PutUint32(buf[off:], uint32(len(recipient)))
107+
off += 4
108+
copy(buf[off:], recipient)
109+
off += len(recipient)
110+
binary.LittleEndian.PutUint32(buf[off:], uint32(len(memo)))
111+
off += 4
112+
copy(buf[off:], memo)
113+
return buf
114+
}

0 commit comments

Comments
 (0)