项目文件夹

文件
Asim Aslam e4d2a41c32
goreleaser / goreleaser (push) Has been cancelled
refactor(x402): Amount/Amounts naming + per-tool amounts + docs (#2965)
Follow-up to the merged x402 integration (#2964). Drop the commerce-y
'price' vocabulary for the protocol's own 'amount', and add per-tool
pricing as an operator concern (the way scopes/rate-limits are set at the
gateway).

- x402.Config: Price -> Amount (default), plus Amounts map for per-tool
  overrides; AmountFor(tool) resolves per-tool -> default. Add a Require
  primitive (per-request enforcement) and LoadConfig for an operator
  config file.
- MCP gateway: enforce payment per-tool inside /mcp/call (where scopes
  are enforced) using AmountFor, instead of a flat path-based middleware.
- CLI: --x402-price -> --x402-amount; add --x402-config (per-tool file)
  to micro mcp serve and micro-mcp-gateway.
- docs: new Payments (x402) guide + nav + README section; blog/22
  updated to Amount/Amounts and the config-file model.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 17:04:34 +01:00

255 行
8.8 KiB
Go

// 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", ...
// Amount: "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"
"os"
)
// 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 or tools.
type Config struct {
// PayTo is the address payments are sent to. Required.
PayTo string `json:"payTo"`
// Network is the chain to settle on (default "base").
Network string `json:"network,omitempty"`
// Asset is the token contract/mint (default: the network's USDC).
Asset string `json:"asset,omitempty"`
// Amount is the default amount required per request, in the asset's
// smallest unit (e.g. "10000" for 0.01 USDC at 6 decimals). "0" or
// empty means free.
Amount string `json:"amount,omitempty"`
// Amounts overrides Amount per tool/resource name, so an operator can
// charge for tools individually — the way Scopes and RateLimit are
// configured per tool at the gateway.
Amounts map[string]string `json:"amounts,omitempty"`
// Description is shown to the paying client/agent.
Description string `json:"description,omitempty"`
// Facilitator verifies payments. Defaults to an HTTPFacilitator
// pointed at FacilitatorURL.
Facilitator Facilitator `json:"-"`
// FacilitatorURL is the verify/settle endpoint used when Facilitator
// is nil (e.g. Coinbase CDP or Alchemy).
FacilitatorURL string `json:"facilitator,omitempty"`
}
func (c Config) network() string {
if c.Network == "" {
return "base"
}
return c.Network
}
// AmountFor returns the amount required for a named tool/resource: the
// per-tool override if present, otherwise the default Amount.
func (c Config) AmountFor(name string) string {
if a, ok := c.Amounts[name]; ok {
return a
}
return c.Amount
}
func (c Config) facilitator() Facilitator {
if c.Facilitator != nil {
return c.Facilitator
}
return &HTTPFacilitator{URL: c.FacilitatorURL}
}
func (c Config) requirements(amount, resource string) Requirements {
return Requirements{
Scheme: "exact",
Network: c.network(),
MaxAmountRequired: amount,
Resource: resource,
Description: c.Description,
PayTo: c.PayTo,
Asset: c.Asset,
MaxTimeoutSeconds: 60,
}
}
// Require enforces payment of amount for a single request. It returns
// true if the request may proceed — the amount is free ("" or "0"), or a
// valid payment was presented — and false once it has written a 402
// challenge, in which case the caller must stop. resource names what is
// being paid for (a tool name or URL path).
func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource string) bool {
if amount == "" || amount == "0" {
return true // free
}
req := c.requirements(amount, resource)
payment := r.Header.Get(PaymentHeader)
if payment == "" {
writeChallenge(w, req, "payment required")
return false
}
res, err := c.facilitator().Verify(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment verification failed: "+err.Error())
return false
}
if !res.Valid {
reason := res.Reason
if reason == "" {
reason = "payment invalid"
}
writeChallenge(w, req, reason)
return false
}
if res.Settlement != "" {
w.Header().Set(PaymentResponseHeader, res.Settlement)
}
return true
}
// Middleware returns HTTP middleware that requires the default Amount for
// any wrapped route. For per-tool amounts, resolve the amount with
// AmountFor and call Require directly (the MCP gateway does this).
func Middleware(cfg Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.Require(w, r, cfg.Amount, r.URL.Path) {
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,
})
}
// LoadConfig reads an x402 config file (JSON) describing the operator's
// payTo address, network, asset, default amount, and per-tool amounts:
//
// { "payTo": "0x…", "network": "solana", "asset": "USDC",
// "amount": "0", "amounts": { "weather.Weather.Forecast": "10000" } }
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parse x402 config %s: %w", path, err)
}
return &c, nil
}
// 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
}