mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +08:00
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.
This commit is contained in:
@@ -1,14 +1,12 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -97,124 +95,19 @@ func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customMo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BlockRunBaseClient) setAuthHeader(reqHeaders http.Header) {
|
func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||||
// No Bearer token — payment is via x402 signing
|
|
||||||
}
|
|
||||||
|
|
||||||
// call overrides the base call to handle HTTP 402 x402 v2 payment flow.
|
|
||||||
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
|
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||||
c.logger.Infof("📡 [BlockRun Base] Request AI Server: %s", c.BaseURL)
|
return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// x402v2PaymentRequired is the structure of the X-Payment-Required header (x402 v2).
|
func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||||
type x402v2PaymentRequired struct {
|
return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
|
||||||
X402Version int `json:"x402Version"`
|
|
||||||
Accepts []x402AcceptOption `json:"accepts"`
|
|
||||||
Resource *x402Resource `json:"resource"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) {
|
||||||
if c.privateKey == nil {
|
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
|
// 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()
|
now := time.Now().Unix()
|
||||||
validAfter := now - 600
|
validAfter := int64(0)
|
||||||
validBefore := now + int64(maxTimeout)
|
validBefore := now + int64(maxTimeout)
|
||||||
|
|
||||||
nonceBytes := make([]byte, 32)
|
nonceBytes := make([]byte, 32)
|
||||||
@@ -417,10 +310,14 @@ func hexToBytes32(s string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseBigInt(s string) (*big.Int, error) {
|
func parseBigInt(s string) (*big.Int, error) {
|
||||||
s = strings.TrimPrefix(s, "0x")
|
|
||||||
n := new(big.Int)
|
n := new(big.Int)
|
||||||
if _, ok := n.SetString(s, 16); ok {
|
// Only treat as hex when explicitly prefixed with 0x/0X.
|
||||||
return n, nil
|
// 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 {
|
if _, ok := n.SetString(s, 10); ok {
|
||||||
return n, nil
|
return n, nil
|
||||||
@@ -443,12 +340,6 @@ func (c *BlockRunBaseClient) buildUrl() string {
|
|||||||
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequest creates the HTTP request without an Authorization header.
|
|
||||||
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
return x402BuildRequest(url, jsonData)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
return req, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -76,120 +74,34 @@ func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customMod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BlockRunSolClient) setAuthHeader(reqHeaders http.Header) {
|
func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||||
// No Bearer token — payment is via x402 signing
|
|
||||||
}
|
|
||||||
|
|
||||||
// call overrides the base call to handle HTTP 402 x402 v2 Solana payment flow.
|
|
||||||
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
|
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||||
c.logger.Infof("📡 [BlockRun Sol] Request AI Server: %s", c.BaseURL)
|
return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// solanaPaymentOption is an entry in the accepts[] array of the x402 v2 response.
|
func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||||
type solanaPaymentOption struct {
|
return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// x402v2SolanaRequired is the parsed X-Payment-Required header for Solana.
|
// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload.
|
||||||
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.
|
|
||||||
func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) {
|
func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) {
|
||||||
if c.keypair == nil {
|
if c.keypair == nil {
|
||||||
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
|
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode base64 → JSON
|
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||||
decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64)
|
return "", err
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to base64-decode payment header: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var req x402v2SolanaRequired
|
var req x402v2PaymentRequired
|
||||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
|
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the Solana option
|
// Find the Solana option
|
||||||
var opt *solanaPaymentOption
|
var opt *x402AcceptOption
|
||||||
for i := range req.Accepts {
|
for i := range req.Accepts {
|
||||||
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
|
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
|
||||||
opt = &req.Accepts[i]
|
opt = &req.Accepts[i]
|
||||||
@@ -360,12 +272,6 @@ func (c *BlockRunSolClient) buildUrl() string {
|
|||||||
return DefaultBlockRunSolURL + BlockRunChatEndpoint
|
return DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequest creates the HTTP request without an Authorization header.
|
|
||||||
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
return x402BuildRequest(url, jsonData)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
return req, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
156
mcp/claw402.go
156
mcp/claw402.go
@@ -1,12 +1,7 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -46,9 +41,12 @@ var claw402ModelEndpoints = map[string]string{
|
|||||||
|
|
||||||
// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway.
|
// 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).
|
// 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 {
|
type Claw402Client struct {
|
||||||
*Client
|
*Client
|
||||||
privateKey *ecdsa.PrivateKey
|
privateKey *ecdsa.PrivateKey
|
||||||
|
claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClaw402Client creates a claw402 client (backward compatible).
|
// NewClaw402Client creates a claw402 client (backward compatible).
|
||||||
@@ -90,7 +88,15 @@ func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
|
|||||||
}
|
}
|
||||||
endpoint := c.resolveEndpoint()
|
endpoint := c.resolveEndpoint()
|
||||||
c.BaseURL = DefaultClaw402URL + endpoint
|
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.
|
// resolveEndpoint returns the API path for the configured model.
|
||||||
@@ -105,113 +111,49 @@ func (c *Claw402Client) resolveEndpoint() string {
|
|||||||
return claw402ModelEndpoints[DefaultClaw402Model]
|
return claw402ModelEndpoints[DefaultClaw402Model]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Claw402Client) setAuthHeader(_ http.Header) {
|
func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||||
// No Bearer token — payment is via x402 signing
|
|
||||||
}
|
|
||||||
|
|
||||||
// call handles the x402 v2 payment flow for claw402.ai.
|
|
||||||
func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) {
|
func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) {
|
||||||
c.logger.Infof("📡 [Claw402] Request AI: %s", c.BaseURL)
|
return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// signPayment reuses the same EIP-712 signing logic as BlockRunBaseClient
|
func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||||
// (same Base chain, same USDC contract, same TransferWithAuthorization).
|
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) {
|
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||||
if c.privateKey == nil {
|
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
||||||
return "", fmt.Errorf("no private key set for Claw402 wallet")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Decode base64 → JSON
|
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req x402v2PaymentRequired
|
func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
if c.claudeProxy != nil {
|
||||||
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
|
return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||||
}
|
}
|
||||||
if len(req.Accepts) == 0 {
|
return c.Client.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||||
return "", fmt.Errorf("no payment options in x402 response")
|
}
|
||||||
|
|
||||||
|
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
|
func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) {
|
||||||
opt := req.Accepts[0]
|
if c.claudeProxy != nil {
|
||||||
senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
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.
|
// buildUrl returns the full claw402 endpoint URL.
|
||||||
@@ -219,12 +161,6 @@ func (c *Claw402Client) buildUrl() string {
|
|||||||
return c.BaseURL
|
return c.BaseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequest creates the HTTP request without Authorization header.
|
|
||||||
func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
return x402BuildRequest(url, jsonData)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
return req, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
219
mcp/x402.go
Normal file
219
mcp/x402.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user