mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>}]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user