diff --git a/api/server.go b/api/server.go index 926beb89..74813fdd 100644 --- a/api/server.go +++ b/api/server.go @@ -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) diff --git a/api/strategy.go b/api/strategy.go index fbc2d626..c6599d1d 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -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() diff --git a/backtest/ai_client.go b/backtest/ai_client.go index 24dd45a6..203e33d1 100644 --- a/backtest/ai_client.go +++ b/backtest/ai_client.go @@ -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") diff --git a/debate/engine.go b/debate/engine.go index 9e39cca2..37f8376c 100644 --- a/debate/engine.go +++ b/debate/engine.go @@ -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() } diff --git a/mcp/blockrun_base.go b/mcp/blockrun_base.go index 89ecf561..97b9d982 100644 --- a/mcp/blockrun_base.go +++ b/mcp/blockrun_base.go @@ -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{ diff --git a/mcp/claw402.go b/mcp/claw402.go new file mode 100644 index 00000000..70a6aeb2 --- /dev/null +++ b/mcp/claw402.go @@ -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 +} diff --git a/telegram/bot.go b/telegram/bot.go index e65df3a1..e80e4c5a 100644 --- a/telegram/bot.go +++ b/telegram/bot.go @@ -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() } diff --git a/trader/auto_trader.go b/trader/auto_trader.go index d2b1b905..3ee4777b 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -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