feat: AI cost tracking, pre-launch balance check, low balance alerts

- store/ai_charge.go: local AI cost tracking per call (SQLite)
- wallet/usdc.go: shared USDC balance query (Base chain RPC)
- Pre-launch: estimate daily cost + runway days
- Low balance: warn <$1, error at $0 (every 10 cycles)
- API: GET /api/ai-costs for cost history
- Frontend: model cards show price per call
- Frontend: wallet create + QR deposit + balance display
This commit is contained in:
shinchan-zhai
2026-03-21 12:31:20 +08:00
parent 79a513470b
commit fd77f2df3e
12 changed files with 629 additions and 98 deletions
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// handleGetAICosts returns AI charges for a specific trader
func (s *Server) handleGetAICosts(c *gin.Context) {
traderID := c.Query("trader_id")
period := c.DefaultQuery("period", "today")
if traderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "trader_id is required"})
return
}
charges, total, err := s.store.AICharge().GetCharges(traderID, period)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"charges": charges,
"total": total,
"count": len(charges),
})
}
// handleGetAICostsSummary returns AI cost summary across all traders
func (s *Server) handleGetAICostsSummary(c *gin.Context) {
period := c.DefaultQuery("period", "today")
total, count, byModel := s.store.AICharge().GetSummary(period)
c.JSON(http.StatusOK, gin.H{
"total": total,
"count": count,
"by_model": byModel,
})
}
+19 -63
View File
@@ -1,13 +1,10 @@
package api
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"nofx/wallet"
"strings"
"time"
@@ -27,11 +24,7 @@ type walletValidateResponse struct {
Error string `json:"error,omitempty"`
}
const (
baseRPCURL = "https://mainnet.base.org"
usdcContractBase = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
usdcDecimals = 6
)
func (s *Server) handleWalletValidate(c *gin.Context) {
var req walletValidateRequest
@@ -85,7 +78,7 @@ func (s *Server) handleWalletValidate(c *gin.Context) {
addrHex := address.Hex()
// Query USDC balance (async-ish, but sequential for simplicity)
balanceStr := queryUSDCBalance(addrHex)
balanceStr := wallet.QueryUSDCBalanceStr(addrHex)
// Check claw402 health
claw402Status := checkClaw402Health()
@@ -98,65 +91,28 @@ func (s *Server) handleWalletValidate(c *gin.Context) {
})
}
func queryUSDCBalance(address string) string {
// Build balanceOf(address) call data
// Function selector: 0x70a08231
// Pad address to 32 bytes
addrNoPre := strings.TrimPrefix(strings.ToLower(address), "0x")
data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre)
payload := map[string]interface{}{
"jsonrpc": "2.0",
"method": "eth_call",
"params": []interface{}{
map[string]string{
"to": usdcContractBase,
"data": data,
},
"latest",
},
"id": 1,
}
body, err := json.Marshal(payload)
type walletGenerateResponse struct {
Address string `json:"address"`
PrivateKey string `json:"private_key"`
}
func (s *Server) handleWalletGenerate(c *gin.Context) {
// Generate new EVM wallet
privateKey, err := crypto.GenerateKey()
if err != nil {
return "0.00"
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate wallet"})
return
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(baseRPCURL, "application/json", bytes.NewReader(body))
if err != nil {
return "0.00"
}
defer resp.Body.Close()
address := crypto.PubkeyToAddress(privateKey.PublicKey)
privKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSA(privateKey))
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "0.00"
}
var rpcResp struct {
Result string `json:"result"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return "0.00"
}
// Parse hex result
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
if hexStr == "" || hexStr == "0" {
return "0.00"
}
balance := new(big.Int)
balance.SetString(hexStr, 16)
// Convert to float with 6 decimals
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(usdcDecimals), nil)
whole := new(big.Int).Div(balance, divisor)
remainder := new(big.Int).Mod(balance, divisor)
return fmt.Sprintf("%d.%06d", whole, remainder)
c.JSON(http.StatusOK, walletGenerateResponse{
Address: address.Hex(),
PrivateKey: privKeyHex,
})
}
func checkClaw402Health() string {
+5
View File
@@ -89,6 +89,7 @@ func (s *Server) setupRoutes() {
// Wallet validation (no authentication required — used by frontend config form)
api.POST("/wallet/validate", s.handleWalletValidate)
api.POST("/wallet/generate", s.handleWalletGenerate)
// Crypto related endpoints (no authentication required, not exposed to bot)
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
@@ -173,6 +174,10 @@ Body: {"show_in_competition":<bool>}`,
`:id = trader_id from GET /api/my-traders.`,
s.handleGetGridRiskInfo)
// AI cost tracking
s.route(protected, "GET", "/ai-costs", "Get AI call costs for a trader (?trader_id=xxx&period=today)", s.handleGetAICosts)
s.route(protected, "GET", "/ai-costs/summary", "Get AI cost summary (?period=today)", s.handleGetAICostsSummary)
// AI model configuration
s.routeWithSchema(protected, "GET", "/models", "List AI model configs",
`Returns: [{"id":"<EXACT id — use this as ai_model_id when creating/updating a trader>","name":"<display name>","provider":"<short provider name — NOT a valid id>","enabled":<bool>}]
+167
View File
@@ -0,0 +1,167 @@
package store
import (
"time"
"gorm.io/gorm"
)
// AICharge represents a single AI call charge record
type AICharge struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_ai_charges_trader" json:"trader_id"`
Model string `gorm:"column:model;not null" json:"model"`
Provider string `gorm:"column:provider;not null" json:"provider"`
CostUSD float64 `gorm:"column:cost_usd;not null" json:"cost_usd"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (AICharge) TableName() string { return "ai_charges" }
// modelPrices maps model ID to approximate cost per call in USD
var modelPrices = map[string]float64{
"deepseek": 0.003,
"deepseek-reasoner": 0.005,
"gpt-5.4": 0.05,
"gpt-5.4-pro": 0.50,
"gpt-5.3": 0.01,
"gpt-5-mini": 0.005,
"claude-opus": 0.12,
"qwen-max": 0.01,
"qwen-plus": 0.005,
"qwen-turbo": 0.002,
"qwen-flash": 0.002,
"grok-4.1": 0.06,
"gemini-3.1-pro": 0.03,
"kimi-k2.5": 0.008,
}
// GetModelPrice returns the price per call for a given model
func GetModelPrice(model string) float64 {
if price, ok := modelPrices[model]; ok {
return price
}
return 0.01 // default fallback
}
// AIChargeStore handles AI charge records
type AIChargeStore struct {
db *gorm.DB
}
// NewAIChargeStore creates a new AIChargeStore
func NewAIChargeStore(db *gorm.DB) *AIChargeStore {
return &AIChargeStore{db: db}
}
func (s *AIChargeStore) initTables() error {
return s.db.AutoMigrate(&AICharge{})
}
// Record records a new AI charge
func (s *AIChargeStore) Record(traderID, model, provider string) error {
cost := GetModelPrice(model)
charge := &AICharge{
TraderID: traderID,
Model: model,
Provider: provider,
CostUSD: cost,
}
return s.db.Create(charge).Error
}
// GetCharges returns charges for a trader within a period, plus total cost
func (s *AIChargeStore) GetCharges(traderID string, period string) ([]AICharge, float64, error) {
var charges []AICharge
query := s.db.Where("trader_id = ?", traderID)
query = applyPeriodFilter(query, period)
if err := query.Order("created_at DESC").Find(&charges).Error; err != nil {
return nil, 0, err
}
var total float64
for _, c := range charges {
total += c.CostUSD
}
return charges, total, nil
}
// GetDailyCost returns total cost across all traders for a period
func (s *AIChargeStore) GetDailyCost(period string) float64 {
var total float64
query := s.db.Model(&AICharge{}).Select("COALESCE(SUM(cost_usd), 0)")
query = applyPeriodFilter(query, period)
query.Scan(&total)
return total
}
// GetSummary returns summary stats for a period
func (s *AIChargeStore) GetSummary(period string) (total float64, count int64, byModel map[string]float64) {
byModel = make(map[string]float64)
query := s.db.Model(&AICharge{})
query = applyPeriodFilter(query, period)
query.Count(&count)
query2 := s.db.Model(&AICharge{}).Select("COALESCE(SUM(cost_usd), 0)")
query2 = applyPeriodFilter(query2, period)
query2.Scan(&total)
// By model breakdown
type modelCost struct {
Model string `gorm:"column:model"`
Total float64 `gorm:"column:total"`
}
var results []modelCost
query3 := s.db.Model(&AICharge{}).Select("model, SUM(cost_usd) as total").Group("model")
query3 = applyPeriodFilter(query3, period)
query3.Find(&results)
for _, r := range results {
byModel[r.Model] = r.Total
}
return total, count, byModel
}
func applyPeriodFilter(query *gorm.DB, period string) *gorm.DB {
now := time.Now()
switch period {
case "today":
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return query.Where("created_at >= ?", start)
case "week":
return query.Where("created_at >= ?", now.AddDate(0, 0, -7))
case "month":
return query.Where("created_at >= ?", now.AddDate(0, -1, 0))
case "all":
return query
default:
// Try parse as date
if t, err := time.Parse("2006-01-02", period); err == nil {
end := t.AddDate(0, 0, 1)
return query.Where("created_at >= ? AND created_at < ?", t, end)
}
// Default to today
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return query.Where("created_at >= ?", start)
}
}
// IsClaw402Config checks if a trader config uses claw402 payment provider
func IsClaw402Config(aiModel string) bool {
return aiModel == "claw402"
}
// EstimateRunway estimates how many days the given USDC balance will last
func EstimateRunway(usdcBalance float64, modelName string, scanIntervalMinutes int) (dailyCost float64, runwayDays float64) {
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = 3
}
callsPerDay := float64(24*60) / float64(scanIntervalMinutes)
pricePerCall := GetModelPrice(modelName)
dailyCost = callsPerDay * pricePerCall
if dailyCost > 0 && usdcBalance > 0 {
runwayDays = usdcBalance / dailyCost
}
return dailyCost, runwayDays
}
+14
View File
@@ -28,6 +28,7 @@ type Store struct {
equity *EquityStore
order *OrderStore
grid *GridStore
aiCharge *AIChargeStore
telegramConfig TelegramConfigStore
mu sync.RWMutex
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
}
if err := s.AICharge().initTables(); err != nil {
return fmt.Errorf("failed to initialize AI charge tables: %w", err)
}
return nil
}
@@ -283,6 +287,16 @@ func (s *Store) Grid() *GridStore {
return s.grid
}
// AICharge gets AI charge storage
func (s *Store) AICharge() *AIChargeStore {
s.mu.Lock()
defer s.mu.Unlock()
if s.aiCharge == nil {
s.aiCharge = NewAIChargeStore(s.gdb)
}
return s.aiCharge
}
// TelegramConfig gets Telegram bot configuration storage
func (s *Store) TelegramConfig() TelegramConfigStore {
s.mu.Lock()
+66
View File
@@ -8,6 +8,8 @@ import (
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
"nofx/wallet"
"github.com/ethereum/go-ethereum/crypto"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
@@ -145,6 +147,7 @@ type AutoTrader struct {
lastBalanceSyncTime time.Time // Last balance sync time
userID string // User ID
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
claw402WalletAddr string // Claw402 wallet address (derived from private key at start)
}
// NewAutoTrader creates an automatic trader
@@ -371,6 +374,9 @@ func (at *AutoTrader) Run() error {
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
// Pre-launch checks for claw402 users
at.runPreLaunchChecks()
at.monitorWg.Add(1)
defer at.monitorWg.Done()
@@ -587,3 +593,63 @@ func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
}
return 0.0
}
// runPreLaunchChecks performs pre-launch checks for claw402 users (wallet balance, runway estimate)
func (at *AutoTrader) runPreLaunchChecks() {
if !store.IsClaw402Config(at.config.AIModel) {
return
}
logger.Info("🔍 Running pre-launch checks (claw402)...")
// Derive wallet address from CustomAPIKey (which is the private key for claw402)
if at.config.CustomAPIKey != "" {
// Try to derive address using go-ethereum
addr := deriveWalletAddress(at.config.CustomAPIKey)
if addr != "" {
at.claw402WalletAddr = addr
logger.Infof("💳 [%s] Claw402 wallet: %s", at.name, addr)
// Query USDC balance
balance, err := wallet.QueryUSDCBalance(addr)
if err != nil {
logger.Warnf("⚠️ [%s] Could not query USDC balance: %v", at.name, err)
} else {
// Estimate runway
scanMinutes := int(at.config.ScanInterval.Minutes())
modelName := at.config.CustomModelName
if modelName == "" {
modelName = "deepseek"
}
dailyCost, runway := store.EstimateRunway(balance, modelName, scanMinutes)
logger.Infof("💰 [%s] USDC Balance: $%.2f | Daily AI cost: ~$%.2f | Runway: ~%.1f days",
at.name, balance, dailyCost, runway)
if balance < 1.0 {
logger.Warnf("⚠️ [%s] Low USDC balance! Consider topping up.", at.name)
}
if balance <= 0 {
logger.Errorf("🚨 [%s] USDC balance is ZERO — AI calls will fail!", at.name)
}
}
}
}
logger.Info("✅ Pre-launch checks complete")
}
// deriveWalletAddress derives an Ethereum address from a hex private key
func deriveWalletAddress(privateKeyHex string) string {
// Remove 0x prefix if present
if len(privateKeyHex) > 2 && privateKeyHex[:2] == "0x" {
privateKeyHex = privateKeyHex[2:]
}
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
return ""
}
address := crypto.PubkeyToAddress(privateKey.PublicKey)
return address.Hex()
}
+46
View File
@@ -6,6 +6,7 @@ import (
"nofx/kernel"
"nofx/logger"
"nofx/store"
"nofx/wallet"
"strings"
"time"
)
@@ -27,6 +28,11 @@ func (at *AutoTrader) runCycle() error {
return nil
}
// Check USDC balance periodically for claw402 users (every 10 cycles)
if at.callCount%10 == 0 && store.IsClaw402Config(at.config.AIModel) {
at.checkClaw402Balance()
}
// Create decision record
record := &store.DecisionRecord{
ExecutionLog: []string{},
@@ -110,6 +116,13 @@ func (at *AutoTrader) runCycle() error {
}
}
// Record AI charge (track cost regardless of decision outcome)
if aiDecision != nil && at.store != nil {
if chargeErr := at.store.AICharge().Record(at.id, at.aiModel, at.config.AIModel); chargeErr != nil {
logger.Warnf("⚠️ Failed to record AI charge: %v", chargeErr)
}
}
if err != nil {
record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
@@ -558,3 +571,36 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
return sorted
}
// checkClaw402Balance checks USDC balance and logs warnings if low
func (at *AutoTrader) checkClaw402Balance() {
scanMinutes := int(at.config.ScanInterval.Minutes())
if scanMinutes <= 0 {
scanMinutes = 3
}
dailyCost, _ := store.EstimateRunway(1.0, at.config.CustomModelName, scanMinutes)
logger.Infof("💰 [%s] Estimated daily AI cost: ~$%.2f (model: %s, interval: %dm)",
at.name, dailyCost, at.config.CustomModelName, scanMinutes)
if at.claw402WalletAddr != "" {
balance, err := wallet.QueryUSDCBalance(at.claw402WalletAddr)
if err != nil {
logger.Warnf("⚠️ [%s] Failed to query USDC balance: %v", at.name, err)
return
}
if balance < 1.0 {
logger.Warnf("⚠️ [%s] Low USDC balance: $%.2f — AI may stop soon!", at.name, balance)
}
if balance <= 0 {
logger.Errorf("🚨 [%s] USDC balance is ZERO — AI calls will fail!", at.name)
}
runway := float64(0)
if dailyCost > 0 {
runway = balance / dailyCost
}
logger.Infof("💰 [%s] USDC Balance: $%.2f | Daily AI cost: ~$%.2f | Runway: ~%.1f days",
at.name, balance, dailyCost, runway)
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package wallet provides shared wallet utilities (USDC balance queries, etc.)
package wallet
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
)
const (
BaseRPCURL = "https://mainnet.base.org"
USDCContractBase = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
USDCDecimals = 6
)
// QueryUSDCBalance queries USDC balance on Base chain and returns as float64
func QueryUSDCBalance(address string) (float64, error) {
balanceStr := QueryUSDCBalanceStr(address)
var balance float64
_, err := fmt.Sscanf(balanceStr, "%f", &balance)
if err != nil {
return 0, fmt.Errorf("failed to parse balance: %w", err)
}
return balance, nil
}
// QueryUSDCBalanceStr queries USDC balance on Base chain and returns as formatted string
func QueryUSDCBalanceStr(address string) string {
// Build balanceOf(address) call data
// Function selector: 0x70a08231
addrNoPre := strings.TrimPrefix(strings.ToLower(address), "0x")
data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre)
payload := map[string]interface{}{
"jsonrpc": "2.0",
"method": "eth_call",
"params": []interface{}{
map[string]string{
"to": USDCContractBase,
"data": data,
},
"latest",
},
"id": 1,
}
body, err := json.Marshal(payload)
if err != nil {
return "0.00"
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(BaseRPCURL, "application/json", bytes.NewReader(body))
if err != nil {
return "0.00"
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "0.00"
}
var rpcResp struct {
Result string `json:"result"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return "0.00"
}
// Parse hex result
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
if hexStr == "" || hexStr == "0" {
return "0.00"
}
balance := new(big.Int)
balance.SetString(hexStr, 16)
// Convert to float with 6 decimals
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(USDCDecimals), nil)
whole := new(big.Int).Div(balance, divisor)
remainder := new(big.Int).Mod(balance, divisor)
return fmt.Sprintf("%d.%06d", whole, remainder)
}
+10
View File
@@ -18,6 +18,7 @@
"katex": "^0.16.27",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.552.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-password-checklist": "^1.8.1",
@@ -6837,6 +6838,15 @@
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+1
View File
@@ -24,6 +24,7 @@
"katex": "^0.16.27",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.552.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-password-checklist": "^1.8.1",
+152 -23
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations'
@@ -298,6 +299,10 @@ function Claw402ConfigForm({
language: Language
}) {
const [walletAddress, setWalletAddress] = useState('')
const [copiedAddr, setCopiedAddr] = useState(false)
const [showDeposit, setShowDeposit] = useState(false)
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
const [newWalletKey, setNewWalletKey] = useState('')
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
const [keyError, setKeyError] = useState('')
const [validating, setValidating] = useState(false)
@@ -317,7 +322,7 @@ function Claw402ConfigForm({
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
// Truncate address for display
const truncAddr = (addr: string) => addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : ''
// Debounced validation when apiKey changes
useEffect(() => {
@@ -375,6 +380,7 @@ function Claw402ConfigForm({
setWalletAddress(data.address || '')
setUsdcBalance(data.balance_usdc || '0.00')
setClaw402Status(data.claw402_status || 'unknown')
if (parseFloat(data.balance_usdc || '0') === 0) setShowDeposit(true)
setTestResult({
status: data.claw402_status === 'ok' ? 'ok' : 'error',
message: data.claw402_status === 'ok'
@@ -446,6 +452,9 @@ function Claw402ConfigForm({
<div className="text-[10px] truncate" style={{ color: '#848E9C' }}>
{m.provider} · {m.desc}
</div>
<div className="text-[10px]" style={{ color: '#00E096' }}>
~${m.price}/call
</div>
</div>
{isSelected && (
<span className="text-[10px] mt-1" style={{ color: '#60A5FA' }}></span>
@@ -485,19 +494,78 @@ function Claw402ConfigForm({
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{t('modelConfig.walletPrivateKey', language)}
</div>
<input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="0x..."
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
style={{
background: '#0B0E11',
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<div className="flex gap-2">
<input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="0x..."
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
style={{
background: '#0B0E11',
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
{!apiKey && (
<button
type="button"
onClick={async () => {
try {
const res = await fetch('/api/wallet/generate', { method: 'POST' })
const data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} catch { /* ignore */ }
}}
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
)}
</div>
{/* New wallet backup warning */}
{showNewWalletBackup && newWalletKey && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
</div>
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{language === 'zh'
? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。'
: 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'}
</div>
<div className="flex items-center gap-2 mb-2">
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
{newWalletKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(newWalletKey)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="shrink-0 text-[10px] px-2 py-1 rounded"
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
</button>
</div>
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
<div> {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden' : 'Save to a password manager (1Password / Bitwarden)'}</div>
<div> {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
<div> {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
</div>
</div>
)}
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span>
<span>
@@ -528,20 +596,81 @@ function Claw402ConfigForm({
{/* Success: address + balance + status */}
{walletAddress && !validating && !keyError && (
<>
<div className="flex items-center gap-2 text-xs" style={{ color: '#00E096' }}>
<span></span>
<span>{t('modelConfig.walletAddress', language)}: <span className="font-mono">{truncAddr(walletAddress)}</span></span>
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px]" style={{ color: '#A0AEC0' }}>
{t('modelConfig.walletAddress', language)}:
</span>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ background: 'rgba(96,165,250,0.1)', color: '#60A5FA', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅' : '📋'}
</button>
</div>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
{language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
</div>
</div>
{usdcBalance !== null && (
<div className="flex items-center gap-2 text-xs" style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
<div className="flex items-center gap-2 text-xs">
<span>💰</span>
<span>{t('modelConfig.usdcBalance', language)}: ${usdcBalance}</span>
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
</span>
<button
type="button"
onClick={() => setShowDeposit(!showDeposit)}
className="text-[10px] px-2 py-0.5 rounded transition-all"
style={{ background: 'rgba(0,224,150,0.1)', color: '#00E096', border: 'none', cursor: 'pointer' }}
>
{showDeposit
? (language === 'zh' ? '收起' : 'Hide')
: (language === 'zh' ? '💳 充值' : '💳 Deposit')}
</button>
</div>
)}
{balanceNum === 0 && usdcBalance !== null && (
<div className="flex items-center gap-2 text-[11px] pl-5" style={{ color: '#F59E0B' }}>
<span>👉</span>
{t('modelConfig.depositUsdc', language)}
{showDeposit && (
<div className="p-3 rounded-xl mt-1" style={{ background: 'rgba(0, 224, 150, 0.04)', border: '1px solid rgba(0, 224, 150, 0.15)' }}>
<div className="text-xs font-semibold mb-2" style={{ color: '#00E096' }}>
💳 {language === 'zh' ? '充值 USDC (Base 链)' : 'Deposit USDC (Base Chain)'}
</div>
<div className="flex gap-3 items-start mb-3">
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
<QRCodeSVG value={walletAddress} size={80} level="M" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
</div>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="text-[10px] px-2 py-0.5 rounded"
style={{ background: 'rgba(96,165,250,0.1)', color: '#60A5FA', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅ Copied' : '📋 Copy Address'}
</button>
</div>
</div>
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
<div>📱 {language === 'zh' ? '用交易所 App 扫描二维码直接转账' : 'Scan QR with exchange app to transfer'}</div>
<div> {language === 'zh' ? '提币时网络选择 Base' : 'Choose Base network when withdrawing'}</div>
<div> {language === 'zh' ? '或跨链桥: ' : 'Or bridge: '}<a href="https://bridge.base.org" target="_blank" rel="noopener" className="underline" style={{ color: '#60A5FA' }}>bridge.base.org</a></div>
<div> {language === 'zh' ? '最低充值 $1 USDC 即可开始' : 'Min $1 USDC to start'}</div>
</div>
</div>
)}
{claw402Status && (
+15 -12
View File
@@ -12,6 +12,7 @@ export interface Claw402Model {
provider: string
desc: string
icon: string
price: number // USD per call
}
export interface AIProviderConfig {
@@ -52,18 +53,20 @@ export const BLOCKRUN_MODELS: BlockrunModel[] = [
// Models available through Claw402 (x402 USDC payment protocol)
export const CLAW402_MODELS: Claw402Model[] = [
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: 'Flagship · Fast', icon: '' },
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: 'Reasoning · Pro', icon: '🧠' },
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: 'Balanced', icon: '💡' },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: 'Fast · Cheap', icon: '🚀' },
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: 'Flagship · Deep', icon: '🎯' },
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: 'Best Value', icon: '🔥' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: 'Reasoning', icon: '🤔' },
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: 'Flagship', icon: '🌟' },
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: 'Balanced', icon: '' },
{ id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: 'Flagship', icon: '' },
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: 'Flagship', icon: '💎' },
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: 'Balanced', icon: '🌙' },
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: '$0.003/call', icon: '🔥', price: 0.003 },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: '$0.005/call', icon: '🤔', price: 0.005 },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: '$0.005/call', icon: '🚀', price: 0.005 },
{ id: 'qwen-turbo', name: 'Qwen Turbo', provider: 'Alibaba', desc: '$0.002/call', icon: '', price: 0.002 },
{ id: 'qwen-flash', name: 'Qwen Flash', provider: 'Alibaba', desc: '$0.002/call', icon: '', price: 0.002 },
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: '$0.005/call', icon: '', price: 0.005 },
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: '$0.008/call', icon: '🌙', price: 0.008 },
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: '$0.01/call', icon: '💡', price: 0.01 },
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: '$0.01/call', icon: '🌟', price: 0.01 },
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: '$0.03/call', icon: '💎', price: 0.03 },
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: '$0.05/call', icon: '', price: 0.05 },
{ id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: '$0.06/call', icon: '', price: 0.06 },
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: '$0.12/call', icon: '🎯', price: 0.12 },
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: '$0.50/call', icon: '🧠', price: 0.50 },
]
// AI Provider configuration - default models and API links