feat(x402): opt-in agent-native payments for tools (#2964)

Integrate the x402 payment protocol (HTTP 402) so a tool can require a
stablecoin payment and an agent can settle it — the next step after
autonomous agents (blog 21): agents that act, and pay.

- wrapper/x402: HTTP middleware enforcing the 402 challenge/verify flow,
  with a pluggable Facilitator interface. Go Micro carries no chain or
  crypto code — verification/settlement is delegated to a facilitator
  (Coinbase CDP, Alchemy, self-hosted), so Base and Solana are just
  different facilitators behind one interface. HTTPFacilitator default;
  tests cover challenge / accept / reject via a mock facilitator.
- MCP gateway: optional Options.Payment gates /mcp/call (listing tools
  and health stay free); off unless configured.
- micro mcp serve and micro-mcp-gateway: opt-in --x402-pay-to/-price/
  -network/-facilitator flags (env vars on the standalone binary).
- blog/22 'Integrating x402: Payments for Agents'; README feature row.

Pricing is flat per call for now; richer models and an agent-side spend
cap (next to MaxSteps/ApproveTool) are follow-ups.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Asim Aslam
2026-06-15 16:41:25 +01:00
committed by GitHub
co-authored by Claude
parent 6eec83a045
commit 9deac487cb
9 changed files with 507 additions and 52 deletions
+1
View File
@@ -61,3 +61,4 @@ examples/mcp/hello/hello
# Built example/harness binaries (go build ./path/... drops these at repo root)
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
+1
View File
@@ -223,6 +223,7 @@ agent := micro.NewAgent("assistant",
| Guardrails | `MaxSteps` (stopping condition) and `ApproveTool` (human-in-the-loop) on every agent |
| Workflows | `micro.NewFlow()` — event-driven; runs a step or triggers an agent |
| MCP gateway | Every endpoint is an AI tool automatically |
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
+32
View File
@@ -37,6 +37,7 @@ import (
"go-micro.dev/v5/registry"
"go-micro.dev/v5/registry/consul"
"go-micro.dev/v5/registry/etcd"
"go-micro.dev/v5/wrapper/x402"
"github.com/urfave/cli/v2"
)
@@ -66,6 +67,27 @@ func main() {
Usage: "Registry address (e.g., consul:8500, etcd:2379)",
EnvVars: []string{"MICRO_REGISTRY_ADDRESS"},
},
&cli.StringFlag{
Name: "x402-pay-to",
Usage: "Enable x402 payments for tool calls; the address payments are sent to",
EnvVars: []string{"X402_PAY_TO"},
},
&cli.StringFlag{
Name: "x402-price",
Usage: "Per-call price in the asset's smallest unit (e.g. 10000 = 0.01 USDC)",
EnvVars: []string{"X402_PRICE"},
},
&cli.StringFlag{
Name: "x402-network",
Usage: "Payment network: base (default), solana, ...",
Value: "base",
EnvVars: []string{"X402_NETWORK"},
},
&cli.StringFlag{
Name: "x402-facilitator",
Usage: "x402 facilitator URL (Coinbase CDP, Alchemy, or self-hosted)",
EnvVars: []string{"X402_FACILITATOR"},
},
&cli.Float64Flag{
Name: "rate-limit",
Usage: "Requests per second per tool (0 = unlimited)",
@@ -131,6 +153,16 @@ func run(c *cli.Context) error {
Logger: logger,
}
// Opt-in x402 payments: enabled when a pay-to address is given.
if payTo := c.String("x402-pay-to"); payTo != "" {
opts.Payment = &x402.Config{
PayTo: payTo,
Price: c.String("x402-price"),
Network: c.String("x402-network"),
FacilitatorURL: c.String("x402-facilitator"),
}
}
// Rate limiting
if rps := c.Float64("rate-limit"); rps > 0 {
opts.RateLimit = &mcp.RateLimitConfig{
+77 -50
View File
@@ -18,6 +18,7 @@ import (
"go-micro.dev/v5/codec/bytes"
"go-micro.dev/v5/gateway/mcp"
"go-micro.dev/v5/registry"
"go-micro.dev/v5/wrapper/x402"
)
func init() {
@@ -84,6 +85,23 @@ Examples:
Name: "registry_address",
Usage: "Registry address (e.g., consul:8500)",
},
&cli.StringFlag{
Name: "x402_pay_to",
Usage: "Enable x402 payments for tool calls; the address payments are sent to",
},
&cli.StringFlag{
Name: "x402_price",
Usage: "Per-call price in the asset's smallest unit (e.g. 10000 = 0.01 USDC)",
},
&cli.StringFlag{
Name: "x402_network",
Usage: "Payment network: base (default), solana, ...",
Value: "base",
},
&cli.StringFlag{
Name: "x402_facilitator",
Usage: "x402 facilitator URL (Coinbase CDP, Alchemy, or self-hosted)",
},
},
Action: serveAction,
},
@@ -233,6 +251,16 @@ func serveAction(ctx *cli.Context) error {
Logger: log.Default(),
}
// Opt-in x402 payments: enabled when a pay-to address is given.
if payTo := ctx.String("x402_pay_to"); payTo != "" {
opts.Payment = &x402.Config{
PayTo: payTo,
Price: ctx.String("x402_price"),
Network: ctx.String("x402_network"),
FacilitatorURL: ctx.String("x402_facilitator"),
}
}
// Handle shutdown gracefully
ctx2, cancel := context.WithCancel(opts.Context)
opts.Context = ctx2
@@ -359,7 +387,7 @@ func testAction(ctx *cli.Context) error {
serviceName := parts[0]
endpointName := parts[1]
// If tool name has 3 parts, combine last two for endpoint (e.g., Handler.Method)
if len(parts) == 3 {
endpointName = parts[1] + "." + parts[2]
@@ -401,10 +429,10 @@ func testAction(ctx *cli.Context) error {
if c == nil {
c = client.DefaultClient
}
// Create request with bytes frame
req := c.NewRequest(serviceName, endpointName, &bytes.Frame{Data: inputBytes})
// Make the call
var rsp bytes.Frame
if err := c.Call(opts.Context, req, &rsp); err != nil {
@@ -415,7 +443,7 @@ func testAction(ctx *cli.Context) error {
// Parse and display response
fmt.Println("✅ Call successful!")
fmt.Println("\nResponse:")
// Try to pretty-print JSON response
var result interface{}
if err := json.Unmarshal(rsp.Data, &result); err == nil {
@@ -442,7 +470,7 @@ func parseTool(toolName string) []string {
func docsAction(ctx *cli.Context) error {
// Get registry
reg := registry.DefaultRegistry
// Create temporary MCP server to discover tools
opts := mcp.Options{
Registry: reg,
@@ -472,15 +500,15 @@ func docsAction(ctx *cli.Context) error {
// Collect all tools with metadata
type ToolDoc struct {
Name string `json:"name"`
Service string `json:"service"`
Endpoint string `json:"endpoint"`
Description string `json:"description"`
Example string `json:"example,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Service string `json:"service"`
Endpoint string `json:"endpoint"`
Description string `json:"description"`
Example string `json:"example,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
var tools []ToolDoc
for _, svc := range services {
fullSvcs, err := opts.Registry.GetService(svc.Name)
@@ -496,22 +524,22 @@ func docsAction(ctx *cli.Context) error {
Description: fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name),
Metadata: ep.Metadata,
}
// Extract description from metadata if available
if desc, ok := ep.Metadata["description"]; ok {
toolDoc.Description = desc
}
// Extract example from metadata if available
if example, ok := ep.Metadata["example"]; ok {
toolDoc.Example = example
}
// Extract scopes from metadata if available
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
toolDoc.Scopes = strings.Split(scopesStr, ",")
}
tools = append(tools, toolDoc)
}
}
@@ -525,38 +553,37 @@ func docsAction(ctx *cli.Context) error {
"tools": tools,
"count": len(tools),
})
case "markdown":
fmt.Fprintf(writer, "# MCP Tools Documentation\n\n")
fmt.Fprintf(writer, "Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))
fmt.Fprintf(writer, "Total Tools: %d\n\n", len(tools))
// Group by service
serviceMap := make(map[string][]ToolDoc)
for _, tool := range tools {
serviceMap[tool.Service] = append(serviceMap[tool.Service], tool)
}
for service, serviceTools := range serviceMap {
fmt.Fprintf(writer, "## Service: %s\n\n", service)
for _, tool := range serviceTools {
fmt.Fprintf(writer, "### %s\n\n", tool.Name)
fmt.Fprintf(writer, "**Description:** %s\n\n", tool.Description)
if len(tool.Scopes) > 0 {
fmt.Fprintf(writer, "**Required Scopes:** %s\n\n", strings.Join(tool.Scopes, ", "))
}
if tool.Example != "" {
fmt.Fprintf(writer, "**Example Input:**\n```json\n%s\n```\n\n", tool.Example)
}
}
}
return nil
default:
return fmt.Errorf("unsupported format: %s (supported: markdown, json)", format)
}
@@ -569,10 +596,10 @@ func exportAction(ctx *cli.Context) error {
}
exportFormat := ctx.Args().First()
// Get registry
reg := registry.DefaultRegistry
// Create temporary MCP server to discover tools
opts := mcp.Options{
Registry: reg,
@@ -619,7 +646,7 @@ func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Opt
fmt.Fprintf(writer, "import requests\nimport json\n\n")
fmt.Fprintf(writer, "# Configure your MCP gateway endpoint\n")
fmt.Fprintf(writer, "MCP_GATEWAY_URL = 'http://localhost:3000/mcp'\n\n")
fmt.Fprintf(writer, "def call_mcp_tool(tool_name, arguments):\n")
fmt.Fprintf(writer, " \"\"\"Call an MCP tool via HTTP gateway\"\"\"\n")
fmt.Fprintf(writer, " response = requests.post(\n")
@@ -628,10 +655,10 @@ func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Opt
fmt.Fprintf(writer, " )\n")
fmt.Fprintf(writer, " response.raise_for_status()\n")
fmt.Fprintf(writer, " return response.json()\n\n")
fmt.Fprintf(writer, "# Define tools\n")
fmt.Fprintf(writer, "tools = []\n\n")
for _, svc := range services {
fullSvcs, err := opts.Registry.GetService(svc.Name)
if err != nil || len(fullSvcs) == 0 {
@@ -641,19 +668,19 @@ func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Opt
for _, ep := range fullSvcs[0].Endpoints {
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
if desc, ok := ep.Metadata["description"]; ok {
description = desc
}
// Generate Python function name (replace dots with underscores)
funcName := strings.ReplaceAll(toolName, ".", "_")
fmt.Fprintf(writer, "def %s(arguments: str) -> str:\n", funcName)
fmt.Fprintf(writer, " \"\"\"% s\"\"\"\n", description)
fmt.Fprintf(writer, " args = json.loads(arguments) if isinstance(arguments, str) else arguments\n")
fmt.Fprintf(writer, " return json.dumps(call_mcp_tool('%s', args))\n\n", toolName)
fmt.Fprintf(writer, "tools.append(Tool(\n")
fmt.Fprintf(writer, " name='%s',\n", toolName)
fmt.Fprintf(writer, " func=%s,\n", funcName)
@@ -661,7 +688,7 @@ func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Opt
fmt.Fprintf(writer, "))\n\n")
}
}
fmt.Fprintf(writer, "# Example usage:\n")
fmt.Fprintf(writer, "# from langchain.agents import initialize_agent, AgentType\n")
fmt.Fprintf(writer, "# from langchain.llms import OpenAI\n")
@@ -669,7 +696,7 @@ func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Opt
fmt.Fprintf(writer, "# llm = OpenAI(temperature=0)\n")
fmt.Fprintf(writer, "# agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)\n")
fmt.Fprintf(writer, "# agent.run('Your query here')\n")
return nil
}
@@ -690,9 +717,9 @@ func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Optio
},
"paths": make(map[string]interface{}),
}
paths := spec["paths"].(map[string]interface{})
for _, svc := range services {
fullSvcs, err := opts.Registry.GetService(svc.Name)
if err != nil || len(fullSvcs) == 0 {
@@ -702,12 +729,12 @@ func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Optio
for _, ep := range fullSvcs[0].Endpoints {
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
path := fmt.Sprintf("/mcp/call/%s", strings.ReplaceAll(toolName, ".", "/"))
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
if desc, ok := ep.Metadata["description"]; ok {
description = desc
}
operation := map[string]interface{}{
"summary": toolName,
"description": description,
@@ -735,7 +762,7 @@ func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Optio
},
},
}
// Add scope security if available
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
operation["security"] = []map[string]interface{}{
@@ -744,13 +771,13 @@ func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Optio
},
}
}
paths[path] = map[string]interface{}{
"post": operation,
}
}
}
// Add security schemes
spec["components"] = map[string]interface{}{
"securitySchemes": map[string]interface{}{
@@ -760,7 +787,7 @@ func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Optio
},
},
}
enc := json.NewEncoder(writer)
enc.SetIndent("", " ")
return enc.Encode(spec)
@@ -769,7 +796,7 @@ func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Optio
// exportJSON exports raw tool definitions as JSON
func exportJSON(writer *os.File, services []*registry.Service, opts mcp.Options) error {
var tools []map[string]interface{}
for _, svc := range services {
fullSvcs, err := opts.Registry.GetService(svc.Name)
if err != nil || len(fullSvcs) == 0 {
@@ -783,23 +810,23 @@ func exportJSON(writer *os.File, services []*registry.Service, opts mcp.Options)
"endpoint": ep.Name,
"metadata": ep.Metadata,
}
if desc, ok := ep.Metadata["description"]; ok {
tool["description"] = desc
}
if example, ok := ep.Metadata["example"]; ok {
tool["example"] = example
}
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
tool["scopes"] = strings.Split(scopesStr, ",")
}
tools = append(tools, tool)
}
}
enc := json.NewEncoder(writer)
enc.SetIndent("", " ")
return enc.Encode(map[string]interface{}{
+14 -2
View File
@@ -32,6 +32,7 @@ import (
"go-micro.dev/v5/metadata"
"go-micro.dev/v5/registry"
"go-micro.dev/v5/store"
"go-micro.dev/v5/wrapper/x402"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
@@ -151,6 +152,11 @@ type Options struct {
// TraceProvider: tp,
// })
TraceProvider trace.TracerProvider
// Payment, when set, requires an x402 payment for tool calls
// (the /mcp/call endpoint). Listing tools and health stay free.
// Opt-in: leave nil to disable payments.
Payment *x402.Config
}
// Server represents a running MCP gateway
@@ -553,9 +559,15 @@ func (s *Server) watchServices() {
func (s *Server) serveHTTP() error {
mux := http.NewServeMux()
// MCP endpoints
// MCP endpoints. Tool calls can be gated behind an x402 payment;
// listing tools and health stay free.
var call http.Handler = http.HandlerFunc(s.handleCallTool)
if s.opts.Payment != nil {
call = x402.Middleware(*s.opts.Payment)(call)
s.opts.Logger.Printf("[mcp] x402 payments enabled (network=%s, payTo=%s)", s.opts.Payment.Network, s.opts.Payment.PayTo)
}
mux.HandleFunc("/mcp/tools", s.handleListTools)
mux.HandleFunc("/mcp/call", s.handleCallTool)
mux.Handle("/mcp/call", call)
mux.HandleFunc("/health", s.handleHealth)
// WebSocket endpoint for bidirectional streaming
+71
View File
@@ -0,0 +1,71 @@
---
layout: blog
title: "Integrating x402: Payments for Agents"
permalink: /blog/22
description: "Agents that act on their own eventually need to pay on their own. Go Micro now speaks x402 — the HTTP 402 payment standard — so a tool can require a stablecoin payment and an agent can settle it, with the chain pluggable behind a facilitator."
---
# Integrating x402: Payments for Agents
*June 15, 2026 &bull; Asim Aslam*
The [last post](/blog/21) was about agents that run on their own — triggered by an event, acting without a human prompt. Follow that one step further and you reach something agents can't do yet in most systems: pay. An autonomous agent that calls an API, rents compute, or uses another agent's service will, sooner or later, need to settle for it — without a person reaching for a credit card. There is now a standard for exactly that, and we're integrating it.
## What x402 is
x402 is an open payment protocol built on the HTTP **402 Payment Required** status code. The flow is simple: a client requests a resource, the server answers `402` with machine-readable payment requirements (amount, asset, network, where to pay), the client pays and retries with an `X-PAYMENT` header, and the server verifies the payment and serves the resource. It's designed for stablecoins and for machine-to-machine use — agents paying for things, per request.
It started at Coinbase, it's multi-chain (Base, Solana, Ethereum, Polygon, and more), and as of April 2026 it's governed by the **x402 Foundation under the Linux Foundation**, with founding members including Google, Visa, Stripe, AWS, Mastercard, Circle, and Shopify. That governance is why we're comfortable integrating it: it's an open standard with the payments industry behind it, not a single vendor's API.
## How Go Micro integrates it
The same way it integrates everything else — interface-first, with a default, and pluggable.
The core is HTTP middleware in `wrapper/x402`. It enforces the 402 challenge and verifies payments, but it carries **no chain or crypto code**. Verification and settlement are delegated to a pluggable **Facilitator**:
```go
type Facilitator interface {
Verify(ctx context.Context, payment string, req Requirements) (Result, error)
}
```
So Go Micro stays chain-agnostic. "Base through Coinbase" and "Solana through Alchemy" are not two integrations — they're the same middleware pointed at two facilitators. The facilitator does the on-chain work; the framework speaks the protocol.
```go
pay := x402.Middleware(x402.Config{
PayTo: "0xYourAddress", // where payments go
Network: "solana", // or "base", ...
Price: "10000", // smallest units, e.g. 0.01 USDC
})
mux.Handle("/paid", pay(handler))
```
## Opt-in, at the gateway
Because every Go Micro endpoint is already an AI-callable tool through the MCP gateway, that's the natural place to charge: a tool call is the thing worth a payment. So x402 is wired into both the built-in `micro mcp serve` and the standalone `micro-mcp-gateway`, and it is strictly **opt-in** — off unless you set a pay-to address.
```bash
micro mcp serve --address :3000 \
--x402-pay-to 0xYourAddress \
--x402-network solana \
--x402-price 10000 \
--x402-facilitator https://facilitator.example
```
With payments enabled, the `/mcp/call` endpoint requires a verified payment; listing tools and health checks stay free. Without the flag, nothing changes. The standalone gateway takes the same options via flags or environment variables, so you can put a paid gateway in front of services you didn't write.
## Why this matters
Go Micro's premise has been that every service is a tool an agent can call. x402 adds one word: a *paid* tool. That turns "tools as services" into something with an economic side — a service can charge per call, and an agent can pay for it, with no human in the loop on either end. It gives the services people build a native way to be paid for, and it gives Go Micro a place in the supply side of an agent economy: the rails for agents to act *and* transact.
## Honest about the edges
- **It's opt-in and dependency-light.** No pay-to address, no payments. Go Micro pulls in no chain libraries — the facilitator does that work.
- **Pricing starts simple.** A flat price per tool call today; richer models — per-tool pricing, metered usage, subscriptions — are the harder design work to come.
- **A paying agent needs a budget.** Blog 21 argued that unattended agents need guardrails; an unattended agent that spends money needs them most. A spend cap belongs next to `MaxSteps` and `ApproveTool`, and it's the next piece to build on the agent side.
Agents that act, and now can pay. Services, agents, workflows, and payments — the substrate for software that operates, and transacts, on its own.
---
Sources: [x402 — Coinbase Developer Docs](https://docs.cdp.coinbase.com/x402/welcome), [What is x402? — Alchemy](https://www.alchemy.com/blog/how-x402-brings-real-time-crypto-payments-to-the-web), [x402 on Solana — solana.com](https://solana.com/x402/what-is-x402).
+7
View File
@@ -11,6 +11,13 @@ permalink: /blog/
<div class="posts">
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/22">Integrating x402: Payments for Agents</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">June 15, 2026</p>
<p>Agents that act on their own eventually need to pay on their own. Go Micro now speaks x402 — the HTTP 402 payment standard — so a tool can require a stablecoin payment and an agent can settle it, with the chain pluggable behind a facilitator.</p>
<a href="/blog/22">Read more &rarr;</a>
</article>
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/21">When the Event Is the Prompt</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">June 15, 2026</p>
+208
View File
@@ -0,0 +1,208 @@
// Package x402 implements the server side of the x402 payment protocol
// (the HTTP 402 "Payment Required" standard) as pluggable middleware.
//
// It lets a service or gateway require a stablecoin payment per request
// and verify it through a pluggable Facilitator (Coinbase CDP, Alchemy,
// or self-hosted), so AI agents can pay for tools and APIs autonomously.
// Go Micro stays chain-agnostic and free of crypto dependencies: it
// speaks the HTTP protocol and delegates verification and settlement to
// the facilitator, which does the on-chain work.
//
// pay := x402.Middleware(x402.Config{
// PayTo: "0xYourAddress", // where payments go
// Network: "base", // or "solana", ...
// Price: "10000", // smallest units (e.g. 0.01 USDC)
// })
// mux.Handle("/paid", pay(handler))
//
// x402 is governed by the x402 Foundation (Linux Foundation). See
// https://x402.org and https://docs.cdp.coinbase.com/x402.
package x402
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// Version is the x402 protocol version this package speaks.
const Version = 1
// Header names defined by the protocol.
const (
PaymentHeader = "X-PAYMENT" // request: the client's payment payload
PaymentResponseHeader = "X-PAYMENT-RESPONSE" // response: settlement details
)
// Requirements describes what a client must pay to access a resource —
// the body of a 402 response (one entry of "accepts").
type Requirements struct {
Scheme string `json:"scheme"` // payment scheme, e.g. "exact"
Network string `json:"network"` // chain, e.g. "base", "solana"
MaxAmountRequired string `json:"maxAmountRequired"` // amount in the asset's smallest unit
Resource string `json:"resource"` // the resource being paid for
Description string `json:"description,omitempty"` // human/agent-readable description
PayTo string `json:"payTo"` // receiving address
Asset string `json:"asset,omitempty"` // token contract/mint (default: network USDC)
MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` // how long the client has to pay
}
// challenge is the JSON body returned with a 402 response.
type challenge struct {
X402Version int `json:"x402Version"`
Accepts []Requirements `json:"accepts"`
Error string `json:"error,omitempty"`
}
// Result is the outcome of verifying a payment.
type Result struct {
Valid bool // whether the payment satisfies the requirements
Payer string // the paying address, if known
Reason string // why the payment was rejected, if not valid
Settlement string // settlement reference (e.g. tx hash), set into X-PAYMENT-RESPONSE
}
// Facilitator verifies (and optionally settles) a payment a client
// presented against the stated requirements. Implementations talk to a
// chain or a hosted facilitator; the gateway stays chain-agnostic, so a
// Base facilitator and a Solana facilitator are just different
// implementations behind this interface.
type Facilitator interface {
Verify(ctx context.Context, payment string, req Requirements) (Result, error)
}
// Config configures payment enforcement for a set of routes.
type Config struct {
// PayTo is the address payments are sent to. Required.
PayTo string
// Network is the chain to settle on (default "base").
Network string
// Asset is the token contract/mint (default: the network's USDC).
Asset string
// Price is the amount required per request, in the asset's smallest
// unit (e.g. "10000" for 0.01 USDC at 6 decimals).
Price string
// Description is shown to the paying client/agent.
Description string
// Facilitator verifies payments. Defaults to an HTTPFacilitator
// pointed at FacilitatorURL.
Facilitator Facilitator
// FacilitatorURL is the verify/settle endpoint used when Facilitator
// is nil (e.g. Coinbase CDP or Alchemy).
FacilitatorURL string
}
func (c Config) network() string {
if c.Network == "" {
return "base"
}
return c.Network
}
func (c Config) requirements(r *http.Request) Requirements {
return Requirements{
Scheme: "exact",
Network: c.network(),
MaxAmountRequired: c.Price,
Resource: r.URL.Path,
Description: c.Description,
PayTo: c.PayTo,
Asset: c.Asset,
MaxTimeoutSeconds: 60,
}
}
// Middleware returns HTTP middleware that requires an x402 payment before
// the wrapped handler runs. A request without a valid X-PAYMENT header
// receives a 402 with the payment requirements; once a payment verifies,
// the request is served.
func Middleware(cfg Config) func(http.Handler) http.Handler {
fac := cfg.Facilitator
if fac == nil {
fac = &HTTPFacilitator{URL: cfg.FacilitatorURL}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req := cfg.requirements(r)
payment := r.Header.Get(PaymentHeader)
if payment == "" {
writeChallenge(w, req, "payment required")
return
}
res, err := fac.Verify(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment verification failed: "+err.Error())
return
}
if !res.Valid {
reason := res.Reason
if reason == "" {
reason = "payment invalid"
}
writeChallenge(w, req, reason)
return
}
if res.Settlement != "" {
w.Header().Set(PaymentResponseHeader, res.Settlement)
}
next.ServeHTTP(w, r)
})
}
}
func writeChallenge(w http.ResponseWriter, req Requirements, reason string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired) // 402
_ = json.NewEncoder(w).Encode(challenge{
X402Version: Version,
Accepts: []Requirements{req},
Error: reason,
})
}
// HTTPFacilitator verifies payments by POSTing to an x402 facilitator's
// verify endpoint (Coinbase CDP, Alchemy, or self-hosted). It carries no
// chain or crypto code itself.
type HTTPFacilitator struct {
URL string
Client *http.Client
}
func (f *HTTPFacilitator) Verify(ctx context.Context, payment string, req Requirements) (Result, error) {
if f.URL == "" {
return Result{}, fmt.Errorf("no facilitator configured")
}
body, _ := json.Marshal(map[string]any{
"x402Version": Version,
"paymentPayload": payment,
"paymentRequirements": req,
})
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, f.URL+"/verify", bytes.NewReader(body))
if err != nil {
return Result{}, err
}
hreq.Header.Set("Content-Type", "application/json")
cl := f.Client
if cl == nil {
cl = http.DefaultClient
}
resp, err := cl.Do(hreq)
if err != nil {
return Result{}, err
}
defer resp.Body.Close()
var out struct {
IsValid bool `json:"isValid"`
InvalidReason string `json:"invalidReason"`
Payer string `json:"payer"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return Result{}, err
}
return Result{Valid: out.IsValid, Reason: out.InvalidReason, Payer: out.Payer}, nil
}
+96
View File
@@ -0,0 +1,96 @@
package x402
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type mockFacilitator struct {
valid bool
reason string
}
func (m mockFacilitator) Verify(ctx context.Context, payment string, req Requirements) (Result, error) {
return Result{Valid: m.valid, Reason: m.reason, Payer: "0xpayer", Settlement: "0xtx"}, nil
}
func paidHandler(cfg Config) http.Handler {
served := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
return Middleware(cfg)(served)
}
// No payment yields a 402 with the requirements describing where to pay.
func TestChallengeWhenNoPayment(t *testing.T) {
h := paidHandler(Config{PayTo: "0xabc", Network: "solana", Price: "10000", Facilitator: mockFacilitator{valid: true}})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tool", nil))
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status = %d, want 402", rec.Code)
}
var ch challenge
if err := json.Unmarshal(rec.Body.Bytes(), &ch); err != nil {
t.Fatalf("challenge body: %v", err)
}
if ch.X402Version != Version || len(ch.Accepts) != 1 {
t.Fatalf("unexpected challenge: %+v", ch)
}
req := ch.Accepts[0]
if req.PayTo != "0xabc" || req.Network != "solana" || req.MaxAmountRequired != "10000" {
t.Errorf("requirements not advertised correctly: %+v", req)
}
}
// A payment the facilitator accepts lets the request through, and the
// settlement is surfaced on the response.
func TestServesWhenPaymentValid(t *testing.T) {
h := paidHandler(Config{PayTo: "0xabc", Price: "10000", Facilitator: mockFacilitator{valid: true}})
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "base64-payment-payload")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.String() != "ok" {
t.Errorf("handler not served, body = %q", rec.Body.String())
}
if rec.Header().Get(PaymentResponseHeader) != "0xtx" {
t.Errorf("settlement not surfaced in %s", PaymentResponseHeader)
}
}
// A payment the facilitator rejects gets a 402 with the reason.
func TestChallengeWhenPaymentInvalid(t *testing.T) {
h := paidHandler(Config{PayTo: "0xabc", Price: "10000", Facilitator: mockFacilitator{valid: false, reason: "insufficient amount"}})
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "bad-payment")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status = %d, want 402", rec.Code)
}
var ch challenge
json.Unmarshal(rec.Body.Bytes(), &ch)
if ch.Error != "insufficient amount" {
t.Errorf("reason not surfaced: %q", ch.Error)
}
}
// Network defaults to base when unset.
func TestNetworkDefault(t *testing.T) {
if got := (Config{}).network(); got != "base" {
t.Errorf("default network = %q, want base", got)
}
}