Compare commits

..

8 Commits

Author SHA1 Message Date
Codex 327347d0bc docs(priorities): refresh planner queue
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-07-02 15:00:04 +00:00
Asim Aslam 274c3b2646 Fail checkpointed runs with unfinished plans (#3684)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 15:27:52 +01:00
Asim Aslam 6b6100340b docs(priorities): refresh planner queue (#3680)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 14:40:53 +01:00
Asim Aslam 35b68b11f9 wrapper/x402: fix misspell lint failure (honour -> honor) (#3678)
golangci-lint's misspell linter fails on master: x402_settle_test.go used
British spellings ("honoured"/"honour"). Switch to US spelling to match the
linter's en_US locale. Introduced by #3676.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 14:39:18 +01:00
Asim Aslam 13d618ba66 test: make universe A2A reachability deterministic (#3677)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 14:20:21 +01:00
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
Asim Aslam c13ff9fc98 docs(priorities): refresh planner queue (#3672)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 13:52:31 +01:00
Asim Aslam 6e010706ec harness: suppress duplicate universe notifications (#3669)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 13:26:43 +01:00
9 changed files with 616 additions and 55 deletions
+3 -3
View File
@@ -21,9 +21,9 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Make universe checkout conformance send exactly one concierge notification** ([#3633](https://github.com/micro/go-micro/issues/3633)) — durable checkout/universe is the strongest 0→hero proof for services → agents → workflows, and resume idempotency must be boring. Keep this first because duplicate buyer notifications are user-visible side effects in the adoption story and the plan/delegate duplicate-side-effect issue is now closed by PR #3664.
2. **Make the universe A2A reachability check deterministic** ([#3653](https://github.com/micro/go-micro/issues/3653)) — the latest live-provider scan shows the final universe A2A smoke can trigger extra concierge notifications and time out even after checkout durability assertions pass. Rank it with the universe side-effect work because gateway reachability should prove interop without adding more side effects or making CI flaky.
3. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — this remains the next scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site by ensuring the non-native streaming fallback path receives the tool surface, without letting protocol depth outrank the on-ramp or side-effect safety.
1. **Constrain universe concierge notifications to one buyer side effect** ([#3682](https://github.com/micro/go-micro/issues/3682)) — PR #3684 closed the unfinished plan/delegate run gap, and the latest live provider-conformance failure has shifted to the universe harness: the checkout resume path is durable and reachable, but the concierge can notify both `order-1` and `buyer`. This is the highest-value Now-phase correctness item because the services → agents → workflows story must produce bounded, idempotent side effects under retry/resume before developers can trust the harness.
2. **Make the examples index a walkable first-agent map** ([#3671](https://github.com/micro/go-micro/issues/3671)) — recent README/docs work surfaced the first-agent on-ramp, and blog #33 sharpened the dogfooded loop story; the next adoption gap is example wayfinding. A newcomer should be able to move from first service to first agent to first workflow from the examples surface without stitching together README, website docs, and directories by hand.
3. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — this remains the next scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site by ensuring the non-native streaming fallback path receives the tool surface, while staying behind the immediate multi-agent correctness and on-ramp blockers.
4. **Parse multi-event A2A SSE fallback responses in the harness** ([#3662](https://github.com/micro/go-micro/issues/3662)) — once the fallback tool path succeeds, the harness must accept legitimate multi-event `message/stream` responses instead of concatenating valid SSE events into invalid JSON. This is a small CI-verifiable harness fix that keeps cross-provider streaming conformance focused on real gateway failures rather than parser brittleness.
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining Now-phase resilience gap after the live-provider side-effect fixes is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
6. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
+18
View File
@@ -368,6 +368,24 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
RunID: a.runID,
ParentID: parentRunID,
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 {
err = fmt.Errorf("agent run %s has unfinished plan steps: %s", run.ID, strings.Join(unfinished, ", "))
run.Status = "failed"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
}
run.Status = "done"
run.State.Stage = ""
if b, marshalErr := json.Marshal(res); marshalErr == nil {
+32
View File
@@ -402,6 +402,38 @@ func (a *agentImpl) completeNextPlanStep() {
}
}
func (a *agentImpl) unfinishedPlanSteps() []string {
plan := a.loadPlan()
if plan == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return nil
}
steps, ok := data["steps"].([]any)
if !ok {
return nil
}
var unfinished []string
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status != "" && status != "pending" && status != "in_progress" {
continue
}
task, _ := step["task"].(string)
if task == "" {
task = "<unnamed>"
}
unfinished = append(unfinished, task)
}
return unfinished
}
// handleHumanInput records that the model needs operator input before it can continue.
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
prompt, _ := call.Input["prompt"].(string)
+37
View File
@@ -141,6 +141,43 @@ func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
}
}
func TestCheckpointFailsRunWithUnfinishedPlanStep(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "unfinished-plan-agent")
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create launch tasks", "status": "done"},
map[string]any{"task": "notify owner via comms", "status": "in_progress"},
},
}})
return &ai.Response{Reply: "all done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("unfinished-plan-agent"), WithCheckpoint(cp))
_, err := a.Ask(ctx, "create tasks and notify owner")
if err == nil {
t.Fatal("Ask succeeded with unfinished plan step")
}
if !strings.Contains(err.Error(), "notify owner via comms") {
t.Fatalf("Ask error = %v, want unfinished delegate step named", err)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want failed run remains actionable", len(runs))
}
if runs[0].Status != "failed" {
t.Fatalf("run status = %q, want failed", runs[0].Status)
}
}
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
+23 -5
View File
@@ -209,6 +209,10 @@ func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if strings.Contains(strings.ToLower(req.Prompt), "a2a reachability probe") {
return &ai.Response{Answer: "concierge reachable"}, nil
}
// The concierge is asked to notify the buyer. Find the notify tool and call it.
for _, t := range req.Tools {
if strings.Contains(t.Name, "Send") && m.opts.ToolHandler != nil {
@@ -240,10 +244,19 @@ func check(cond bool, format string, args ...any) {
// a2aReachable calls the named agent through the gateway using the A2A
// client — exercising both directions of the protocol — and reports
// whether the agent replied.
func a2aReachable(base, agent string) bool {
reply, err := a2a.NewClient(base+"/agents/"+agent).Send(context.Background(), "notify the buyer")
return err == nil && reply != ""
// whether the agent replied. The probe is intentionally side-effect-free:
// the checkout flow already proved notify tool execution, and reachability
// should not depend on a live model deciding to send another notification.
func a2aReachable(ctx context.Context, base, agent string) error {
probe := "A2A reachability probe only. Reply with the words concierge reachable. Do not call tools or send notifications."
reply, err := a2a.NewClient(base+"/agents/"+agent).Send(ctx, probe)
if err != nil {
return err
}
if strings.TrimSpace(reply) == "" {
return fmt.Errorf("empty A2A reply")
}
return nil
}
func providerKey(provider string) string {
@@ -419,7 +432,12 @@ func runUniverse(provider string) int {
// translated to its Agent.Chat RPC.
gw := httptest.NewServer(a2a.New(a2a.Options{Registry: reg, Client: cl, BaseURL: "http://gw"}).Handler())
defer gw.Close()
check(a2aReachable(gw.URL, "concierge"), "concierge agent reachable over the A2A gateway")
beforeA2A := atomic.LoadInt64(&ntf.sent)
reachCtx, cancelReach := context.WithTimeout(ctx, 10*time.Second)
reachErr := a2aReachable(reachCtx, gw.URL, "concierge")
cancelReach()
check(reachErr == nil, "concierge agent reachable over the A2A gateway: %v", reachErr)
check(atomic.LoadInt64(&ntf.sent) == beforeA2A, "A2A reachability probe did not send extra buyer notifications")
fmt.Println("\n\033[1m> shutting down the universe\033[0m")
// defers stop the agent and flow (deregistering them).
+109
View File
@@ -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) }
+38
View File
@@ -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
}
+194 -47
View File
@@ -15,6 +15,11 @@
// })
// 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
@@ -22,32 +27,41 @@ 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.
// 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
PaymentResponseHeader = "X-PAYMENT-RESPONSE" // response: settlement details
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", "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
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.
@@ -57,7 +71,7 @@ type challenge struct {
Error string `json:"error,omitempty"`
}
// Result is the outcome of verifying a payment.
// 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
@@ -65,20 +79,29 @@ type Result struct {
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.
// 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").
// 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"`
@@ -92,8 +115,13 @@ type Config struct {
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.
// 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).
@@ -124,35 +152,62 @@ func (c Config) facilitator() Facilitator {
}
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: c.network(),
Network: net,
MaxAmountRequired: amount,
Resource: resource,
Description: c.Description,
MimeType: "application/json",
PayTo: c.PayTo,
Asset: c.Asset,
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 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).
// 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 := r.Header.Get(PaymentHeader)
payment := Payment(r)
if payment == "" {
writeChallenge(w, req, "payment required")
return false
}
res, err := c.facilitator().Verify(r.Context(), payment, req)
fac := c.facilitator()
res, err := fac.Verify(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment verification failed: "+err.Error())
return false
@@ -165,8 +220,28 @@ func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource
writeChallenge(w, req, reason)
return false
}
// Capture the funds when the facilitator can settle. Verify alone only
// authorizes the "exact" transfer; settlement broadcasts it.
if s, ok := fac.(Settler); ok {
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 != "" {
w.Header().Set(PaymentResponseHeader, res.Settlement)
setSettlementHeaders(w, res.Settlement)
}
return true
}
@@ -184,6 +259,11 @@ func Middleware(cfg Config) func(http.Handler) http.Handler {
}
}
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
@@ -211,44 +291,111 @@ func LoadConfig(path string) (*Config, error) {
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.
// 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) {
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))
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 Result{}, err
return out, err
}
defer resp.Body.Close()
var out struct {
IsValid bool `json:"isValid"`
InvalidReason string `json:"invalidReason"`
Payer string `json:"payer"`
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 Result{}, err
return out, err
}
return Result{Valid: out.IsValid, Reason: out.InvalidReason, Payer: out.Payer}, nil
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 ""
}
+162
View File
@@ -0,0 +1,162 @@
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"))
}
}
var _ Settler = (*HTTPFacilitator)(nil)