mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 20:11:13 +08:00
feat: add Claw402 (claw402.ai) x402 USDC payment provider
Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC). Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.) with per-model endpoint routing. - mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow - mcp/blockrun_base.go: extract shared signX402Payment() for reuse - Register "claw402" provider in all 6 consumer switch statements: api/server.go, api/strategy.go, trader/auto_trader.go, telegram/bot.go, debate/engine.go, backtest/ai_client.go
This commit is contained in:
@@ -3450,6 +3450,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
|
||||
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
|
||||
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedModels)
|
||||
|
||||
@@ -674,6 +674,9 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
case "blockrun-sol":
|
||||
aiClient = mcp.NewBlockRunSolClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "claw402":
|
||||
aiClient = mcp.NewClaw402Client()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
default:
|
||||
// Use generic client
|
||||
aiClient = mcp.NewClient()
|
||||
|
||||
@@ -92,6 +92,13 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
||||
brSol := mcp.NewBlockRunSolClient()
|
||||
brSol.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brSol, nil
|
||||
case "claw402":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("claw402 provider requires wallet private key")
|
||||
}
|
||||
claw := mcp.NewClaw402Client()
|
||||
claw.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return claw, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
|
||||
@@ -103,6 +103,8 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
|
||||
client = mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
client = mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
client = mcp.NewClaw402Client()
|
||||
default:
|
||||
client = mcp.New()
|
||||
}
|
||||
|
||||
@@ -169,21 +169,9 @@ func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, erro
|
||||
|
||||
// x402v2PaymentRequired is the structure of the X-Payment-Required header (x402 v2).
|
||||
type x402v2PaymentRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []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"`
|
||||
} `json:"accepts"`
|
||||
Resource *struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"resource"`
|
||||
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.
|
||||
@@ -192,7 +180,6 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
return "", fmt.Errorf("no private key set for BlockRun Base wallet")
|
||||
}
|
||||
|
||||
// Decode base64 → JSON
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64)
|
||||
@@ -205,12 +192,34 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
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")
|
||||
}
|
||||
|
||||
opt := req.Accepts[0]
|
||||
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.
|
||||
// Used by both BlockRunBaseClient and Claw402Client.
|
||||
func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) {
|
||||
recipient := opt.PayTo
|
||||
amount := opt.Amount
|
||||
network := opt.Network
|
||||
@@ -224,28 +233,22 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
resourceURL := ""
|
||||
resourceDesc := ""
|
||||
resourceMime := "application/json"
|
||||
if req.Resource != nil {
|
||||
resourceURL = req.Resource.URL
|
||||
resourceDesc = req.Resource.Description
|
||||
resourceMime = req.Resource.MimeType
|
||||
if resource != nil {
|
||||
resourceURL = resource.URL
|
||||
resourceDesc = resource.Description
|
||||
resourceMime = resource.MimeType
|
||||
}
|
||||
|
||||
// Timestamps: validAfter = now-600 (clock skew), validBefore = now+maxTimeout
|
||||
now := time.Now().Unix()
|
||||
validAfter := now - 600
|
||||
validBefore := now + int64(maxTimeout)
|
||||
|
||||
// Random nonce (bytes32)
|
||||
nonceBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonce := "0x" + hex.EncodeToString(nonceBytes)
|
||||
|
||||
// Sender address
|
||||
senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
||||
|
||||
// Build EIP-712 domain separator
|
||||
domainName := "USD Coin"
|
||||
domainVersion := "2"
|
||||
if extra != nil {
|
||||
@@ -262,7 +265,6 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
return "", fmt.Errorf("failed to build domain separator: %w", err)
|
||||
}
|
||||
|
||||
// Build struct hash
|
||||
amountBig, err := parseBigInt(amount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid amount: %w", err)
|
||||
@@ -273,26 +275,22 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
return "", fmt.Errorf("failed to build struct hash: %w", err)
|
||||
}
|
||||
|
||||
// EIP-712 digest
|
||||
digest := make([]byte, 0, 66)
|
||||
digest = append(digest, 0x19, 0x01)
|
||||
digest = append(digest, domainSeparator...)
|
||||
digest = append(digest, structHash...)
|
||||
hash := keccak256Bytes(digest)
|
||||
|
||||
// Sign with secp256k1
|
||||
sig, err := crypto.Sign(hash, c.privateKey)
|
||||
sig, err := crypto.Sign(hash, privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign: %w", err)
|
||||
}
|
||||
// Adjust V: go-ethereum returns 0/1, EIP-712 expects 27/28
|
||||
if sig[64] < 27 {
|
||||
sig[64] += 27
|
||||
}
|
||||
|
||||
sigHex := "0x" + hex.EncodeToString(sig)
|
||||
|
||||
// Build x402 v2 payment payload
|
||||
paymentData := map[string]interface{}{
|
||||
"x402Version": 2,
|
||||
"resource": map[string]string{
|
||||
|
||||
230
mcp/claw402.go
Normal file
230
mcp/claw402.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderClaw402 = "claw402"
|
||||
DefaultClaw402URL = "https://claw402.ai"
|
||||
DefaultClaw402Model = "deepseek"
|
||||
)
|
||||
|
||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
||||
var claw402ModelEndpoints = map[string]string{
|
||||
// OpenAI
|
||||
"gpt-5.4": "/api/v1/ai/openai/chat/5.4",
|
||||
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro",
|
||||
"gpt-5.3": "/api/v1/ai/openai/chat/5.3",
|
||||
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
|
||||
// Anthropic
|
||||
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
|
||||
// DeepSeek
|
||||
"deepseek": "/api/v1/ai/deepseek/chat",
|
||||
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
||||
// Qwen
|
||||
"qwen-max": "/api/v1/ai/qwen/chat/max",
|
||||
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
|
||||
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
|
||||
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
|
||||
// Grok
|
||||
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
|
||||
// Gemini
|
||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||
// Kimi
|
||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
||||
}
|
||||
|
||||
// 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).
|
||||
type Claw402Client struct {
|
||||
*Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewClaw402Client creates a claw402 client (backward compatible).
|
||||
func NewClaw402Client() AIClient {
|
||||
return NewClaw402ClientWithOptions()
|
||||
}
|
||||
|
||||
// NewClaw402ClientWithOptions creates a claw402 client with options.
|
||||
func NewClaw402ClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderClaw402),
|
||||
WithModel(DefaultClaw402Model),
|
||||
WithBaseURL(DefaultClaw402URL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model]
|
||||
|
||||
c := &Claw402Client{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the EVM private key and selects the model endpoint.
|
||||
func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
|
||||
hexKey := strings.TrimPrefix(apiKey, "0x")
|
||||
privKey, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
|
||||
} else {
|
||||
c.privateKey = privKey
|
||||
c.APIKey = apiKey
|
||||
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
|
||||
c.logger.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
}
|
||||
endpoint := c.resolveEndpoint()
|
||||
c.BaseURL = DefaultClaw402URL + endpoint
|
||||
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
|
||||
}
|
||||
|
||||
// resolveEndpoint returns the API path for the configured model.
|
||||
func (c *Claw402Client) resolveEndpoint() string {
|
||||
if ep, ok := claw402ModelEndpoints[c.Model]; ok {
|
||||
return ep
|
||||
}
|
||||
// Allow raw path override (e.g. "/api/v1/ai/openai/chat/5.4")
|
||||
if strings.HasPrefix(c.Model, "/api/") {
|
||||
return c.Model
|
||||
}
|
||||
return claw402ModelEndpoints[DefaultClaw402Model]
|
||||
}
|
||||
|
||||
func (c *Claw402Client) setAuthHeader(_ http.Header) {
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
// signPayment reuses the same EIP-712 signing logic as BlockRunBaseClient
|
||||
// (same Base chain, same USDC contract, same TransferWithAuthorization).
|
||||
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
if c.privateKey == nil {
|
||||
return "", fmt.Errorf("no private key set for Claw402 wallet")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Reuse the same signing logic as BlockRunBaseClient — identical chain + USDC contract
|
||||
opt := req.Accepts[0]
|
||||
senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
||||
|
||||
return signX402Payment(c.privateKey, senderAddr, opt, req.Resource)
|
||||
}
|
||||
|
||||
// buildUrl returns the full claw402 endpoint URL.
|
||||
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
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func newLLMClient(st *store.Store, userID string) mcp.AIClient {
|
||||
|
||||
// isUSDCProvider returns true for providers that pay per call with USDC (x402 protocol).
|
||||
func isUSDCProvider(provider string) bool {
|
||||
return provider == "blockrun-base" || provider == "blockrun-sol"
|
||||
return provider == "blockrun-base" || provider == "blockrun-sol" || provider == "claw402"
|
||||
}
|
||||
|
||||
func clientForProvider(provider string) mcp.AIClient {
|
||||
@@ -340,6 +340,8 @@ func clientForProvider(provider string) mcp.AIClient {
|
||||
return mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
return mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
return mcp.NewClaw402Client()
|
||||
default:
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
|
||||
@@ -216,6 +216,11 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using BlockRun (Solana Wallet) AI", config.Name)
|
||||
|
||||
case "claw402":
|
||||
mcpClient = mcp.NewClaw402Client()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using Claw402 (Base USDC) AI", config.Name)
|
||||
|
||||
case "qwen":
|
||||
mcpClient = mcp.NewQwenClient()
|
||||
apiKey := config.QwenKey
|
||||
|
||||
Reference in New Issue
Block a user