chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package x402
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CDPFacilitatorURL is Coinbase Developer Platform's hosted x402 facilitator,
|
||||
// which can settle real payments on Base mainnet (the open x402.org facilitator
|
||||
// is testnet-only).
|
||||
const CDPFacilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
|
||||
|
||||
// CDP returns a Facilitator (and Settler) backed by Coinbase's hosted
|
||||
// facilitator, authenticating each verify/settle call with a short-lived
|
||||
// Ed25519 Bearer JWT minted from a CDP Secret API Key. keyID and keySecret are
|
||||
// the CDP API Key ID and base64 Ed25519 secret; the secret is used only to sign
|
||||
// the JWT (stdlib crypto — no chain code, no external dependency).
|
||||
//
|
||||
// cfg.Facilitator = x402.CDP(os.Getenv("CDP_API_KEY_ID"), os.Getenv("CDP_API_KEY_SECRET"))
|
||||
func CDP(keyID, keySecret string) *HTTPFacilitator {
|
||||
return &HTTPFacilitator{
|
||||
URL: CDPFacilitatorURL,
|
||||
Authorize: cdpAuthorizer(keyID, keySecret),
|
||||
}
|
||||
}
|
||||
|
||||
// cdpAuthorizer returns an Authorize hook that attaches a CDP Bearer JWT bound
|
||||
// to the request's method and URL.
|
||||
func cdpAuthorizer(keyID, keySecret string) func(*http.Request) error {
|
||||
return func(r *http.Request) error {
|
||||
tok, err := cdpBearer(keyID, keySecret, r.Method, r.URL.Host, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cdpBearer builds a CDP Bearer JWT (EdDSA / Ed25519) authorizing a single REST
|
||||
// call, per CDP's authentication spec: the token binds to "METHOD host/path"
|
||||
// and is valid for two minutes.
|
||||
func cdpBearer(keyID, keySecret, method, host, path string) (string, error) {
|
||||
if keyID == "" || keySecret == "" {
|
||||
return "", fmt.Errorf("CDP API key id and secret are required")
|
||||
}
|
||||
key, err := ed25519KeyFromSecret(keySecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
header := map[string]any{
|
||||
"alg": "EdDSA",
|
||||
"typ": "JWT",
|
||||
"kid": keyID,
|
||||
"nonce": hex.EncodeToString(nonce),
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
claims := map[string]any{
|
||||
"sub": keyID,
|
||||
"iss": "cdp",
|
||||
"aud": []string{"cdp_service"},
|
||||
"nbf": now,
|
||||
"exp": now + 120,
|
||||
"uri": method + " " + host + path,
|
||||
}
|
||||
hb, _ := json.Marshal(header)
|
||||
cb, _ := json.Marshal(claims)
|
||||
signing := b64url(hb) + "." + b64url(cb)
|
||||
sig := ed25519.Sign(key, []byte(signing))
|
||||
return signing + "." + b64url(sig), nil
|
||||
}
|
||||
|
||||
// ed25519KeyFromSecret decodes a CDP Ed25519 secret. CDP secrets are base64 and
|
||||
// decode to 64 bytes (32-byte seed + 32-byte public key) — Go's PrivateKey
|
||||
// layout; a bare 32-byte seed is also accepted.
|
||||
func ed25519KeyFromSecret(secret string) (ed25519.PrivateKey, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if strings.Contains(secret, "BEGIN") {
|
||||
return nil, fmt.Errorf("CDP secret looks like a PEM/EC key; x402 bearer auth needs an Ed25519 Secret API Key")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
if raw, err = base64.RawURLEncoding.DecodeString(secret); err != nil {
|
||||
return nil, fmt.Errorf("CDP secret is not valid base64: %w", err)
|
||||
}
|
||||
}
|
||||
switch len(raw) {
|
||||
case ed25519.PrivateKeySize: // 64: seed + public key
|
||||
return ed25519.PrivateKey(raw), nil
|
||||
case ed25519.SeedSize: // 32: seed only
|
||||
return ed25519.NewKeyFromSeed(raw), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("CDP secret decoded to %d bytes; expected 32 or 64 (Ed25519)", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
@@ -0,0 +1,156 @@
|
||||
package x402
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Payer produces a payment payload for the given requirements. A real
|
||||
// implementation signs a stablecoin authorization with a wallet; tests
|
||||
// and local development can return a fixed token. It is the consumer
|
||||
// counterpart to the Facilitator (which verifies on the server side).
|
||||
type Payer interface {
|
||||
Pay(ctx context.Context, req Requirements) (payment string, err error)
|
||||
}
|
||||
|
||||
// Client is an HTTP client that automatically settles x402 payment
|
||||
// challenges, up to an optional spend Budget. It is the consumer
|
||||
// counterpart to Middleware: point it at a paid tool, and a 402 is paid
|
||||
// and retried transparently — unless paying would exceed the budget, the
|
||||
// spend cap that keeps an autonomous, paying caller in bounds.
|
||||
type Client struct {
|
||||
// HTTP is the underlying client (defaults to http.DefaultClient).
|
||||
HTTP *http.Client
|
||||
// Payer constructs payment payloads. Required to pay.
|
||||
Payer Payer
|
||||
// Budget caps total spend across all calls, in the asset's smallest
|
||||
// unit (0 = unlimited). A call that would exceed it is refused before
|
||||
// any payment is made.
|
||||
Budget int64
|
||||
|
||||
mu sync.Mutex
|
||||
spent int64
|
||||
}
|
||||
|
||||
// Spent returns the total amount paid so far, in the asset's smallest unit.
|
||||
func (c *Client) Spent() int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.spent
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// Do performs req. If the server answers 402, Do reads the requirements,
|
||||
// checks the budget, pays via Payer, and retries once with the payment
|
||||
// attached. It returns an error (without paying) if the payment would
|
||||
// exceed the budget.
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
// Buffer the body so the request can be replayed after paying.
|
||||
var body []byte
|
||||
if req.Body != nil {
|
||||
b, err := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = b
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil || resp.StatusCode != http.StatusPaymentRequired {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
var ch challenge
|
||||
_ = json.NewDecoder(resp.Body).Decode(&ch)
|
||||
resp.Body.Close()
|
||||
if len(ch.Accepts) == 0 {
|
||||
return resp, fmt.Errorf("x402: 402 response carried no requirements")
|
||||
}
|
||||
reqd := ch.Accepts[0]
|
||||
// The amount governs the whole spend cap, so it must be a real positive
|
||||
// integer. A swallowed parse error (non-decimal, overflow, empty) would
|
||||
// yield 0 and pass the budget check trivially, and a negative amount would
|
||||
// inflate the remaining allowance — either way the cap is defeated. Refuse
|
||||
// before signing anything.
|
||||
amount, err := strconv.ParseInt(strings.TrimSpace(reqd.MaxAmountRequired), 10, 64)
|
||||
if err != nil || amount <= 0 {
|
||||
return resp, fmt.Errorf("x402: refusing to pay %s: invalid maxAmountRequired %q",
|
||||
reqd.Resource, reqd.MaxAmountRequired)
|
||||
}
|
||||
|
||||
// Spend cap: reserve before paying so concurrent calls cannot all pass
|
||||
// the check and overspend the caller's allowance. Roll the reservation
|
||||
// back if payment construction, replay, or verification fails.
|
||||
c.mu.Lock()
|
||||
if c.Budget > 0 && c.spent+amount > c.Budget {
|
||||
spent := c.spent
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("x402: paying %d for %s would exceed budget (spent %d of %d)",
|
||||
amount, reqd.Resource, spent, c.Budget)
|
||||
}
|
||||
c.spent += amount
|
||||
c.mu.Unlock()
|
||||
reserved := true
|
||||
rollback := func() {
|
||||
if !reserved {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.spent -= amount
|
||||
c.mu.Unlock()
|
||||
reserved = false
|
||||
}
|
||||
|
||||
if c.Payer == nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("x402: payment required for %s but no Payer configured", reqd.Resource)
|
||||
}
|
||||
payment, err := c.Payer.Pay(req.Context(), reqd)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("x402: pay: %w", err)
|
||||
}
|
||||
|
||||
// Replay the request with the payment attached.
|
||||
var rbody io.Reader
|
||||
if body != nil {
|
||||
rbody = bytes.NewReader(body)
|
||||
}
|
||||
retry, err := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), rbody)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range req.Header {
|
||||
retry.Header[k] = v
|
||||
}
|
||||
retry.Header.Set(PaymentHeader, payment)
|
||||
|
||||
resp2, err := c.httpClient().Do(retry)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, err
|
||||
}
|
||||
if resp2.StatusCode == http.StatusPaymentRequired {
|
||||
rollback()
|
||||
return resp2, fmt.Errorf("x402: payment for %s was rejected", reqd.Resource)
|
||||
}
|
||||
|
||||
reserved = false
|
||||
return resp2, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package x402
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockPayer returns a fixed payment payload that the (mock) facilitator
|
||||
// on the server accepts.
|
||||
type mockPayer struct{ calls int }
|
||||
|
||||
func (p *mockPayer) Pay(ctx context.Context, req Requirements) (string, error) {
|
||||
p.calls++
|
||||
return "payment-ok", nil
|
||||
}
|
||||
|
||||
// paidServer is an httptest server whose endpoint requires `amount`,
|
||||
// verified by an always-valid facilitator.
|
||||
func paidServer(amount string) *httptest.Server {
|
||||
cfg := Config{PayTo: "0xabc", Network: "base", Amount: amount, Facilitator: mockFacilitator{valid: true}}
|
||||
h := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("data"))
|
||||
}))
|
||||
return httptest.NewServer(h)
|
||||
}
|
||||
|
||||
// The client pays a 402 within budget and gets the result; spend is tracked.
|
||||
func TestClientPaysWithinBudget(t *testing.T) {
|
||||
srv := paidServer("10000")
|
||||
defer srv.Close()
|
||||
|
||||
payer := &mockPayer{}
|
||||
c := &Client{Payer: payer, Budget: 50000}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if b, _ := io.ReadAll(resp.Body); string(b) != "data" {
|
||||
t.Errorf("body = %q, want data", b)
|
||||
}
|
||||
if payer.calls != 1 {
|
||||
t.Errorf("payer called %d times, want 1", payer.calls)
|
||||
}
|
||||
if c.Spent() != 10000 {
|
||||
t.Errorf("spent = %d, want 10000", c.Spent())
|
||||
}
|
||||
}
|
||||
|
||||
// A call that would exceed the budget is refused before paying.
|
||||
func TestClientRefusesOverBudget(t *testing.T) {
|
||||
srv := paidServer("10000")
|
||||
defer srv.Close()
|
||||
|
||||
payer := &mockPayer{}
|
||||
c := &Client{Payer: payer, Budget: 5000} // less than the price
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
_, err := c.Do(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected an over-budget error")
|
||||
}
|
||||
if payer.calls != 0 {
|
||||
t.Errorf("payer should not be called when over budget (calls=%d)", payer.calls)
|
||||
}
|
||||
if c.Spent() != 0 {
|
||||
t.Errorf("nothing should be spent when refused, got %d", c.Spent())
|
||||
}
|
||||
}
|
||||
|
||||
// The budget accumulates across calls and stops further spend.
|
||||
func TestClientBudgetAccumulates(t *testing.T) {
|
||||
srv := paidServer("10000")
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{Payer: &mockPayer{}, Budget: 15000}
|
||||
|
||||
// First call (10000) fits.
|
||||
req1, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if _, err := c.Do(req1); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
// Second call (another 10000) would total 20000 > 15000 — refused.
|
||||
req2, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if _, err := c.Do(req2); err == nil {
|
||||
t.Fatal("second call should be refused (would exceed budget)")
|
||||
}
|
||||
if c.Spent() != 10000 {
|
||||
t.Errorf("spent = %d, want 10000", c.Spent())
|
||||
}
|
||||
}
|
||||
|
||||
// A free endpoint needs no payer and no spend.
|
||||
func TestClientFreeEndpoint(t *testing.T) {
|
||||
srv := paidServer("0") // free
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{} // no payer
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (free)", resp.StatusCode)
|
||||
}
|
||||
if c.Spent() != 0 {
|
||||
t.Errorf("free call should spend nothing, got %d", c.Spent())
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent callers reserve budget before paying, so a spend cap cannot be
|
||||
// overshot by parallel tool calls that all observe the same pre-spend balance.
|
||||
func TestClientBudgetReservedConcurrently(t *testing.T) {
|
||||
srv := paidServer("10000")
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{Payer: &mockPayer{}, Budget: 10000}
|
||||
errCh := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
resp, err := c.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
errCh <- err
|
||||
}()
|
||||
}
|
||||
|
||||
var paid, refused int
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errCh; err != nil {
|
||||
refused++
|
||||
} else {
|
||||
paid++
|
||||
}
|
||||
}
|
||||
if paid != 1 || refused != 1 {
|
||||
t.Fatalf("paid=%d refused=%d, want one paid and one refused", paid, refused)
|
||||
}
|
||||
if c.Spent() != 10000 {
|
||||
t.Errorf("spent = %d, want 10000", c.Spent())
|
||||
}
|
||||
}
|
||||
|
||||
// A reserved budget slot is released when payment cannot be produced, so a
|
||||
// transient payer failure does not permanently consume an agent's allowance.
|
||||
func TestClientBudgetReservationRollsBackOnPayError(t *testing.T) {
|
||||
srv := paidServer("10000")
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{Payer: payerFunc(func(context.Context, Requirements) (string, error) {
|
||||
return "", context.Canceled
|
||||
}), Budget: 10000}
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if _, err := c.Do(req); err == nil {
|
||||
t.Fatal("expected pay error")
|
||||
}
|
||||
if c.Spent() != 0 {
|
||||
t.Errorf("failed payment should roll back spend, got %d", c.Spent())
|
||||
}
|
||||
}
|
||||
|
||||
// A 402 whose maxAmountRequired is not a positive integer must be refused
|
||||
// before any payment — otherwise a swallowed parse error (0) or a negative
|
||||
// amount defeats the spend cap. The payer is never called and nothing is spent.
|
||||
func TestClientRefusesInvalidAmount(t *testing.T) {
|
||||
for _, amount := range []string{"abc", "-100", "99999999999999999999999999", "0x10", "1.5"} {
|
||||
srv := paidServer(amount)
|
||||
|
||||
payer := &mockPayer{}
|
||||
c := &Client{Payer: payer, Budget: 1_000_000}
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
resp, err := c.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("amount %q: expected refusal, got nil error", amount)
|
||||
}
|
||||
if payer.calls != 0 {
|
||||
t.Errorf("amount %q: payer called %d times, want 0", amount, payer.calls)
|
||||
}
|
||||
if c.Spent() != 0 {
|
||||
t.Errorf("amount %q: spent %d, want 0", amount, c.Spent())
|
||||
}
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type payerFunc func(context.Context, Requirements) (string, error)
|
||||
|
||||
func (f payerFunc) Pay(ctx context.Context, req Requirements) (string, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package x402
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLiveFacilitatorConformance is an opt-in smoke test for hosted x402
|
||||
// facilitators. It is skipped by default so local and CI runs never need live
|
||||
// credentials, but operators can run it against Coinbase, Alchemy, or a
|
||||
// self-hosted facilitator by providing a real payment payload and matching
|
||||
// requirements.
|
||||
func TestLiveFacilitatorConformance(t *testing.T) {
|
||||
url := os.Getenv("GO_MICRO_X402_LIVE_FACILITATOR_URL")
|
||||
payment := os.Getenv("GO_MICRO_X402_LIVE_PAYMENT")
|
||||
payTo := os.Getenv("GO_MICRO_X402_LIVE_PAY_TO")
|
||||
if url == "" || payment == "" || payTo == "" {
|
||||
t.Skip("set GO_MICRO_X402_LIVE_FACILITATOR_URL, GO_MICRO_X402_LIVE_PAYMENT, and GO_MICRO_X402_LIVE_PAY_TO to run live x402 facilitator conformance")
|
||||
}
|
||||
|
||||
network := getenv("GO_MICRO_X402_LIVE_NETWORK", "base")
|
||||
amount := getenv("GO_MICRO_X402_LIVE_AMOUNT", "1")
|
||||
asset := os.Getenv("GO_MICRO_X402_LIVE_ASSET")
|
||||
resource := getenv("GO_MICRO_X402_LIVE_RESOURCE", "go-micro-x402-live-conformance")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
res, err := (&HTTPFacilitator{URL: url}).Verify(ctx, payment, Requirements{
|
||||
Scheme: "exact",
|
||||
Network: network,
|
||||
MaxAmountRequired: amount,
|
||||
Resource: resource,
|
||||
PayTo: payTo,
|
||||
Asset: asset,
|
||||
MaxTimeoutSeconds: 60,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("live facilitator verify: %v", err)
|
||||
}
|
||||
if !res.Valid {
|
||||
t.Fatalf("live facilitator rejected payment: %s", res.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package x402
|
||||
|
||||
import "strings"
|
||||
|
||||
// assetInfo describes a known stablecoin: its contract address and EIP-712
|
||||
// domain (name, version) used to build a transfer-authorization signature.
|
||||
type assetInfo struct {
|
||||
Address string
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
// knownAssets maps a CAIP-2 network to its default stablecoin (USDC). Used to
|
||||
// fill in the asset and its EIP-712 domain when the operator does not specify
|
||||
// them, so a client can sign without hand-configured token metadata.
|
||||
var knownAssets = map[string]assetInfo{
|
||||
"eip155:8453": {"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "USD Coin", "2"}, // Base mainnet
|
||||
"eip155:84532": {"0x036CbD53842c5426634e7929541eC2318f3dCF7e", "USDC", "2"}, // Base Sepolia
|
||||
}
|
||||
|
||||
// NormalizeNetwork maps common short chain names to their CAIP-2 identifiers,
|
||||
// which hosted facilitators (Coinbase CDP) use, and passes anything else
|
||||
// through unchanged. Empty defaults to Base mainnet.
|
||||
func NormalizeNetwork(n string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(n)) {
|
||||
case "", "base", "eip155:8453":
|
||||
return "eip155:8453"
|
||||
case "base-sepolia", "eip155:84532":
|
||||
return "eip155:84532"
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
func defaultAsset(network string) (assetInfo, bool) {
|
||||
a, ok := knownAssets[network]
|
||||
return a, ok
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// 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 ""
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package x402
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVerifyAndSettle checks that Require verifies then settles against an
|
||||
// HTTP facilitator and surfaces the settlement in both response headers.
|
||||
func TestVerifyAndSettle(t *testing.T) {
|
||||
var verifyHit, settleHit bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/verify":
|
||||
verifyHit = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "payer": "0xabc"})
|
||||
case "/settle":
|
||||
settleHit = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xdeadbeef"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := Config{PayTo: "0xpay", Network: "eip155:8453", FacilitatorURL: srv.URL}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453"}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if !cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatalf("Require returned false; body=%s", rec.Body.String())
|
||||
}
|
||||
if !verifyHit || !settleHit {
|
||||
t.Fatalf("expected both verify and settle to be hit: verify=%v settle=%v", verifyHit, settleHit)
|
||||
}
|
||||
if got := rec.Header().Get(PaymentResponseHeader); got != "0xdeadbeef" {
|
||||
t.Errorf("X-PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
|
||||
}
|
||||
if got := rec.Header().Get(PaymentResponseHeaderV2); got != "0xdeadbeef" {
|
||||
t.Errorf("PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettlementFailureChallenges checks that a failed settlement blocks the
|
||||
// request with a fresh 402 rather than letting it through.
|
||||
func TestSettlementFailureChallenges(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/verify":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
|
||||
case "/settle":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "errorReason": "insufficient_funds"})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatal("Require should return false when settlement fails")
|
||||
}
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Errorf("status = %d, want 402", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentSignatureHeaderAccepted checks the v2 request header is honored.
|
||||
func TestPaymentSignatureHeaderAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "success": true, "transaction": "0x1"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeaderV2, "eyJ4IjoxfQ==") // PAYMENT-SIGNATURE only
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if !cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatalf("Require should honor PAYMENT-SIGNATURE; body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequirementsExtraForKnownNetwork checks the EIP-712 domain is filled in.
|
||||
func TestRequirementsExtraForKnownNetwork(t *testing.T) {
|
||||
for _, net := range []string{"base", "eip155:8453"} {
|
||||
req := Config{PayTo: "0xpay", Network: net}.requirements("10000", "chat")
|
||||
if req.Extra["name"] == "" || req.Extra["version"] == "" {
|
||||
t.Errorf("network %q: extra not filled: %v", net, req.Extra)
|
||||
}
|
||||
if req.Asset == "" {
|
||||
t.Errorf("network %q: asset not defaulted", net)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePayment checks the base64 header is decoded to an object for the
|
||||
// facilitator (which expects the payload object, not the raw string).
|
||||
func TestDecodePayment(t *testing.T) {
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453","payload":{"x":1}}`))
|
||||
obj := decodePayment(enc)
|
||||
m, ok := obj.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("decodePayment did not return an object: %T", obj)
|
||||
}
|
||||
if m["network"] != "eip155:8453" {
|
||||
t.Errorf("decoded network = %v", m["network"])
|
||||
}
|
||||
// Non-base64 passes through unchanged.
|
||||
if got := decodePayment("not-base64!"); got != "not-base64!" {
|
||||
t.Errorf("passthrough failed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPBearer certifies the CDP JWT is well-formed and its signature verifies.
|
||||
func TestCDPBearer(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
secret := base64.StdEncoding.EncodeToString(priv)
|
||||
|
||||
tok, err := cdpBearer("key-id", secret, "POST", "api.cdp.coinbase.com", "/platform/v2/x402/verify")
|
||||
if err != nil {
|
||||
t.Fatalf("cdpBearer: %v", err)
|
||||
}
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 JWT segments, got %d", len(parts))
|
||||
}
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
|
||||
t.Fatal("JWT signature does not verify")
|
||||
}
|
||||
var claims map[string]any
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
_ = json.Unmarshal(cb, &claims)
|
||||
if claims["iss"] != "cdp" || claims["uri"] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" {
|
||||
t.Errorf("bad claims: %v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPAuthorizeAttachesBearer checks CDP() signs facilitator requests.
|
||||
func TestCDPAuthorizeAttachesBearer(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(nil)
|
||||
fac := CDP("key-id", base64.StdEncoding.EncodeToString(priv))
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://api.cdp.coinbase.com/platform/v2/x402/verify", nil)
|
||||
if err := fac.Authorize(req); err != nil {
|
||||
t.Fatalf("authorize: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ey") {
|
||||
t.Errorf("missing Bearer JWT: %q", req.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireSettlementFailsClosed checks that a paid config with
|
||||
// RequireSettlement refuses to serve when the facilitator only verifies (does
|
||||
// not settle) — otherwise the resource is released while no funds move.
|
||||
func TestRequireSettlementFailsClosed(t *testing.T) {
|
||||
// mockFacilitator implements Verify but not Settler.
|
||||
cfg := Config{PayTo: "0xpay", Facilitator: mockFacilitator{valid: true}, RequireSettlement: true}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatal("Require should fail closed when settlement is required but unavailable")
|
||||
}
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Errorf("status = %d, want 402", rec.Code)
|
||||
}
|
||||
|
||||
// Without RequireSettlement the verify-only facilitator still serves.
|
||||
cfg.RequireSettlement = false
|
||||
rec = httptest.NewRecorder()
|
||||
r = httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
if !cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatalf("verify-only should serve when settlement is not required; body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireSettlementServesWithSettler checks that a paid config with
|
||||
// RequireSettlement serves when the facilitator can settle.
|
||||
func TestRequireSettlementServesWithSettler(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/verify":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
|
||||
case "/settle":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xabc"})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// HTTPFacilitator implements Settler.
|
||||
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL, RequireSettlement: true}
|
||||
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
||||
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if !cfg.Require(rec, r, "10000", "chat") {
|
||||
t.Fatalf("Require should serve with a settling facilitator; body=%s", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get(PaymentResponseHeader); got != "0xabc" {
|
||||
t.Errorf("settlement header = %q, want 0xabc", got)
|
||||
}
|
||||
}
|
||||
|
||||
var _ Settler = (*HTTPFacilitator)(nil)
|
||||
@@ -0,0 +1,142 @@
|
||||
package x402
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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", Amount: "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", Amount: "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.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", Amount: "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)
|
||||
}
|
||||
}
|
||||
|
||||
// A free amount ("" or "0") requires no payment.
|
||||
func TestRequireFreeAmount(t *testing.T) {
|
||||
cfg := Config{PayTo: "0xabc", Facilitator: mockFacilitator{valid: false}}
|
||||
rec := httptest.NewRecorder()
|
||||
if !cfg.Require(rec, httptest.NewRequest(http.MethodGet, "/free", nil), "0", "free.tool") {
|
||||
t.Error("a zero amount should be free and proceed")
|
||||
}
|
||||
// Require doesn't write on success; recorder defaults to 200.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-tool amounts override the default.
|
||||
func TestAmountFor(t *testing.T) {
|
||||
cfg := Config{Amount: "100", Amounts: map[string]string{"paid.Tool.Do": "5000"}}
|
||||
if got := cfg.AmountFor("paid.Tool.Do"); got != "5000" {
|
||||
t.Errorf("per-tool amount = %q, want 5000", got)
|
||||
}
|
||||
if got := cfg.AmountFor("other.Tool.Do"); got != "100" {
|
||||
t.Errorf("default amount = %q, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig reads an operator x402 config file.
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "x402.json")
|
||||
os.WriteFile(path, []byte(`{
|
||||
"payTo": "0xabc", "network": "solana", "asset": "USDC",
|
||||
"amount": "0", "amounts": {"weather.Weather.Forecast": "10000"}
|
||||
}`), 0o644)
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.PayTo != "0xabc" || cfg.Network != "solana" {
|
||||
t.Errorf("config not parsed: %+v", cfg)
|
||||
}
|
||||
if cfg.AmountFor("weather.Weather.Forecast") != "10000" {
|
||||
t.Errorf("per-tool amount not loaded: %+v", cfg.Amounts)
|
||||
}
|
||||
if cfg.AmountFor("anything.else") != "0" {
|
||||
t.Errorf("default amount = %q, want 0", cfg.AmountFor("anything.else"))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user