diff --git a/CLAUDE.md b/CLAUDE.md index f14d7ed..3730a8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ make agent # Build example WASM agent → agents/research/example/a make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm make agent-sentinel # Build treasury sentinel WASM agent → agents/sentinel/agent.wasm +make agent-x402buyer # Build x402 buyer WASM agent → agents/x402buyer/agent.wasm make test # Run tests: go test -v ./... make lint # golangci-lint (5m timeout) make vet # go vet @@ -29,6 +30,7 @@ make demo # Build + run bridge reconciliation demo make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify) make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop → resume → verify) make demo-sentinel # Build + run treasury sentinel demo (effect lifecycle → crash → reconcile) +make demo-x402 # Build + run x402 payment demo (pay for premium data → crash → reconcile) make clean # Remove bin/, checkpoints/, agent.wasm ``` @@ -66,7 +68,7 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi - `cmd/igord/` — CLI entry point, subcommand dispatch (`run`, `resume`, `verify`, `inspect`), tick loop - `internal/agent/` — Agent lifecycle: load WASM, init, tick, checkpoint, resume, budget deduction - `internal/runtime/` — wazero sandbox: 64MB memory limit, WASI with fs/net disabled -- `internal/hostcall/` — `igor` host module: clock, rand, log, wallet hostcall implementations +- `internal/hostcall/` — `igor` host module: clock, rand, log, wallet, http, x402 payment hostcall implementations - `internal/inspector/` — Checkpoint inspection and lineage chain verification (`chain.go`: `VerifyChain`) - `internal/storage/` — `CheckpointProvider` interface + filesystem impl + checkpoint history archival - `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 - `pkg/manifest/` — Capability manifest parsing and validation - `pkg/protocol/` — Message types: `AgentPackage`, `AgentTransfer`, `AgentStarted` - `pkg/receipt/` — Payment receipt data structure, Ed25519 signing, binary serialization -- `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 +- `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 - `sdk/igor/effects.go` — Effect lifecycle primitives: EffectLog, IntentState (Recorded→InFlight→Confirmed/Unresolved→Compensated), the resume rule (InFlight→Unresolved on Unmarshal) - `agents/heartbeat/` — Demo agent: logs heartbeat with tick count and age, milestones every 10 ticks - `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume - `agents/sentinel/` — Treasury sentinel: monitors simulated treasury balance, triggers refills with effect-safe intent tracking, demonstrates crash recovery and reconciliation +- `agents/x402buyer/` — x402 payment demo: encounters HTTP 402 paywall, pays from budget via wallet_pay hostcall, receives premium data, crash-safe payment reconciliation - `agents/research/example/` — Original demo agent (Survivor) from research phases - `agents/research/reconciliation/` — Bridge reconciliation demo agent (research phase) - `scripts/demo-portable.sh` — End-to-end portable agent demo diff --git a/Makefile b/Makefile index 3cd0557..d1c9d03 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.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 +.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 .DEFAULT_GOAL := help @@ -10,6 +10,7 @@ HEARTBEAT_AGENT_DIR := agents/heartbeat RECONCILIATION_AGENT_DIR := agents/research/reconciliation PRICEWATCHER_AGENT_DIR := agents/pricewatcher SENTINEL_AGENT_DIR := agents/sentinel +X402BUYER_AGENT_DIR := agents/x402buyer # Go commands GOCMD := go @@ -58,6 +59,7 @@ clean: ## Remove build artifacts rm -f agents/research/reconciliation/agent.wasm rm -f agents/pricewatcher/agent.wasm rm -f agents/sentinel/agent.wasm + rm -f agents/x402buyer/agent.wasm @echo "Clean complete" test: ## Run tests (with race detector) @@ -136,6 +138,13 @@ agent-sentinel: ## Build treasury sentinel demo agent WASM cd $(SENTINEL_AGENT_DIR) && $(MAKE) build @echo "Agent built: $(SENTINEL_AGENT_DIR)/agent.wasm" +agent-x402buyer: ## Build x402 buyer demo agent WASM + @echo "Building x402buyer agent..." + @which tinygo > /dev/null || \ + (echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1) + cd $(X402BUYER_AGENT_DIR) && $(MAKE) build + @echo "Agent built: $(X402BUYER_AGENT_DIR)/agent.wasm" + demo: build agent-reconciliation ## Build and run reconciliation demo @echo "Building demo runner..." @mkdir -p $(BINARY_DIR) @@ -158,6 +167,14 @@ demo-sentinel: build agent-sentinel ## Run the treasury sentinel demo (effect li @chmod +x scripts/demo-sentinel.sh @./scripts/demo-sentinel.sh +demo-x402: build agent-x402buyer ## Run the x402 payment demo (pay for premium data, crash recovery) + @echo "Building paywall server..." + @mkdir -p $(BINARY_DIR) + $(GOBUILD) -o $(BINARY_DIR)/paywall ./cmd/paywall + @echo "Running x402 Payment Demo..." + @chmod +x scripts/demo-x402.sh + @./scripts/demo-x402.sh + check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests) @echo "All checks passed" diff --git a/agents/x402buyer/Makefile b/agents/x402buyer/Makefile new file mode 100644 index 0000000..7fa1ed3 --- /dev/null +++ b/agents/x402buyer/Makefile @@ -0,0 +1,7 @@ +.PHONY: build clean + +build: + tinygo build -target=wasi -no-debug -o agent.wasm . + +clean: + rm -f agent.wasm diff --git a/agents/x402buyer/agent.manifest.json b/agents/x402buyer/agent.manifest.json new file mode 100644 index 0000000..0377697 --- /dev/null +++ b/agents/x402buyer/agent.manifest.json @@ -0,0 +1,25 @@ +{ + "capabilities": { + "clock": { "version": 1 }, + "rand": { "version": 1 }, + "log": { "version": 1 }, + "http": { + "version": 1, + "options": { + "allowed_hosts": ["localhost"], + "timeout_ms": 5000, + "max_response_bytes": 8192 + } + }, + "x402": { + "version": 1, + "options": { + "allowed_recipients": ["paywall-provider"], + "max_payment_microcents": 1000000 + } + } + }, + "resource_limits": { + "max_memory_bytes": 67108864 + } +} diff --git a/agents/x402buyer/go.mod b/agents/x402buyer/go.mod new file mode 100644 index 0000000..166d0fa --- /dev/null +++ b/agents/x402buyer/go.mod @@ -0,0 +1,7 @@ +module github.com/simonovic86/igor/agents/x402buyer + +go 1.24 + +require github.com/simonovic86/igor/sdk/igor v0.0.0 + +replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor diff --git a/agents/x402buyer/main.go b/agents/x402buyer/main.go new file mode 100644 index 0000000..8009bf4 --- /dev/null +++ b/agents/x402buyer/main.go @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +// x402buyer: a demo agent that pays for premium data using the x402 protocol. +// +// The agent demonstrates Igor's payment capabilities: +// - Fetches data from a paywall endpoint +// - Receives HTTP 402 with payment terms +// - Uses wallet_pay to send payment from its budget +// - Retries with the payment receipt to get premium data +// - Effect lifecycle ensures crash-safe payments +// +// The paywall server is a mock — no real blockchain involved. +// The point is to demonstrate the pattern: observe → pay → verify → survive. +package main + +import ( + "encoding/binary" + + "github.com/simonovic86/igor/sdk/igor" +) + +const ( + paywallURL = "http://localhost:8402/api/premium-data" +) + +// Phases of the agent. +const ( + phasePolling uint8 = 0 // normal operation: fetch data + phaseReconciling uint8 = 1 // handling unresolved intents on resume +) + +// X402Buyer implements a demo agent that pays for premium data. +type X402Buyer struct { + TickCount uint64 + BirthNano int64 + LastNano int64 + Phase uint8 + PaidCount uint32 // successful payments + DataCount uint32 // premium data received + TotalPaid int64 // total microcents paid + LastReceipt []byte // last payment receipt (for retry) + + // Effect tracking for crash-safe payments. + Effects igor.EffectLog +} + +func (b *X402Buyer) Init() {} + +func (b *X402Buyer) Tick() bool { + b.TickCount++ + now := igor.ClockNow() + if b.BirthNano == 0 { + b.BirthNano = now + igor.Logf("[x402buyer] Agent initialized. Fetching premium data from paywall.") + } + b.LastNano = now + ageSec := (b.LastNano - b.BirthNano) / 1_000_000_000 + + // On resume, handle unresolved intents first. + unresolved := b.Effects.Unresolved() + if len(unresolved) > 0 { + b.Phase = phaseReconciling + for _, intent := range unresolved { + b.reconcile(intent) + } + b.Effects.Prune() + b.Phase = phasePolling + return true + } + + // Check for pending intents that need execution. + pending := b.Effects.Pending() + for _, intent := range pending { + if intent.State == igor.Recorded { + return b.executePayment(intent) + } + } + + // Normal operation: try to fetch premium data. + // Every 3 ticks, attempt a data fetch (to show repeated payment cycles). + if b.TickCount%3 != 1 { + if b.TickCount%5 == 0 { + igor.Logf("[x402buyer] tick=%d age=%ds payments=%d data=%d total_paid=%d", + b.TickCount, ageSec, b.PaidCount, b.DataCount, b.TotalPaid) + } + return false + } + + igor.Logf("[x402buyer] Fetching premium data (tick=%d)...", b.TickCount) + + // Try fetching with last receipt if we have one. + var status int + var body []byte + var err error + if len(b.LastReceipt) > 0 { + headers := map[string]string{ + "X-Payment": encodeBytesHex(b.LastReceipt), + } + status, body, err = igor.HTTPRequest("GET", paywallURL, headers, nil) + } else { + status, body, err = igor.HTTPGet(paywallURL) + } + + if err != nil { + igor.Logf("[x402buyer] HTTP error: %s", err.Error()) + return false + } + + if status == 200 { + // Premium data received! + b.DataCount++ + b.LastReceipt = nil // Receipt consumed. + igor.Logf("[x402buyer] PREMIUM DATA RECEIVED: %s", string(body)) + return false + } + + if status == 402 { + // Payment required. Parse terms from body. + amount, recipient, memo := parsePaymentTerms(body) + if amount <= 0 { + igor.Logf("[x402buyer] Invalid payment terms in 402 response") + return false + } + igor.Logf("[x402buyer] 402 PAYMENT REQUIRED: %d microcents to %s (%s)", + amount, recipient, memo) + return b.recordPaymentIntent(amount, recipient, memo) + } + + igor.Logf("[x402buyer] Unexpected status %d: %s", status, string(body)) + return false +} + +// recordPaymentIntent creates a payment intent. Checkpointed before execution. +func (b *X402Buyer) recordPaymentIntent(amount int64, recipient, memo string) bool { + var key [16]byte + _ = igor.RandBytes(key[:]) + + data := igor.NewEncoder(128). + Int64(amount). + Bytes([]byte(recipient)). + Bytes([]byte(memo)). + Finish() + + if err := b.Effects.Record(key[:], data); err != nil { + igor.Logf("[x402buyer] ERROR: failed to record intent: %s", err.Error()) + return false + } + + igor.Logf("[x402buyer] Payment intent RECORDED (key=%x...). Waiting for checkpoint...", + key[:4]) + return false // Wait for checkpoint before execution. +} + +// executePayment transitions a Recorded intent to InFlight and sends payment. +func (b *X402Buyer) executePayment(intent igor.Intent) bool { + // Decode payment parameters. + d := igor.NewDecoder(intent.Data) + amount := d.Int64() + recipient := string(d.Bytes()) + memo := string(d.Bytes()) + if err := d.Err(); err != nil { + igor.Logf("[x402buyer] ERROR: corrupt intent data: %s", err.Error()) + _ = b.Effects.Compensate(intent.ID) + return false + } + + if err := b.Effects.Begin(intent.ID); err != nil { + igor.Logf("[x402buyer] ERROR: failed to begin intent: %s", err.Error()) + return false + } + + igor.Logf("[x402buyer] Payment IN-FLIGHT: %d microcents to %s (key=%x...)", + amount, recipient, intent.ID[:4]) + + // === DANGER ZONE: crash between Begin and Confirm → Unresolved === + + receipt, err := igor.WalletPay(amount, recipient, memo) + if err != nil { + igor.Logf("[x402buyer] Payment FAILED: %s", err.Error()) + _ = b.Effects.Compensate(intent.ID) + b.Effects.Prune() + return false + } + + // Payment succeeded. + b.PaidCount++ + b.TotalPaid += amount + b.LastReceipt = receipt + + if err := b.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[x402buyer] ERROR: failed to confirm intent: %s", err.Error()) + return false + } + + igor.Logf("[x402buyer] Payment CONFIRMED: %d microcents (key=%x..., receipt=%d bytes)", + amount, intent.ID[:4], len(receipt)) + b.Effects.Prune() + return true // Fast tick to retry the HTTP request with receipt. +} + +// reconcile handles an unresolved payment after crash recovery. +func (b *X402Buyer) reconcile(intent igor.Intent) { + igor.Logf("[x402buyer] RECONCILING: unresolved payment (key=%x...)", intent.ID[:4]) + + d := igor.NewDecoder(intent.Data) + amount := d.Int64() + recipient := string(d.Bytes()) + _ = d.Bytes() // memo + + // In production: check if payment was settled on-chain. + // Here we simulate: if first byte of key is even, payment went through. + paymentCompleted := (intent.ID[0] % 2) == 0 + + if paymentCompleted { + b.PaidCount++ + b.TotalPaid += amount + // We don't have the receipt, but the payment is settled. + if err := b.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[x402buyer] ERROR: reconcile confirm failed: %s", err.Error()) + return + } + igor.Logf("[x402buyer] Reconciled: payment of %d to %s COMPLETED before crash", + amount, recipient) + } else { + if err := b.Effects.Compensate(intent.ID); err != nil { + igor.Logf("[x402buyer] ERROR: reconcile compensate failed: %s", err.Error()) + return + } + igor.Logf("[x402buyer] Reconciled: payment of %d to %s DID NOT complete — will retry", + amount, recipient) + } +} + +func (b *X402Buyer) Marshal() []byte { + return igor.NewEncoder(512). + Uint64(b.TickCount). + Int64(b.BirthNano). + Int64(b.LastNano). + Bool(b.Phase != 0). + Uint32(b.PaidCount). + Uint32(b.DataCount). + Int64(b.TotalPaid). + Bytes(b.LastReceipt). + Bytes(b.Effects.Marshal()). + Finish() +} + +func (b *X402Buyer) Unmarshal(data []byte) { + d := igor.NewDecoder(data) + b.TickCount = d.Uint64() + b.BirthNano = d.Int64() + b.LastNano = d.Int64() + if d.Bool() { + b.Phase = phaseReconciling + } + b.PaidCount = d.Uint32() + b.DataCount = d.Uint32() + b.TotalPaid = d.Int64() + b.LastReceipt = d.Bytes() + b.Effects.Unmarshal(d.Bytes()) // THE RESUME RULE: InFlight → Unresolved + if err := d.Err(); err != nil { + panic("unmarshal checkpoint: " + err.Error()) + } +} + +// parsePaymentTerms extracts amount, recipient, and memo from a 402 response body. +// Expected format (simple binary): [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo] +// Falls back to a fixed default if parsing fails. +func parsePaymentTerms(body []byte) (amount int64, recipient, memo string) { + if len(body) < 12 { + return 0, "", "" + } + amount = int64(binary.LittleEndian.Uint64(body[:8])) + off := 8 + recipientLen := int(binary.LittleEndian.Uint32(body[off:])) + off += 4 + if off+recipientLen > len(body) { + return 0, "", "" + } + recipient = string(body[off : off+recipientLen]) + off += recipientLen + if off+4 > len(body) { + return amount, recipient, "" + } + memoLen := int(binary.LittleEndian.Uint32(body[off:])) + off += 4 + if off+memoLen > len(body) { + return amount, recipient, "" + } + memo = string(body[off : off+memoLen]) + return amount, recipient, memo +} + +// encodeBytesHex encodes bytes as hex string (simple, no dependencies). +func encodeBytesHex(data []byte) string { + const hex = "0123456789abcdef" + buf := make([]byte, len(data)*2) + for i, b := range data { + buf[i*2] = hex[b>>4] + buf[i*2+1] = hex[b&0x0f] + } + return string(buf) +} + +func init() { igor.Run(&X402Buyer{}) } +func main() {} diff --git a/cmd/paywall/main.go b/cmd/paywall/main.go new file mode 100644 index 0000000..7f33a6b --- /dev/null +++ b/cmd/paywall/main.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Mock x402 paywall server for the x402buyer demo. +// +// Endpoints: +// +// GET /api/premium-data +// - No X-Payment header → 402 with payment terms +// - X-Payment header present → 200 with premium data +// +// Payment terms are binary-encoded in the response body: +// +// [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo] +package main + +import ( + "encoding/binary" + "fmt" + "log" + "math/rand/v2" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + paymentAmount int64 = 100_000 // 0.10 currency units (100,000 microcents) + paymentRecipient string = "paywall-provider" + paymentMemo string = "premium-data-access" +) + +func main() { + addr := ":8402" + if envAddr := os.Getenv("PAYWALL_ADDR"); envAddr != "" { + addr = envAddr + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/premium-data", handlePremiumData) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + // Graceful shutdown on SIGINT/SIGTERM. + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("[paywall] Listening on %s", addr) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("[paywall] Server error: %v", err) + } + }() + + <-done + log.Println("[paywall] Shutting down...") + srv.Close() +} + +func handlePremiumData(w http.ResponseWriter, r *http.Request) { + payment := r.Header.Get("X-Payment") + + if payment == "" { + // No payment — return 402 with payment terms. + log.Printf("[paywall] 402 → %s (no payment header)", r.RemoteAddr) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusPaymentRequired) + _, _ = w.Write(encodePaymentTerms(paymentAmount, paymentRecipient, paymentMemo)) + return + } + + // Payment present — return premium data. + log.Printf("[paywall] 200 → %s (payment received, %d bytes)", r.RemoteAddr, len(payment)) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + + // Simulate premium data: a randomized market insight. + insights := []string{ + "BTC dominance rising to 58.3%, altcoin rotation expected", + "ETH gas fees averaging 12 gwei, L2 adoption accelerating", + "DeFi TVL crossed $95B, new ATH since 2022", + "Stablecoin supply expanding, USDC market cap up 15% MoM", + "On-chain whale activity: 3 wallets accumulated 12,000 BTC this week", + } + insight := insights[rand.IntN(len(insights))] + fmt.Fprintf(w, `{"insight":"%s","timestamp":%d,"tier":"premium"}`, insight, time.Now().Unix()) +} + +// encodePaymentTerms encodes payment parameters for the 402 response body. +// Format: [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo] +func encodePaymentTerms(amount int64, recipient, memo string) []byte { + size := 8 + 4 + len(recipient) + 4 + len(memo) + buf := make([]byte, size) + off := 0 + binary.LittleEndian.PutUint64(buf[off:], uint64(amount)) + off += 8 + binary.LittleEndian.PutUint32(buf[off:], uint32(len(recipient))) + off += 4 + copy(buf[off:], recipient) + off += len(recipient) + binary.LittleEndian.PutUint32(buf[off:], uint32(len(memo))) + off += 4 + copy(buf[off:], memo) + return buf +} diff --git a/internal/agent/instance.go b/internal/agent/instance.go index bc42a43..8a92f0a 100644 --- a/internal/agent/instance.go +++ b/internal/agent/instance.go @@ -171,6 +171,39 @@ type pricingStateRef struct { func (p *pricingStateRef) GetNodePrice() int64 { return p.instance.PricePerSecond } +// walletPayStateRef is an indirection layer that lets wallet_pay hostcall closures +// reference the Instance before it is fully constructed. Same pattern as walletStateRef. +type walletPayStateRef struct { + instance *Instance +} + +func (w *walletPayStateRef) GetBudget() int64 { return w.instance.Budget } +func (w *walletPayStateRef) DeductBudget(amount int64) error { + return w.instance.DeductBudget(amount) +} +func (w *walletPayStateRef) GetAgentPubKey() ed25519.PublicKey { + if w.instance.AgentIdentity == nil { + return make([]byte, ed25519.PublicKeySize) + } + return w.instance.AgentIdentity.PublicKey +} +func (w *walletPayStateRef) SignPayment(data []byte) []byte { + if w.instance.AgentIdentity == nil { + return make([]byte, ed25519.SignatureSize) + } + return ed25519.Sign(w.instance.AgentIdentity.PrivateKey, data) +} + +// DeductBudget atomically deducts the given amount from the agent's budget. +// Returns an error if the budget is insufficient. +func (i *Instance) DeductBudget(amount int64) error { + if i.Budget < amount { + return fmt.Errorf("insufficient budget: have %d, need %d", i.Budget, amount) + } + i.Budget -= amount + return nil +} + // GetBudget returns the current budget (implements hostcall.WalletState). func (i *Instance) GetBudget() int64 { return i.Budget @@ -305,10 +338,12 @@ func loadAgent( // agent_tick (not during loading), so the nil instance is safe at this point. wsRef := &walletStateRef{} psRef := &pricingStateRef{} + wpRef := &walletPayStateRef{} registry := hostcall.NewRegistry(logger, el) registry.SetWalletState(wsRef) registry.SetPricingState(psRef) + registry.SetWalletPayState(wpRef) if err := registry.RegisterHostModule(ctx, engine.Runtime(), capManifest); err != nil { return nil, fmt.Errorf("failed to register host module: %w", err) } @@ -352,9 +387,10 @@ func loadAgent( } // Now that the instance exists, wire state refs so hostcall closures - // can access budget, receipts, and pricing during agent_tick. + // can access budget, receipts, pricing, and payments during agent_tick. wsRef.instance = instance psRef.instance = instance + wpRef.instance = instance if err := instance.verifyExports(); err != nil { return nil, fmt.Errorf("agent lifecycle validation failed: %w", err) diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go index f1ff9dd..a92eb5e 100644 --- a/internal/eventlog/eventlog.go +++ b/internal/eventlog/eventlog.go @@ -19,6 +19,7 @@ const ( WalletReceipt HostcallID = 6 NodePrice HostcallID = 7 HTTPRequest HostcallID = 8 + WalletPay HostcallID = 9 ) // Entry is a single observation recorded during a tick. diff --git a/internal/hostcall/payment.go b/internal/hostcall/payment.go new file mode 100644 index 0000000..6da07e1 --- /dev/null +++ b/internal/hostcall/payment.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hostcall + +import ( + "context" + "crypto/ed25519" + "encoding/binary" + "time" + + "github.com/simonovic86/igor/internal/eventlog" + "github.com/simonovic86/igor/pkg/manifest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +const ( + maxRecipientBytes = 256 + maxMemoBytes = 512 + + // Payment hostcall error codes (negative i32). + payErrInsufficientBudget int32 = -1 + payErrInputTooLong int32 = -2 + payErrRecipientBlocked int32 = -3 + payErrAmountExceeded int32 = -4 + payErrBufferTooSmall int32 = -5 + payErrProcessing int32 = -6 +) + +// WalletPayState provides budget deduction and signing for the wallet_pay hostcall. +type WalletPayState interface { + GetBudget() int64 + DeductBudget(amount int64) error + GetAgentPubKey() ed25519.PublicKey + SignPayment(data []byte) []byte +} + +// registerPayment registers the wallet_pay hostcall on the igor WASM host module. +// +// ABI: +// +// wallet_pay( +// amount_microcents i64, +// recipient_ptr, recipient_len i32, +// memo_ptr, memo_len i32, +// receipt_ptr, receipt_cap i32 +// ) -> i32 +// +// Returns bytes written to receipt buffer on success, negative error code on failure. +// Receipt layout: [receipt_len: 4 bytes LE][receipt: N bytes]. +func (r *Registry) registerPayment(builder wazero.HostModuleBuilder, state WalletPayState, capCfg manifest.CapabilityConfig) { + allowedRecipients := extractAllowedRecipients(capCfg) + maxPayment := int64(extractIntOption(capCfg.Options, "max_payment_microcents", 0)) + + builder.NewFunctionBuilder(). + WithFunc(func(_ context.Context, m api.Module, + amount int64, + recipientPtr, recipientLen, + memoPtr, memoLen, + receiptPtr, receiptCap uint32, + ) int32 { + // Validate input sizes. + if recipientLen > maxRecipientBytes || memoLen > maxMemoBytes { + return payErrInputTooLong + } + + // Read recipient from WASM memory. + recipientData, ok := m.Memory().Read(recipientPtr, recipientLen) + if !ok { + return payErrProcessing + } + recipient := string(recipientData) + + // Read memo from WASM memory. + var memo string + if memoLen > 0 { + memoData, ok := m.Memory().Read(memoPtr, memoLen) + if !ok { + return payErrProcessing + } + memo = string(memoData) + } + + // Validate amount. + if amount <= 0 { + return payErrProcessing + } + if maxPayment > 0 && amount > maxPayment { + r.logger.Warn("Payment exceeds cap", + "amount", amount, + "max", maxPayment) + return payErrAmountExceeded + } + + // Validate recipient against allowed list. + if len(allowedRecipients) > 0 { + allowed := false + for _, ar := range allowedRecipients { + if recipient == ar { + allowed = true + break + } + } + if !allowed { + r.logger.Warn("Payment recipient blocked", + "recipient", recipient) + return payErrRecipientBlocked + } + } + + // Check receipt buffer capacity BEFORE deducting budget to avoid + // double-charge on retry when the buffer is too small. + pubKey := state.GetAgentPubKey() + receiptDataLen := 8 + 8 + 4 + len(recipient) + 4 + len(memo) + len(pubKey) + fullReceiptLen := receiptDataLen + ed25519.SignatureSize + needed := uint32(4 + fullReceiptLen) + if needed > receiptCap { + // Write size hint so caller can retry with correct buffer. + if receiptCap >= 4 { + sizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(sizeBuf, uint32(fullReceiptLen)) + m.Memory().Write(receiptPtr, sizeBuf) + } + return payErrBufferTooSmall + } + + // Check sufficient budget. + if state.GetBudget() < amount { + r.logger.Warn("Insufficient budget for payment", + "amount", amount, + "budget", state.GetBudget()) + return payErrInsufficientBudget + } + + // Deduct budget. + if err := state.DeductBudget(amount); err != nil { + r.logger.Error("Budget deduction failed", "error", err) + return payErrProcessing + } + + // Build signed payment receipt. + // Format: [amount:8][timestamp:8][recipient_len:4][recipient][memo_len:4][memo][pubkey:32] + receiptData := make([]byte, receiptDataLen) + off := 0 + binary.LittleEndian.PutUint64(receiptData[off:], uint64(amount)) + off += 8 + ts := time.Now().UnixNano() + binary.LittleEndian.PutUint64(receiptData[off:], uint64(ts)) + off += 8 + binary.LittleEndian.PutUint32(receiptData[off:], uint32(len(recipient))) + off += 4 + copy(receiptData[off:], recipient) + off += len(recipient) + binary.LittleEndian.PutUint32(receiptData[off:], uint32(len(memo))) + off += 4 + copy(receiptData[off:], memo) + off += len(memo) + copy(receiptData[off:], pubKey) + + sig := state.SignPayment(receiptData) + + // Full receipt: [receiptData][signature] + fullReceipt := make([]byte, len(receiptData)+len(sig)) + copy(fullReceipt, receiptData) + copy(fullReceipt[len(receiptData):], sig) + + // Write receipt: [receipt_len: 4 bytes LE][receipt: N bytes]. + out := make([]byte, needed) + binary.LittleEndian.PutUint32(out[:4], uint32(len(fullReceipt))) + copy(out[4:], fullReceipt) + if !m.Memory().Write(receiptPtr, out) { + return payErrProcessing + } + + // Record observation for replay (CM-4). + obsPayload := make([]byte, 8+len(fullReceipt)) + binary.LittleEndian.PutUint64(obsPayload[:8], uint64(amount)) + copy(obsPayload[8:], fullReceipt) + r.eventLog.Record(eventlog.WalletPay, obsPayload) + + r.logger.Info("Payment processed", + "amount", amount, + "recipient", recipient, + "memo", memo, + "receipt_bytes", len(fullReceipt)) + + return int32(needed) + }). + Export("wallet_pay") +} + +// extractAllowedRecipients reads the allowed_recipients option from the capability config. +func extractAllowedRecipients(cfg manifest.CapabilityConfig) []string { + if cfg.Options == nil { + return nil + } + raw, ok := cfg.Options["allowed_recipients"] + if !ok { + return nil + } + slice, ok := raw.([]any) + if !ok { + return nil + } + recipients := make([]string, 0, len(slice)) + for _, v := range slice { + if s, ok := v.(string); ok { + recipients = append(recipients, s) + } + } + return recipients +} diff --git a/internal/hostcall/registry.go b/internal/hostcall/registry.go index ac0b4f5..930325e 100644 --- a/internal/hostcall/registry.go +++ b/internal/hostcall/registry.go @@ -18,11 +18,12 @@ import ( // Registry builds and manages the igor host module for a single agent. type Registry struct { - logger *slog.Logger - eventLog *eventlog.EventLog - walletState WalletState // optional; nil = wallet hostcalls not available - pricingState PricingState // optional; nil = pricing hostcalls not available - httpClient HTTPClient // optional; nil = use http.DefaultClient + logger *slog.Logger + eventLog *eventlog.EventLog + walletState WalletState // optional; nil = wallet hostcalls not available + pricingState PricingState // optional; nil = pricing hostcalls not available + httpClient HTTPClient // optional; nil = use http.DefaultClient + walletPayState WalletPayState // optional; nil = payment hostcalls not available } // NewRegistry creates a hostcall registry bound to the given event log. @@ -51,6 +52,12 @@ func (r *Registry) SetHTTPClient(c HTTPClient) { r.httpClient = c } +// SetWalletPayState installs the payment state provider for the wallet_pay hostcall. +// Must be called before RegisterHostModule if the agent declares "x402" capability. +func (r *Registry) SetWalletPayState(wps WalletPayState) { + r.walletPayState = wps +} + // RegisterHostModule builds and instantiates the "igor" WASM host module // with only the capabilities declared in the manifest. // Must be called after WASI instantiation and before agent module instantiation. @@ -103,6 +110,12 @@ func (r *Registry) RegisterHostModule( registered++ } + if m.Has("x402") && r.walletPayState != nil { + capCfg := m.Capabilities["x402"] + r.registerPayment(builder, r.walletPayState, capCfg) + registered++ + } + // Only instantiate if at least one capability was registered. // If the agent has an empty manifest, skip module creation entirely. // If the agent's WASM imports from "igor", instantiation will fail diff --git a/internal/replay/engine.go b/internal/replay/engine.go index a4e8cb5..5c79790 100644 --- a/internal/replay/engine.go +++ b/internal/replay/engine.go @@ -284,6 +284,11 @@ func registerReplayHostModule( registered++ } + if m.Has("x402") { + registerReplayPayment(builder, iter, repErr) + registered++ + } + if registered == 0 { return nil } @@ -700,6 +705,11 @@ func registerChainReplayHostModule( registered++ } + if m.Has("x402") { + registerChainReplayPayment(builder, holder, repErr) + registered++ + } + if registered == 0 { return nil } @@ -791,6 +801,101 @@ func registerChainReplayPricing( Export("node_price") } +// registerReplayPayment registers wallet_pay that returns the recorded receipt. +// Observation payload layout: [amount:8][receipt_bytes]. +// The hostcall writes [receipt_len:4 LE][receipt_bytes] to the output buffer. +func registerReplayPayment( + builder wazero.HostModuleBuilder, + iter *entryIterator, + repErr *replayError, +) { + builder.NewFunctionBuilder(). + WithFunc(func(_ context.Context, m api.Module, + _ int64, + _, _, + _, _, + receiptPtr, receiptCap uint32, + ) int32 { + entry, err := iter.next(eventlog.WalletPay) + if err != nil { + repErr.err = err + return payReplayErr + } + if len(entry.Payload) < 8 { + repErr.err = fmt.Errorf("wallet_pay payload too short: %d", len(entry.Payload)) + return payReplayErr + } + receipt := entry.Payload[8:] // skip amount prefix + needed := uint32(4 + len(receipt)) + if needed > receiptCap { + // Write size hint, same as live hostcall. + if receiptCap >= 4 { + sizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(sizeBuf, uint32(len(receipt))) + m.Memory().Write(receiptPtr, sizeBuf) + } + return -5 // payErrBufferTooSmall + } + out := make([]byte, needed) + binary.LittleEndian.PutUint32(out[:4], uint32(len(receipt))) + copy(out[4:], receipt) + if !m.Memory().Write(receiptPtr, out) { + repErr.err = fmt.Errorf("wallet_pay memory write failed") + return payReplayErr + } + return int32(needed) + }). + Export("wallet_pay") +} + +// registerChainReplayPayment registers wallet_pay replay that reads from the +// iteratorHolder, allowing the iterator to be swapped between ticks. +func registerChainReplayPayment( + builder wazero.HostModuleBuilder, + holder *iteratorHolder, + repErr *replayError, +) { + builder.NewFunctionBuilder(). + WithFunc(func(_ context.Context, m api.Module, + _ int64, + _, _, + _, _, + receiptPtr, receiptCap uint32, + ) int32 { + entry, err := holder.iter.next(eventlog.WalletPay) + if err != nil { + repErr.err = err + return payReplayErr + } + if len(entry.Payload) < 8 { + repErr.err = fmt.Errorf("wallet_pay payload too short: %d", len(entry.Payload)) + return payReplayErr + } + receipt := entry.Payload[8:] + needed := uint32(4 + len(receipt)) + if needed > receiptCap { + if receiptCap >= 4 { + sizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(sizeBuf, uint32(len(receipt))) + m.Memory().Write(receiptPtr, sizeBuf) + } + return -5 + } + out := make([]byte, needed) + binary.LittleEndian.PutUint32(out[:4], uint32(len(receipt))) + copy(out[4:], receipt) + if !m.Memory().Write(receiptPtr, out) { + repErr.err = fmt.Errorf("wallet_pay memory write failed") + return payReplayErr + } + return int32(needed) + }). + Export("wallet_pay") +} + +// payReplayErr is a generic error code for replay payment failures. +const payReplayErr int32 = -6 + // firstDiff returns the index of the first differing byte, or -1 if equal. func firstDiff(a, b []byte) int { n := len(a) diff --git a/pkg/manifest/parse.go b/pkg/manifest/parse.go index 12128b2..65c1aaf 100644 --- a/pkg/manifest/parse.go +++ b/pkg/manifest/parse.go @@ -12,7 +12,7 @@ import ( ) // NodeCapabilities lists capabilities available on the current node. -var NodeCapabilities = []string{"clock", "rand", "log", "wallet", "pricing", "http"} +var NodeCapabilities = []string{"clock", "rand", "log", "wallet", "pricing", "http", "x402"} // ParseCapabilityManifest parses a capability manifest from JSON bytes. // An empty or nil input returns an empty manifest (no capabilities declared). diff --git a/scripts/demo-x402.sh b/scripts/demo-x402.sh new file mode 100644 index 0000000..3931b68 --- /dev/null +++ b/scripts/demo-x402.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# demo-x402.sh — x402 Payment Protocol: agent pays for premium data. +# +# Demonstrates Igor's payment capabilities: +# 1. Start a mock paywall server (returns 402 without payment) +# 2. Run x402buyer agent — it encounters 402, pays, gets premium data +# 3. KILL the process mid-payment cycle +# 4. Resume on "Machine B" — reconcile unresolved payments +# 5. Verify cryptographic lineage across both machines +# +# The paywall is simulated — no real blockchain. The agent pays from its +# budget via the wallet_pay hostcall and receives a signed receipt. +set -euo pipefail + +IGORD="./bin/igord" +WASM="./agents/x402buyer/agent.wasm" +PAYWALL="./bin/paywall" +DIR_A="/tmp/igor-demo-x402-a" +DIR_B="/tmp/igor-demo-x402-b" + +# Cleanup from previous runs. +rm -rf "$DIR_A" "$DIR_B" +mkdir -p "$DIR_A" "$DIR_B" + +echo "" +echo "==========================================" +echo " Igor: x402 Payment Protocol Demo" +echo " Agent pays for premium data" +echo "==========================================" +echo "" +echo " Scenario: Agent fetches premium market data." +echo " Paywall returns 402. Agent pays. Gets data." +echo " Crash mid-payment. Resume. Reconcile. No duplicates." +echo "" + +# --- Start paywall server --- +echo "[Paywall] Starting mock x402 paywall server on :8402..." +$PAYWALL & +PID_PAYWALL=$! + +# Wait for paywall to be ready. +for i in $(seq 1 10); do + if curl -s http://localhost:8402/health > /dev/null 2>&1; then + break + fi + sleep 0.5 +done +echo "[Paywall] Ready." +echo "" + +# --- Machine A: Run agent --- +echo "[Machine A] Starting x402buyer agent..." +echo "[Machine A] Agent will encounter 402, pay, and fetch premium data." +echo "" +$IGORD run --budget 100.0 --checkpoint-dir "$DIR_A" --agent-id x402buyer "$WASM" & +PID_A=$! + +# Let it run long enough to complete a few payment cycles. +sleep 20 + +echo "" +echo "[Machine A] KILLING process (simulating crash mid-payment)..." +kill -9 "$PID_A" 2>/dev/null || true +wait "$PID_A" 2>/dev/null || true + +echo "[Machine A] Process killed. Checkpoint is the last known good state." +echo "" + +# --- Copy checkpoint to Machine B --- +CKPT_FILE="$DIR_A/x402buyer.checkpoint" +if [ ! -f "$CKPT_FILE" ]; then + echo "ERROR: Checkpoint file not found at $CKPT_FILE" + echo "(Agent may not have run long enough to produce a checkpoint.)" + kill "$PID_PAYWALL" 2>/dev/null || true + rm -rf "$DIR_A" "$DIR_B" + exit 1 +fi + +echo "==========================================" +echo " Simulating Transfer to Machine B" +echo "==========================================" +echo "" +echo "[Transfer] Copying checkpoint + identity to Machine B..." +cp "$CKPT_FILE" "$DIR_B/" +if [ -f "$DIR_A/x402buyer.identity" ]; then + cp "$DIR_A/x402buyer.identity" "$DIR_B/" +fi +echo "[Transfer] Done. The checkpoint IS the agent." +echo "" + +# --- Machine B: Resume and reconcile --- +echo "==========================================" +echo " Machine B: Resume + Reconcile" +echo "==========================================" +echo "" +echo "[Machine B] Resuming x402buyer from checkpoint..." +echo "[Machine B] If there's an in-flight payment, it becomes UNRESOLVED." +echo "[Machine B] The agent will check payment status, not blindly retry." +echo "" +$IGORD resume --checkpoint "$DIR_B/x402buyer.checkpoint" --wasm "$WASM" --checkpoint-dir "$DIR_B" --agent-id x402buyer & +PID_B=$! + +# Let it reconcile and run a few more payment cycles. +sleep 15 + +echo "" +echo "[Machine B] Stopping agent gracefully..." +kill -INT "$PID_B" 2>/dev/null || true +wait "$PID_B" 2>/dev/null || true + +# Stop paywall server. +kill "$PID_PAYWALL" 2>/dev/null || true +wait "$PID_PAYWALL" 2>/dev/null || true +echo "" + +# --- Verify Lineage --- +VERIFY_DIR="/tmp/igor-demo-x402-verify" +rm -rf "$VERIFY_DIR" +mkdir -p "$VERIFY_DIR" + +if [ -d "$DIR_A/history/x402buyer" ]; then + cp "$DIR_A/history/x402buyer/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi +if [ -d "$DIR_B/history/x402buyer" ]; then + cp "$DIR_B/history/x402buyer/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi + +CKPT_COUNT=$(ls "$VERIFY_DIR/"*.ckpt 2>/dev/null | wc -l | tr -d ' ') +echo "==========================================" +echo " Lineage Verification ($CKPT_COUNT checkpoints)" +echo "==========================================" +echo "" + +if [ "$CKPT_COUNT" -gt 0 ]; then + $IGORD verify "$VERIFY_DIR" +else + echo "No checkpoint history found for verification." +fi + +echo "" +echo "==========================================" +echo " Demo Complete" +echo "==========================================" +echo "" +echo "What you just saw:" +echo " - Agent requested premium data from a paywall" +echo " - Server returned HTTP 402 Payment Required" +echo " - Agent paid from its budget via wallet_pay hostcall" +echo " - Agent received signed payment receipt" +echo " - Agent retried with receipt, got premium data" +echo " - Process KILLED mid-payment (SIGKILL)" +echo " - Resumed on Machine B from checkpoint" +echo " - In-flight payments became UNRESOLVED (the resume rule)" +echo " - Agent RECONCILED — checked if payment settled" +echo " - No duplicate payment. Budget conserved. Continuity." +echo "" +echo "This is x402 for autonomous agents:" +echo " The agent pays for what it needs. And survives the paying." +echo "" + +# Cleanup. +rm -rf "$DIR_A" "$DIR_B" "$VERIFY_DIR" diff --git a/sdk/igor/hostcalls_payment_stub.go b/sdk/igor/hostcalls_payment_stub.go new file mode 100644 index 0000000..298c999 --- /dev/null +++ b/sdk/igor/hostcalls_payment_stub.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !tinygo && !wasip1 + +package igor + +// WalletPay sends a payment from the agent's budget to the given recipient. +// In non-WASM builds, dispatches to the registered MockBackend. +func WalletPay(amount int64, recipient, memo string) ([]byte, error) { + if activeMock != nil { + return activeMock.WalletPay(amount, recipient, memo) + } + panic("igor: WalletPay requires WASM runtime or mock (see sdk/igor/mock)") +} diff --git a/sdk/igor/hostcalls_payment_wasm.go b/sdk/igor/hostcalls_payment_wasm.go new file mode 100644 index 0000000..0819cd8 --- /dev/null +++ b/sdk/igor/hostcalls_payment_wasm.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +package igor + +import ( + "encoding/binary" + "fmt" + "unsafe" +) + +// Raw WASM import for the payment hostcall from the igor host module. + +//go:wasmimport igor wallet_pay +func walletPay( + amount int64, + recipientPtr, recipientLen, + memoPtr, memoLen, + receiptPtr, receiptCap uint32, +) int32 + +// WalletPay sends a payment from the agent's budget to the given recipient. +// Requires the "x402" capability in the agent manifest. +// Returns the signed payment receipt bytes on success. +func WalletPay(amount int64, recipient, memo string) ([]byte, error) { + recipientBuf := []byte(recipient) + memoBuf := []byte(memo) + + // Initial receipt buffer: 1KB. + receiptBuf := make([]byte, 1024) + + var recipientPtr, memoPtr, memoLen uint32 + recipientPtr = uint32(uintptr(unsafe.Pointer(&recipientBuf[0]))) + if len(memoBuf) > 0 { + memoPtr = uint32(uintptr(unsafe.Pointer(&memoBuf[0]))) + memoLen = uint32(len(memoBuf)) + } + receiptPtr := uint32(uintptr(unsafe.Pointer(&receiptBuf[0]))) + + rc := walletPay( + amount, + recipientPtr, uint32(len(recipientBuf)), + memoPtr, memoLen, + receiptPtr, uint32(len(receiptBuf)), + ) + + // If receipt buffer too small, retry with the size hint. + if rc == -5 && len(receiptBuf) >= 4 { + needed := binary.LittleEndian.Uint32(receiptBuf[:4]) + if needed > 0 && needed <= 64*1024 { // Cap retry at 64KB. + receiptBuf = make([]byte, needed+4) // +4 for the length prefix. + receiptPtr = uint32(uintptr(unsafe.Pointer(&receiptBuf[0]))) + rc = walletPay( + amount, + uint32(uintptr(unsafe.Pointer(&recipientBuf[0]))), uint32(len(recipientBuf)), + memoPtr, memoLen, + receiptPtr, uint32(len(receiptBuf)), + ) + } + } + + if rc < 0 { + switch rc { + case -1: + return nil, fmt.Errorf("wallet_pay: insufficient budget") + case -2: + return nil, fmt.Errorf("wallet_pay: input too long") + case -3: + return nil, fmt.Errorf("wallet_pay: recipient not allowed") + case -4: + return nil, fmt.Errorf("wallet_pay: amount exceeds cap") + default: + return nil, fmt.Errorf("wallet_pay failed: code %d", rc) + } + } + + // Parse receipt: [receipt_len: 4 bytes LE][receipt: N bytes]. + if len(receiptBuf) < 4 { + return nil, nil + } + receiptLen := binary.LittleEndian.Uint32(receiptBuf[:4]) + if int(receiptLen)+4 > len(receiptBuf) { + return nil, fmt.Errorf("wallet_pay: receipt truncated") + } + return receiptBuf[4 : 4+receiptLen], nil +} diff --git a/sdk/igor/mock/mock.go b/sdk/igor/mock/mock.go index 786ec6c..b9b61b5 100644 --- a/sdk/igor/mock/mock.go +++ b/sdk/igor/mock/mock.go @@ -31,16 +31,20 @@ import ( // HTTPHandler is a function that handles mock HTTP requests. type HTTPHandler func(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) +// PaymentHandler is a function that handles mock wallet_pay requests. +type PaymentHandler func(amount int64, recipient, memo string) (receipt []byte, err error) + // Runtime provides mock implementations of Igor hostcalls for native testing. type Runtime struct { - mu sync.Mutex - clock func() int64 - randSrc *rand.Rand - logs []string - budget int64 - receipts [][]byte - nodePrice int64 - httpHandler HTTPHandler + mu sync.Mutex + clock func() int64 + randSrc *rand.Rand + logs []string + budget int64 + receipts [][]byte + nodePrice int64 + httpHandler HTTPHandler + paymentHandler PaymentHandler } // New creates a mock runtime using the real system clock and crypto-seeded rand. @@ -206,3 +210,28 @@ func (r *Runtime) SetHTTPHandler(h HTTPHandler) { defer r.mu.Unlock() r.httpHandler = h } + +// WalletPay implements MockBackend. +func (r *Runtime) WalletPay(amount int64, recipient, memo string) ([]byte, error) { + r.mu.Lock() + handler := r.paymentHandler + r.mu.Unlock() + if handler != nil { + return handler(amount, recipient, memo) + } + // Default: deduct from budget and return a dummy receipt. + r.mu.Lock() + defer r.mu.Unlock() + if r.budget < amount { + return nil, fmt.Errorf("insufficient budget: have %d, need %d", r.budget, amount) + } + r.budget -= amount + return []byte("mock-payment-receipt"), nil +} + +// SetPaymentHandler configures a function to handle mock wallet_pay requests. +func (r *Runtime) SetPaymentHandler(h PaymentHandler) { + r.mu.Lock() + defer r.mu.Unlock() + r.paymentHandler = h +} diff --git a/sdk/igor/mock_backend.go b/sdk/igor/mock_backend.go index 532706a..1338d2e 100644 --- a/sdk/igor/mock_backend.go +++ b/sdk/igor/mock_backend.go @@ -13,6 +13,7 @@ type MockBackend interface { WalletReceipt(index int) ([]byte, error) NodePrice() int64 HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) + WalletPay(amount int64, recipient, memo string) (receipt []byte, err error) } // activeMock is set by mock.Enable() and cleared by mock.Disable().