e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
414 lines
15 KiB
Go
414 lines
15 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))
|
|
//
|
|
// For real settlement on Base mainnet, point it at the Coinbase CDP
|
|
// facilitator, which requires an authenticated request:
|
|
//
|
|
// cfg.Facilitator = x402.CDP(os.Getenv("CDP_API_KEY_ID"), os.Getenv("CDP_API_KEY_SECRET"))
|
|
//
|
|
// 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/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// Version is the x402 protocol version this package speaks.
|
|
const Version = 1
|
|
|
|
// Header names defined by the protocol. Version 1 uses X-PAYMENT /
|
|
// X-PAYMENT-RESPONSE; version 2 renamed them to PAYMENT-SIGNATURE /
|
|
// PAYMENT-RESPONSE. We accept either request header and emit both response
|
|
// headers so any conformant client interoperates.
|
|
const (
|
|
PaymentHeader = "X-PAYMENT" // request: the client's payment payload
|
|
PaymentHeaderV2 = "PAYMENT-SIGNATURE" // request: v2 alias
|
|
PaymentResponseHeader = "X-PAYMENT-RESPONSE" // response: settlement details
|
|
PaymentResponseHeaderV2 = "PAYMENT-RESPONSE" // response: v2 alias
|
|
)
|
|
|
|
// 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", "eip155:8453"
|
|
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
|
|
MimeType string `json:"mimeType,omitempty"` // response mime type
|
|
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
|
|
Extra map[string]string `json:"extra,omitempty"` // scheme extras, e.g. EIP-712 domain {name, version}
|
|
}
|
|
|
|
// 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 (or settling) 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 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)
|
|
}
|
|
|
|
// Settler is an optional capability: a Facilitator that also settles a verified
|
|
// payment on-chain (captures the funds) and returns a settlement reference.
|
|
// Require calls Settle after a successful Verify when the facilitator
|
|
// implements it — for the "exact" scheme, verify authorizes and settle
|
|
// captures, so without settlement no funds actually move.
|
|
type Settler interface {
|
|
Settle(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"). Accepts short
|
|
// names ("base", "base-sepolia") or CAIP-2 ids ("eip155:8453").
|
|
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"`
|
|
// Extra carries scheme-specific data echoed in the requirement's "extra".
|
|
// For the EVM "exact" scheme this is the asset's EIP-712 domain, e.g.
|
|
// {"name":"USD Coin","version":"2"}; when empty it is filled in for known
|
|
// assets so clients can build a valid transfer signature.
|
|
Extra map[string]string `json:"extra,omitempty"`
|
|
// Facilitator verifies (and, if it implements Settler, settles) 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"`
|
|
// RequireSettlement fails closed when a paid request cannot be settled:
|
|
// if the facilitator only verifies (does not implement Settler), Require
|
|
// refuses to serve rather than releasing the resource while no funds move.
|
|
// Leave false only for verify-only flows where authorization is enough.
|
|
RequireSettlement bool `json:"requireSettlement,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 {
|
|
net := c.network()
|
|
asset := c.Asset
|
|
extra := c.Extra
|
|
// Fill the asset and its EIP-712 domain for known networks so a client
|
|
// can sign without the operator hand-configuring token metadata. Keyed on
|
|
// the CAIP-2 form so both "base" and "eip155:8453" resolve.
|
|
if def, ok := defaultAsset(NormalizeNetwork(net)); ok {
|
|
if asset == "" {
|
|
asset = def.Address
|
|
}
|
|
if extra == nil && strings.EqualFold(asset, def.Address) {
|
|
extra = map[string]string{"name": def.Name, "version": def.Version}
|
|
}
|
|
}
|
|
return Requirements{
|
|
Scheme: "exact",
|
|
Network: net,
|
|
MaxAmountRequired: amount,
|
|
Resource: resource,
|
|
Description: c.Description,
|
|
MimeType: "application/json",
|
|
PayTo: c.PayTo,
|
|
Asset: asset,
|
|
MaxTimeoutSeconds: 60,
|
|
Extra: extra,
|
|
}
|
|
}
|
|
|
|
// Payment returns the client's payment payload from either the v1 or v2
|
|
// request header, or "" if none is present.
|
|
func Payment(r *http.Request) string {
|
|
if p := r.Header.Get(PaymentHeader); p != "" {
|
|
return p
|
|
}
|
|
return r.Header.Get(PaymentHeaderV2)
|
|
}
|
|
|
|
// 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 settled, when the facilitator supports
|
|
// it) — 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 := Payment(r)
|
|
if payment == "" {
|
|
writeChallenge(w, req, "payment required")
|
|
return false
|
|
}
|
|
fac := c.facilitator()
|
|
res, err := fac.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
|
|
}
|
|
// Capture the funds when the facilitator can settle. Verify alone only
|
|
// authorizes the "exact" transfer; settlement broadcasts it.
|
|
s, canSettle := fac.(Settler)
|
|
if c.RequireSettlement && !canSettle {
|
|
// Fail closed: a paid config must not serve the resource on a
|
|
// verify-only facilitator, or it gives the tool away for free.
|
|
writeChallenge(w, req, "payment settlement unavailable")
|
|
return false
|
|
}
|
|
if canSettle {
|
|
sres, err := s.Settle(r.Context(), payment, req)
|
|
if err != nil {
|
|
writeChallenge(w, req, "payment settlement failed: "+err.Error())
|
|
return false
|
|
}
|
|
if !sres.Valid {
|
|
reason := sres.Reason
|
|
if reason == "" {
|
|
reason = "settlement rejected"
|
|
}
|
|
writeChallenge(w, req, reason)
|
|
return false
|
|
}
|
|
if sres.Settlement != "" {
|
|
res.Settlement = sres.Settlement
|
|
}
|
|
}
|
|
if res.Settlement != "" {
|
|
setSettlementHeaders(w, 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 setSettlementHeaders(w http.ResponseWriter, settlement string) {
|
|
w.Header().Set(PaymentResponseHeader, settlement)
|
|
w.Header().Set(PaymentResponseHeaderV2, settlement)
|
|
}
|
|
|
|
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 and settles payments by POSTing to an x402
|
|
// facilitator's /verify and /settle endpoints (Coinbase CDP, Alchemy, or
|
|
// self-hosted). It carries no chain or crypto code itself; when the endpoint
|
|
// requires authentication (e.g. CDP), set Authorize to attach credentials —
|
|
// see CDP.
|
|
type HTTPFacilitator struct {
|
|
URL string
|
|
Client *http.Client
|
|
// Authorize, when set, is called on each facilitator request to attach
|
|
// authentication (e.g. a Bearer token). Nil for open facilitators.
|
|
Authorize func(*http.Request) error
|
|
}
|
|
|
|
// Verify checks the payment is valid against the requirements.
|
|
func (f *HTTPFacilitator) Verify(ctx context.Context, payment string, req Requirements) (Result, error) {
|
|
out, err := f.post(ctx, "/verify", payment, req)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{Valid: out.IsValid, Reason: firstNonEmpty(out.InvalidReason, out.Error), Payer: out.Payer}, nil
|
|
}
|
|
|
|
// Settle captures a verified payment on-chain and returns the transaction
|
|
// reference, satisfying Settler.
|
|
func (f *HTTPFacilitator) Settle(ctx context.Context, payment string, req Requirements) (Result, error) {
|
|
out, err := f.post(ctx, "/settle", payment, req)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
// Facilitators report settlement as "success" with a "transaction" ref.
|
|
ok := out.Success || out.IsValid
|
|
return Result{Valid: ok, Reason: firstNonEmpty(out.ErrorReason, out.InvalidReason, out.Error), Payer: out.Payer, Settlement: out.Transaction}, nil
|
|
}
|
|
|
|
type facilitatorResponse struct {
|
|
IsValid bool `json:"isValid"`
|
|
Success bool `json:"success"`
|
|
InvalidReason string `json:"invalidReason"`
|
|
ErrorReason string `json:"errorReason"`
|
|
Error string `json:"error"`
|
|
Payer string `json:"payer"`
|
|
Transaction string `json:"transaction"`
|
|
}
|
|
|
|
func (f *HTTPFacilitator) post(ctx context.Context, path, payment string, req Requirements) (facilitatorResponse, error) {
|
|
var out facilitatorResponse
|
|
if f.URL == "" {
|
|
return out, fmt.Errorf("no facilitator configured")
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"x402Version": Version,
|
|
"paymentPayload": decodePayment(payment),
|
|
"paymentRequirements": req,
|
|
})
|
|
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(f.URL, "/")+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
hreq.Header.Set("Content-Type", "application/json")
|
|
if f.Authorize != nil {
|
|
if err := f.Authorize(hreq); err != nil {
|
|
return out, fmt.Errorf("authorize: %w", err)
|
|
}
|
|
}
|
|
cl := f.Client
|
|
if cl == nil {
|
|
cl = http.DefaultClient
|
|
}
|
|
resp, err := cl.Do(hreq)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
var buf bytes.Buffer
|
|
_, _ = buf.ReadFrom(resp.Body)
|
|
return out, fmt.Errorf("facilitator %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(buf.String()))
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decodePayment turns the base64 X-PAYMENT header into the PaymentPayload
|
|
// object facilitators expect. If it is not base64 JSON, the raw value is passed
|
|
// through unchanged (some facilitators accept the encoded string).
|
|
func decodePayment(payment string) any {
|
|
payment = strings.TrimSpace(payment)
|
|
for _, dec := range []*base64.Encoding{base64.StdEncoding, base64.RawURLEncoding} {
|
|
if raw, err := dec.DecodeString(payment); err == nil {
|
|
var obj any
|
|
if json.Unmarshal(raw, &obj) == nil {
|
|
return obj
|
|
}
|
|
}
|
|
}
|
|
return payment
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|