diff --git a/internal/x402/payer_context_test.go b/internal/x402/payer_context_test.go new file mode 100644 index 00000000..81b13ccc --- /dev/null +++ b/internal/x402/payer_context_test.go @@ -0,0 +1,160 @@ +package x402 + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func TestSplicePayerSystemMessage(t *testing.T) { + payer := "0x2447b86f22245fa1271978bF37907D07EDE06261" + + t.Run("prepends system message and preserves the rest", func(t *testing.T) { + body := []byte(`{"model":"openrouter/auto","stream":true,"messages":[{"role":"user","content":"claim my airdrop"}]}`) + out, ok := splicePayerSystemMessage(body, payer) + if !ok { + t.Fatalf("expected ok") + } + var doc struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.Model != "openrouter/auto" || !doc.Stream { + t.Fatalf("sibling fields not preserved: %+v", doc) + } + if len(doc.Messages) != 2 { + t.Fatalf("want 2 messages, got %d", len(doc.Messages)) + } + if doc.Messages[0].Role != "system" || !strings.Contains(doc.Messages[0].Content, payer) { + t.Fatalf("system payer message not first: %+v", doc.Messages[0]) + } + if doc.Messages[1].Content != "claim my airdrop" { + t.Fatalf("user message mangled: %+v", doc.Messages[1]) + } + }) + + t.Run("non-JSON body passes through", func(t *testing.T) { + body := []byte("not json") + out, ok := splicePayerSystemMessage(body, payer) + if ok || string(out) != "not json" { + t.Fatalf("expected byte-identical passthrough, got ok=%v out=%q", ok, out) + } + }) + + t.Run("JSON without messages passes through", func(t *testing.T) { + body := []byte(`{"model":"x"}`) + out, ok := splicePayerSystemMessage(body, payer) + if ok || string(out) != `{"model":"x"}` { + t.Fatalf("expected passthrough, got ok=%v out=%q", ok, out) + } + }) +} + +// proxyBodySeen runs a request through buildUpstreamProxy for the given rule +// and returns the body + headers the upstream received. +func proxyBodySeen(t *testing.T, rule *RouteRule, reqPath, body string, hdr map[string]string) (string, http.Header) { + t.Helper() + var gotBody string + var gotHeader http.Header + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + gotHeader = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + rule.UpstreamURL = upstream.URL + + proxy, err := buildUpstreamProxy(rule) + if err != nil { + t.Fatalf("buildUpstreamProxy: %v", err) + } + req := httptest.NewRequest(http.MethodPost, reqPath, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + for k, v := range hdr { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("proxy status = %d", rec.Code) + } + return gotBody, gotHeader +} + +func TestBuildUpstreamProxy_InjectsPayerContextForAgentOffers(t *testing.T) { + payer := "0xD0391EeDc3268F3deeF1F05fff5D7aEf82F64cCF" + chatBody := `{"model":"openrouter/auto","messages":[{"role":"user","content":"claim mine"}]}` + + t.Run("agent offer with verified payer gets the system message", func(t *testing.T) { + rule := &RouteRule{OfferType: "agent", StripPrefix: "/services/claim-bot"} + body, _ := proxyBodySeen(t, rule, "/services/claim-bot/v1/chat/completions", chatBody, + map[string]string{HeaderPaymentPayer: payer}) + if !strings.Contains(body, payer) || !strings.Contains(body, "x402 payment context") { + t.Fatalf("payer context not injected; upstream saw: %s", body) + } + if !strings.Contains(body, "claim mine") { + t.Fatalf("original user message lost: %s", body) + } + }) + + t.Run("falls back to SIWX verified wallet", func(t *testing.T) { + rule := &RouteRule{OfferType: "agent", StripPrefix: "/services/claim-bot"} + body, _ := proxyBodySeen(t, rule, "/services/claim-bot/v1/chat/completions", chatBody, + map[string]string{HeaderVerifiedWallet: payer}) + if !strings.Contains(body, payer) { + t.Fatalf("verified-wallet fallback not injected; upstream saw: %s", body) + } + }) + + t.Run("no identity header means untouched body", func(t *testing.T) { + rule := &RouteRule{OfferType: "agent", StripPrefix: "/services/claim-bot"} + body, _ := proxyBodySeen(t, rule, "/services/claim-bot/v1/chat/completions", chatBody, nil) + if body != chatBody { + t.Fatalf("body modified without identity header: %s", body) + } + }) + + t.Run("non-agent offers are untouched", func(t *testing.T) { + rule := &RouteRule{OfferType: "http", StripPrefix: "/services/api"} + body, _ := proxyBodySeen(t, rule, "/services/api/v1/chat/completions", chatBody, + map[string]string{HeaderPaymentPayer: payer}) + if body != chatBody { + t.Fatalf("http offer body modified: %s", body) + } + }) + + t.Run("normalized bare path also gets injection", func(t *testing.T) { + // Buyers frequently POST to the service base; normalizeChatCompletionsPath + // rewrites it to /v1/chat/completions, and injection must follow. + rule := &RouteRule{OfferType: "agent", StripPrefix: "/services/claim-bot"} + body, _ := proxyBodySeen(t, rule, "/services/claim-bot", chatBody, + map[string]string{HeaderPaymentPayer: payer}) + if !strings.Contains(body, payer) { + t.Fatalf("payer context not injected on normalized path; upstream saw: %s", body) + } + }) + + t.Run("content-length is recomputed", func(t *testing.T) { + rule := &RouteRule{OfferType: "agent", StripPrefix: "/services/claim-bot"} + body, hdr := proxyBodySeen(t, rule, "/services/claim-bot/v1/chat/completions", chatBody, + map[string]string{HeaderPaymentPayer: payer}) + if cl := hdr.Get("Content-Length"); cl != "" { + n, err := strconv.Atoi(cl) + if err != nil || n != len(body) { + t.Fatalf("content-length %q != body length %d", cl, len(body)) + } + } + }) +} diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 02fa2c37..8bda591c 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -2,7 +2,10 @@ package x402 import ( "bufio" + "bytes" + "encoding/json" "fmt" + "io" "log" "net" "net/http" @@ -10,6 +13,7 @@ import ( "net/url" "os" "sort" + "strconv" "strings" "sync/atomic" "time" @@ -824,6 +828,7 @@ func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { } else if rule.UpstreamAuth != "" { pr.Out.Header.Set("Authorization", rule.UpstreamAuth) } + injectAgentPayerContext(pr, rule) }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { log.Printf("x402-verifier: upstream proxy error for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) @@ -837,6 +842,101 @@ func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { // agent upstreams (LiteLLM, Hermes). const chatCompletionsPath = "/v1/chat/completions" +// payerContextBodyLimit caps how much of a chat-completions request body the +// verifier buffers to splice in the payer-context system message. Larger +// bodies pass through byte-identical. +const payerContextBodyLimit = 4 << 20 + +// injectAgentPayerContext rewrites a paid agent-offer chat request so the +// verified payer wallet rides IN-BAND as a leading system message. The +// X-Payment-Payer header alone doesn't reach the model — agent runtimes drop +// HTTP headers before the LLM sees the conversation — so without this a buyer +// must repeat their own address in the prompt. Identity headers are +// verifier-set only (client copies are stripped at HandleProxy entry), so the +// injected value is an authenticated fact. +// +// Injection is strictly best-effort: any shape we don't fully understand +// (non-JSON, no messages array, oversized or unreadable body, compressed +// content) leaves the request byte-identical rather than risking a paid +// request. +func injectAgentPayerContext(pr *httputil.ProxyRequest, rule *RouteRule) { + if rule.OfferType != "agent" || pr.Out.Method != http.MethodPost || + pr.Out.URL.Path != chatCompletionsPath || pr.Out.Body == nil { + return + } + if ce := pr.In.Header.Get("Content-Encoding"); ce != "" && !strings.EqualFold(ce, "identity") { + return + } + if ct := pr.In.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "json") { + return + } + payer := pr.In.Header.Get(HeaderPaymentPayer) + if payer == "" { + payer = pr.In.Header.Get(HeaderVerifiedWallet) + } + if payer == "" { + return + } + + orig := pr.Out.Body + body, err := io.ReadAll(io.LimitReader(orig, payerContextBodyLimit+1)) + if err != nil || len(body) > payerContextBodyLimit { + // Splice the consumed bytes back in front of the unread remainder so + // the upstream still receives the full original body. + pr.Out.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(body), orig), orig} + return + } + orig.Close() + + newBody, ok := splicePayerSystemMessage(body, payer) + if !ok { + newBody = body + } + pr.Out.Body = io.NopCloser(bytes.NewReader(newBody)) + pr.Out.ContentLength = int64(len(newBody)) + pr.Out.Header.Set("Content-Length", strconv.Itoa(len(newBody))) +} + +// splicePayerSystemMessage prepends a system message carrying the verified +// payer wallet to an OpenAI chat-completions JSON body. Returns the original +// bytes and false when the body isn't the expected shape. +func splicePayerSystemMessage(body []byte, payer string) ([]byte, bool) { + var doc map[string]json.RawMessage + if err := json.Unmarshal(body, &doc); err != nil { + return body, false + } + rawMsgs, ok := doc["messages"] + if !ok { + return body, false + } + var messages []json.RawMessage + if err := json.Unmarshal(rawMsgs, &messages); err != nil { + return body, false + } + note, err := json.Marshal(map[string]string{ + "role": "system", + "content": "x402 payment context: this request was paid by wallet " + payer + + " (payer identity verified on-chain by the payment gateway; buyers cannot spoof it). " + + "When the buyer refers to their own wallet or address without spelling it out, use this address.", + }) + if err != nil { + return body, false + } + merged, err := json.Marshal(append([]json.RawMessage{note}, messages...)) + if err != nil { + return body, false + } + doc["messages"] = merged + newBody, err := json.Marshal(doc) + if err != nil { + return body, false + } + return newBody, true +} + // normalizeChatCompletionsPath forgives the common wrong-path shapes buyers // send to chat-completions offers. External x402 clients (and the prompts on // older 402 pages) frequently POST to the bare service base or to