项目文件夹

文件
Asim Aslam b60952cbfe wrapper/x402: settle payments, CDP auth, and conformance fixes (#3676)
The x402 wrapper could advertise a 402 and verify a payment, but never
settled it (the "exact" scheme needs verify + settle to actually move
funds), and its HTTPFacilitator sent no auth, so it could not use the
Coinbase CDP facilitator — the only one that settles Base mainnet. It
also passed the raw base64 X-PAYMENT string where facilitators expect the
decoded payload object.

- Add an optional Settler interface; HTTPFacilitator now implements
  Verify and Settle (POST /verify then /settle), and Require settles a
  verified payment and emits the settlement reference.
- HTTPFacilitator.Authorize hook + x402.CDP(keyID, secret) constructor:
  mint a short-lived Ed25519 Bearer JWT (stdlib crypto only, no chain
  code, no new dependency) so verify/settle authenticate to CDP.
- Decode the X-PAYMENT payload to the object facilitators expect, with
  passthrough for non-JSON payloads.
- Requirements gains extra (EIP-712 domain) and mimeType; the asset and
  its {name,version} are auto-filled for known networks so clients can
  sign. NormalizeNetwork maps base/base-sepolia to CAIP-2 ids.
- Accept the v2 PAYMENT-SIGNATURE request header and emit both
  X-PAYMENT-RESPONSE and PAYMENT-RESPONSE.

Backward compatible: the default network stays "base", the Facilitator
interface is unchanged (Settler is additive), and gateway/mcp builds and
tests unchanged. Adds tests for settle, CDP JWT, header aliases, extra,
and payload decoding.
2026-07-02 14:18:53 +01:00

163 行
5.7 KiB
Go

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 honoured.
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 honour 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"))
}
}
var _ Settler = (*HTTPFacilitator)(nil)