From 4774348ed66eeecd5ce8f34e76debce6a766b325 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Wed, 11 Mar 2026 15:42:19 +0800 Subject: [PATCH] refactor: centralize x402 payment flow into shared mcp/x402.go Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/ setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402 body before retry, log Payment-Response tx hash, check Payment-Required before X-Payment-Required. --- mcp/blockrun_base.go | 139 +++------------------------ mcp/blockrun_sol.go | 114 ++-------------------- mcp/claw402.go | 156 +++++++++--------------------- mcp/x402.go | 219 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 338 deletions(-) create mode 100644 mcp/x402.go diff --git a/mcp/blockrun_base.go b/mcp/blockrun_base.go index 97b9d982..c97efa4a 100644 --- a/mcp/blockrun_base.go +++ b/mcp/blockrun_base.go @@ -1,14 +1,12 @@ package mcp import ( - "bytes" "crypto/ecdsa" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" - "io" "math/big" "net/http" "strings" @@ -97,124 +95,19 @@ func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customMo } } -func (c *BlockRunBaseClient) setAuthHeader(reqHeaders http.Header) { - // No Bearer token — payment is via x402 signing -} +func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) } -// call overrides the base call to handle HTTP 402 x402 v2 payment flow. func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) { - c.logger.Infof("📡 [BlockRun Base] Request AI Server: %s", c.BaseURL) - - requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) - jsonData, err := c.hooks.marshalRequestBody(requestBody) - if err != nil { - return "", err - } - - url := c.hooks.buildUrl() - req, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Handle x402 v2 Payment Required - if resp.StatusCode == http.StatusPaymentRequired { - paymentHeader := resp.Header.Get("X-Payment-Required") - if paymentHeader == "" { - return "", fmt.Errorf("received 402 but no X-Payment-Required header") - } - - paymentSig, err := c.signPayment(paymentHeader) - if err != nil { - return "", fmt.Errorf("failed to sign x402 payment: %w", err) - } - - req2, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to build retry request: %w", err) - } - req2.Header.Set("X-Payment", paymentSig) - - resp2, err := c.httpClient.Do(req2) - if err != nil { - return "", fmt.Errorf("failed to send payment retry: %w", err) - } - defer resp2.Body.Close() - - body2, err := io.ReadAll(resp2.Body) - if err != nil { - return "", fmt.Errorf("failed to read payment retry response: %w", err) - } - if resp2.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun payment retry failed (status %d): %s", resp2.StatusCode, string(body2)) - } - return c.hooks.parseMCPResponse(body2) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun API error (status %d): %s", resp.StatusCode, string(body)) - } - return c.hooks.parseMCPResponse(body) + return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt) } -// x402v2PaymentRequired is the structure of the X-Payment-Required header (x402 v2). -type x402v2PaymentRequired struct { - X402Version int `json:"x402Version"` - Accepts []x402AcceptOption `json:"accepts"` - Resource *x402Resource `json:"resource"` +func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) { + return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req) } -// signPayment parses the X-Payment-Required header (x402 v2) and returns a signed X-Payment value. +// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value. func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) { - if c.privateKey == nil { - return "", fmt.Errorf("no private key set for BlockRun Base wallet") - } - - decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - return "", fmt.Errorf("failed to base64-decode payment header: %w", err) - } - } - - var req x402v2PaymentRequired - if err := json.Unmarshal(decoded, &req); err != nil { - return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err) - } - if len(req.Accepts) == 0 { - return "", fmt.Errorf("no payment options in x402 response") - } - - senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex() - return signX402Payment(c.privateKey, senderAddr, req.Accepts[0], req.Resource) -} - -// x402AcceptOption is the payment option from x402 v2 header (extracted for shared signing). -type x402AcceptOption struct { - Scheme string `json:"scheme"` - Network string `json:"network"` - Amount string `json:"amount"` - Asset string `json:"asset"` - PayTo string `json:"payTo"` - MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` - Extra map[string]string `json:"extra"` -} - -type x402Resource struct { - URL string `json:"url"` - Description string `json:"description"` - MimeType string `json:"mimeType"` + return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base") } // signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC. @@ -240,7 +133,7 @@ func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402Ac } now := time.Now().Unix() - validAfter := now - 600 + validAfter := int64(0) validBefore := now + int64(maxTimeout) nonceBytes := make([]byte, 32) @@ -417,10 +310,14 @@ func hexToBytes32(s string) ([]byte, error) { } func parseBigInt(s string) (*big.Int, error) { - s = strings.TrimPrefix(s, "0x") n := new(big.Int) - if _, ok := n.SetString(s, 16); ok { - return n, nil + // Only treat as hex when explicitly prefixed with 0x/0X. + // x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC). + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + if _, ok := n.SetString(s[2:], 16); ok { + return n, nil + } + return nil, fmt.Errorf("cannot parse hex big.Int from %q", s) } if _, ok := n.SetString(s, 10); ok { return n, nil @@ -443,12 +340,6 @@ func (c *BlockRunBaseClient) buildUrl() string { return DefaultBlockRunBaseURL + BlockRunChatEndpoint } -// buildRequest creates the HTTP request without an Authorization header. func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) { - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("fail to build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - return req, nil + return x402BuildRequest(url, jsonData) } diff --git a/mcp/blockrun_sol.go b/mcp/blockrun_sol.go index 03dab8ec..cb88636e 100644 --- a/mcp/blockrun_sol.go +++ b/mcp/blockrun_sol.go @@ -1,12 +1,10 @@ package mcp import ( - "bytes" "context" "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "strings" @@ -76,120 +74,34 @@ func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customMod } } -func (c *BlockRunSolClient) setAuthHeader(reqHeaders http.Header) { - // No Bearer token — payment is via x402 signing -} +func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) } -// call overrides the base call to handle HTTP 402 x402 v2 Solana payment flow. func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) { - c.logger.Infof("📡 [BlockRun Sol] Request AI Server: %s", c.BaseURL) - - requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) - jsonData, err := c.hooks.marshalRequestBody(requestBody) - if err != nil { - return "", err - } - - url := c.hooks.buildUrl() - req, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Handle x402 v2 Payment Required - if resp.StatusCode == http.StatusPaymentRequired { - paymentHeader := resp.Header.Get("X-Payment-Required") - if paymentHeader == "" { - return "", fmt.Errorf("received 402 but no X-Payment-Required header") - } - - paymentSig, err := c.signSolanaPayment(paymentHeader) - if err != nil { - return "", fmt.Errorf("failed to sign Solana x402 payment: %w", err) - } - - req2, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to build retry request: %w", err) - } - req2.Header.Set("X-Payment", paymentSig) - - resp2, err := c.httpClient.Do(req2) - if err != nil { - return "", fmt.Errorf("failed to send payment retry: %w", err) - } - defer resp2.Body.Close() - - body2, err := io.ReadAll(resp2.Body) - if err != nil { - return "", fmt.Errorf("failed to read payment retry response: %w", err) - } - if resp2.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun Sol payment retry failed (status %d): %s", resp2.StatusCode, string(body2)) - } - return c.hooks.parseMCPResponse(body2) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun Sol API error (status %d): %s", resp.StatusCode, string(body)) - } - return c.hooks.parseMCPResponse(body) + return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt) } -// solanaPaymentOption is an entry in the accepts[] array of the x402 v2 response. -type solanaPaymentOption struct { - Scheme string `json:"scheme"` - Network string `json:"network"` - Amount string `json:"amount"` - Asset string `json:"asset"` - PayTo string `json:"payTo"` - MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` - Extra map[string]string `json:"extra"` +func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) { + return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req) } -// x402v2SolanaRequired is the parsed X-Payment-Required header for Solana. -type x402v2SolanaRequired struct { - X402Version int `json:"x402Version"` - Accepts []solanaPaymentOption `json:"accepts"` - Resource *struct { - URL string `json:"url"` - Description string `json:"description"` - MimeType string `json:"mimeType"` - } `json:"resource"` -} - -// signSolanaPayment parses the X-Payment-Required header and builds a signed x402 v2 Solana payload. +// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload. func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) { if c.keypair == nil { return "", fmt.Errorf("no private key set for BlockRun Sol wallet") } - // Decode base64 → JSON - decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64) + decoded, err := x402DecodeHeader(paymentHeaderB64) if err != nil { - decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - return "", fmt.Errorf("failed to base64-decode payment header: %w", err) - } + return "", err } - var req x402v2SolanaRequired + var req x402v2PaymentRequired if err := json.Unmarshal(decoded, &req); err != nil { return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err) } // Find the Solana option - var opt *solanaPaymentOption + var opt *x402AcceptOption for i := range req.Accepts { if strings.HasPrefix(req.Accepts[i].Network, "solana:") { opt = &req.Accepts[i] @@ -360,12 +272,6 @@ func (c *BlockRunSolClient) buildUrl() string { return DefaultBlockRunSolURL + BlockRunChatEndpoint } -// buildRequest creates the HTTP request without an Authorization header. func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) { - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("fail to build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - return req, nil + return x402BuildRequest(url, jsonData) } diff --git a/mcp/claw402.go b/mcp/claw402.go index 70a6aeb2..03bb9ca6 100644 --- a/mcp/claw402.go +++ b/mcp/claw402.go @@ -1,12 +1,7 @@ package mcp import ( - "bytes" "crypto/ecdsa" - "encoding/base64" - "encoding/json" - "fmt" - "io" "net/http" "strings" @@ -46,9 +41,12 @@ var claw402ModelEndpoints = map[string]string{ // Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway. // Reuses the same EIP-712 signing as BlockRunBaseClient (same Base chain + USDC contract). +// When the selected model routes to an Anthropic endpoint, it automatically uses +// the Anthropic wire format for requests and responses (via an internal ClaudeClient). type Claw402Client struct { *Client - privateKey *ecdsa.PrivateKey + privateKey *ecdsa.PrivateKey + claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/ } // NewClaw402Client creates a claw402 client (backward compatible). @@ -90,7 +88,15 @@ func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) { } endpoint := c.resolveEndpoint() c.BaseURL = DefaultClaw402URL + endpoint - c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint) + + // Anthropic endpoints need different wire format (Messages API) + if strings.Contains(endpoint, "/anthropic/") { + c.claudeProxy = &ClaudeClient{Client: c.Client} + c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint) + } else { + c.claudeProxy = nil + c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint) + } } // resolveEndpoint returns the API path for the configured model. @@ -105,113 +111,49 @@ func (c *Claw402Client) resolveEndpoint() string { return claw402ModelEndpoints[DefaultClaw402Model] } -func (c *Claw402Client) setAuthHeader(_ http.Header) { - // No Bearer token — payment is via x402 signing -} +func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) } -// call handles the x402 v2 payment flow for claw402.ai. func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) { - c.logger.Infof("📡 [Claw402] Request AI: %s", c.BaseURL) - - requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) - jsonData, err := c.hooks.marshalRequestBody(requestBody) - if err != nil { - return "", err - } - - url := c.hooks.buildUrl() - req, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Handle x402 Payment Required - if resp.StatusCode == http.StatusPaymentRequired { - // Check both header variants - paymentHeader := resp.Header.Get("X-Payment-Required") - if paymentHeader == "" { - paymentHeader = resp.Header.Get("Payment-Required") - } - if paymentHeader == "" { - // Try reading payment details from response body - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("received 402 but no payment header found. Body: %s", string(body)) - } - - paymentSig, err := c.signPayment(paymentHeader) - if err != nil { - return "", fmt.Errorf("failed to sign x402 payment: %w", err) - } - - req2, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to build retry request: %w", err) - } - // Send payment in both header variants for compatibility - req2.Header.Set("X-Payment", paymentSig) - req2.Header.Set("Payment-Signature", paymentSig) - - resp2, err := c.httpClient.Do(req2) - if err != nil { - return "", fmt.Errorf("failed to send payment retry: %w", err) - } - defer resp2.Body.Close() - - body2, err := io.ReadAll(resp2.Body) - if err != nil { - return "", fmt.Errorf("failed to read payment response: %w", err) - } - if resp2.StatusCode != http.StatusOK { - return "", fmt.Errorf("Claw402 payment retry failed (status %d): %s", resp2.StatusCode, string(body2)) - } - return c.hooks.parseMCPResponse(body2) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Claw402 API error (status %d): %s", resp.StatusCode, string(body)) - } - return c.hooks.parseMCPResponse(body) + return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt) } -// signPayment reuses the same EIP-712 signing logic as BlockRunBaseClient -// (same Base chain, same USDC contract, same TransferWithAuthorization). +func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) { + return x402CallFull(c.Client, c.signPayment, "Claw402", req) +} + +// signPayment signs x402 v2 EIP-712 payment (same Base chain + USDC as BlockRunBase). func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) { - if c.privateKey == nil { - return "", fmt.Errorf("no private key set for Claw402 wallet") - } + return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402") +} - // Decode base64 → JSON - decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - return "", fmt.Errorf("failed to decode payment header: %w", err) - } - } +// ── Format overrides for Anthropic endpoints ───────────────────────────────── - var req x402v2PaymentRequired - if err := json.Unmarshal(decoded, &req); err != nil { - return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err) +func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { + if c.claudeProxy != nil { + return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt) } - if len(req.Accepts) == 0 { - return "", fmt.Errorf("no payment options in x402 response") + return c.Client.buildMCPRequestBody(systemPrompt, userPrompt) +} + +func (c *Claw402Client) buildRequestBodyFromRequest(req *Request) map[string]any { + if c.claudeProxy != nil { + return c.claudeProxy.buildRequestBodyFromRequest(req) } + return c.Client.buildRequestBodyFromRequest(req) +} - // Reuse the same signing logic as BlockRunBaseClient — identical chain + USDC contract - opt := req.Accepts[0] - senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex() +func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) { + if c.claudeProxy != nil { + return c.claudeProxy.parseMCPResponse(body) + } + return c.Client.parseMCPResponse(body) +} - return signX402Payment(c.privateKey, senderAddr, opt, req.Resource) +func (c *Claw402Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) { + if c.claudeProxy != nil { + return c.claudeProxy.parseMCPResponseFull(body) + } + return c.Client.parseMCPResponseFull(body) } // buildUrl returns the full claw402 endpoint URL. @@ -219,12 +161,6 @@ func (c *Claw402Client) buildUrl() string { return c.BaseURL } -// buildRequest creates the HTTP request without Authorization header. func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) { - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("fail to build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - return req, nil + return x402BuildRequest(url, jsonData) } diff --git a/mcp/x402.go b/mcp/x402.go new file mode 100644 index 00000000..5debb8d1 --- /dev/null +++ b/mcp/x402.go @@ -0,0 +1,219 @@ +package mcp + +import ( + "bytes" + "crypto/ecdsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/ethereum/go-ethereum/crypto" +) + +// ── Shared x402 types ──────────────────────────────────────────────────────── + +// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2). +type x402v2PaymentRequired struct { + X402Version int `json:"x402Version"` + Accepts []x402AcceptOption `json:"accepts"` + Resource *x402Resource `json:"resource"` +} + +// x402AcceptOption is a payment option from the x402 v2 header. +type x402AcceptOption struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Amount string `json:"amount"` + Asset string `json:"asset"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` + Extra map[string]string `json:"extra"` +} + +// x402Resource describes the resource being paid for. +type x402Resource struct { + URL string `json:"url"` + Description string `json:"description"` + MimeType string `json:"mimeType"` +} + +// x402SignFunc is a callback that signs an x402 payment header and returns the +// base64-encoded payment signature. +type x402SignFunc func(paymentHeaderB64 string) (string, error) + +// ── Shared x402 helpers ────────────────────────────────────────────────────── + +// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header, +// trying RawStdEncoding first then StdEncoding as fallback. +func x402DecodeHeader(b64 string) ([]byte, error) { + decoded, err := base64.RawStdEncoding.DecodeString(b64) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("failed to base64-decode payment header: %w", err) + } + } + return decoded, nil +} + +// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with +// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402. +func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) { + if privateKey == nil { + return "", fmt.Errorf("no private key set for %s wallet", providerName) + } + + decoded, err := x402DecodeHeader(paymentHeaderB64) + if err != nil { + return "", err + } + + var req x402v2PaymentRequired + if err := json.Unmarshal(decoded, &req); err != nil { + return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err) + } + if len(req.Accepts) == 0 { + return "", fmt.Errorf("no payment options in x402 response") + } + + senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource) +} + +// doX402Request executes an HTTP request and handles the x402 v2 payment flow. +// On a 402 response it reads the Payment-Required (or X-Payment-Required) header, +// signs via signFn, retries with Payment-Signature, and logs the Payment-Response +// header (tx hash) on success. +func doX402Request( + httpClient *http.Client, + buildReqFn func() (*http.Request, error), + signFn x402SignFunc, + providerTag string, + logger Logger, +) ([]byte, error) { + req, err := buildReqFn() + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPaymentRequired { + paymentHeader := resp.Header.Get("Payment-Required") + if paymentHeader == "" { + paymentHeader = resp.Header.Get("X-Payment-Required") + } + if paymentHeader == "" { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("received 402 but no Payment-Required header found. Body: %s", string(body)) + } + + // Drain 402 body to allow HTTP connection reuse. + _, _ = io.Copy(io.Discard, resp.Body) + + paymentSig, err := signFn(paymentHeader) + if err != nil { + return nil, fmt.Errorf("failed to sign x402 payment: %w", err) + } + + req2, err := buildReqFn() + if err != nil { + return nil, fmt.Errorf("failed to build retry request: %w", err) + } + req2.Header.Set("X-Payment", paymentSig) + req2.Header.Set("Payment-Signature", paymentSig) + + resp2, err := httpClient.Do(req2) + if err != nil { + return nil, fmt.Errorf("failed to send payment retry: %w", err) + } + defer resp2.Body.Close() + + body2, err := io.ReadAll(resp2.Body) + if err != nil { + return nil, fmt.Errorf("failed to read payment retry response: %w", err) + } + if resp2.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2)) + } + + if txHash := resp2.Header.Get("Payment-Response"); txHash != "" { + logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash) + } + + return body2, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body)) + } + return body, nil +} + +// x402BuildRequest creates a POST request with Content-Type but no auth header. +func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) { + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("fail to build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing. +func x402SetAuthHeader(_ http.Header) {} + +// x402Call handles the x402 payment flow for the simple CallWithMessages path. +func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) { + c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL) + + requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) + jsonData, err := c.hooks.marshalRequestBody(requestBody) + if err != nil { + return "", err + } + + body, err := doX402Request(c.httpClient, func() (*http.Request, error) { + return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData) + }, signFn, tag, c.logger) + if err != nil { + return "", err + } + return c.hooks.parseMCPResponse(body) +} + +// x402CallFull handles the x402 payment flow for the advanced Request path. +func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) { + if c.APIKey == "" { + return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first") + } + if req.Model == "" { + req.Model = c.Model + } + + c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL) + + requestBody := c.hooks.buildRequestBodyFromRequest(req) + jsonData, err := c.hooks.marshalRequestBody(requestBody) + if err != nil { + return nil, err + } + + body, err := doX402Request(c.httpClient, func() (*http.Request, error) { + return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData) + }, signFn, tag, c.logger) + if err != nil { + return nil, err + } + return c.hooks.parseMCPResponseFull(body) +}