mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 00:36:56 +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:
43
api/handler_ai_cost.go
Normal file
43
api/handler_ai_cost.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"nofx/wallet"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,11 +24,7 @@ type walletValidateResponse struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
baseRPCURL = "https://mainnet.base.org"
|
|
||||||
usdcContractBase = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
||||||
usdcDecimals = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleWalletValidate(c *gin.Context) {
|
func (s *Server) handleWalletValidate(c *gin.Context) {
|
||||||
var req walletValidateRequest
|
var req walletValidateRequest
|
||||||
@@ -85,7 +78,7 @@ func (s *Server) handleWalletValidate(c *gin.Context) {
|
|||||||
addrHex := address.Hex()
|
addrHex := address.Hex()
|
||||||
|
|
||||||
// Query USDC balance (async-ish, but sequential for simplicity)
|
// Query USDC balance (async-ish, but sequential for simplicity)
|
||||||
balanceStr := queryUSDCBalance(addrHex)
|
balanceStr := wallet.QueryUSDCBalanceStr(addrHex)
|
||||||
|
|
||||||
// Check claw402 health
|
// Check claw402 health
|
||||||
claw402Status := checkClaw402Health()
|
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 {
|
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}
|
address := crypto.PubkeyToAddress(privateKey.PublicKey)
|
||||||
resp, err := client.Post(baseRPCURL, "application/json", bytes.NewReader(body))
|
privKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSA(privateKey))
|
||||||
if err != nil {
|
|
||||||
return "0.00"
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
c.JSON(http.StatusOK, walletGenerateResponse{
|
||||||
if err != nil {
|
Address: address.Hex(),
|
||||||
return "0.00"
|
PrivateKey: privKeyHex,
|
||||||
}
|
})
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkClaw402Health() string {
|
func checkClaw402Health() string {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// Wallet validation (no authentication required — used by frontend config form)
|
// Wallet validation (no authentication required — used by frontend config form)
|
||||||
api.POST("/wallet/validate", s.handleWalletValidate)
|
api.POST("/wallet/validate", s.handleWalletValidate)
|
||||||
|
api.POST("/wallet/generate", s.handleWalletGenerate)
|
||||||
|
|
||||||
// Crypto related endpoints (no authentication required, not exposed to bot)
|
// Crypto related endpoints (no authentication required, not exposed to bot)
|
||||||
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
||||||
@@ -173,6 +174,10 @@ Body: {"show_in_competition":<bool>}`,
|
|||||||
`:id = trader_id from GET /api/my-traders.`,
|
`:id = trader_id from GET /api/my-traders.`,
|
||||||
s.handleGetGridRiskInfo)
|
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
|
// AI model configuration
|
||||||
s.routeWithSchema(protected, "GET", "/models", "List AI model configs",
|
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>}]
|
`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
store/ai_charge.go
Normal file
167
store/ai_charge.go
Normal 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
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type Store struct {
|
|||||||
equity *EquityStore
|
equity *EquityStore
|
||||||
order *OrderStore
|
order *OrderStore
|
||||||
grid *GridStore
|
grid *GridStore
|
||||||
|
aiCharge *AIChargeStore
|
||||||
telegramConfig TelegramConfigStore
|
telegramConfig TelegramConfigStore
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
|
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +287,16 @@ func (s *Store) Grid() *GridStore {
|
|||||||
return s.grid
|
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
|
// TelegramConfig gets Telegram bot configuration storage
|
||||||
func (s *Store) TelegramConfig() TelegramConfigStore {
|
func (s *Store) TelegramConfig() TelegramConfigStore {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
_ "nofx/mcp/payment"
|
_ "nofx/mcp/payment"
|
||||||
_ "nofx/mcp/provider"
|
_ "nofx/mcp/provider"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/wallet"
|
||||||
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"nofx/trader/aster"
|
"nofx/trader/aster"
|
||||||
"nofx/trader/binance"
|
"nofx/trader/binance"
|
||||||
"nofx/trader/bitget"
|
"nofx/trader/bitget"
|
||||||
@@ -145,6 +147,7 @@ type AutoTrader struct {
|
|||||||
lastBalanceSyncTime time.Time // Last balance sync time
|
lastBalanceSyncTime time.Time // Last balance sync time
|
||||||
userID string // User ID
|
userID string // User ID
|
||||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
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
|
// NewAutoTrader creates an automatic trader
|
||||||
@@ -371,6 +374,9 @@ func (at *AutoTrader) Run() error {
|
|||||||
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
|
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
|
||||||
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
|
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
|
||||||
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
|
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)
|
at.monitorWg.Add(1)
|
||||||
defer at.monitorWg.Done()
|
defer at.monitorWg.Done()
|
||||||
|
|
||||||
@@ -587,3 +593,63 @@ func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
|
|||||||
}
|
}
|
||||||
return 0.0
|
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/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/wallet"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,11 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
return nil
|
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
|
// Create decision record
|
||||||
record := &store.DecisionRecord{
|
record := &store.DecisionRecord{
|
||||||
ExecutionLog: []string{},
|
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 {
|
if err != nil {
|
||||||
record.Success = false
|
record.Success = false
|
||||||
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
|
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
|
||||||
@@ -558,3 +571,36 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
|||||||
|
|
||||||
return sorted
|
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
wallet/usdc.go
Normal file
91
wallet/usdc.go
Normal 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
web/package-lock.json
generated
10
web/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lightweight-charts": "^5.1.0",
|
"lightweight-charts": "^5.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-password-checklist": "^1.8.1",
|
"react-password-checklist": "^1.8.1",
|
||||||
@@ -6837,6 +6838,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lightweight-charts": "^5.1.0",
|
"lightweight-charts": "^5.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-password-checklist": "^1.8.1",
|
"react-password-checklist": "^1.8.1",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
||||||
import type { AIModel } from '../../types'
|
import type { AIModel } from '../../types'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
@@ -298,6 +299,10 @@ function Claw402ConfigForm({
|
|||||||
language: Language
|
language: Language
|
||||||
}) {
|
}) {
|
||||||
const [walletAddress, setWalletAddress] = useState('')
|
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 [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||||
const [keyError, setKeyError] = useState('')
|
const [keyError, setKeyError] = useState('')
|
||||||
const [validating, setValidating] = useState(false)
|
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)
|
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
|
||||||
|
|
||||||
// Truncate address for display
|
// Truncate address for display
|
||||||
const truncAddr = (addr: string) => addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : ''
|
|
||||||
|
|
||||||
// Debounced validation when apiKey changes
|
// Debounced validation when apiKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -375,6 +380,7 @@ function Claw402ConfigForm({
|
|||||||
setWalletAddress(data.address || '')
|
setWalletAddress(data.address || '')
|
||||||
setUsdcBalance(data.balance_usdc || '0.00')
|
setUsdcBalance(data.balance_usdc || '0.00')
|
||||||
setClaw402Status(data.claw402_status || 'unknown')
|
setClaw402Status(data.claw402_status || 'unknown')
|
||||||
|
if (parseFloat(data.balance_usdc || '0') === 0) setShowDeposit(true)
|
||||||
setTestResult({
|
setTestResult({
|
||||||
status: data.claw402_status === 'ok' ? 'ok' : 'error',
|
status: data.claw402_status === 'ok' ? 'ok' : 'error',
|
||||||
message: data.claw402_status === 'ok'
|
message: data.claw402_status === 'ok'
|
||||||
@@ -446,6 +452,9 @@ function Claw402ConfigForm({
|
|||||||
<div className="text-[10px] truncate" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] truncate" style={{ color: '#848E9C' }}>
|
||||||
{m.provider} · {m.desc}
|
{m.provider} · {m.desc}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[10px]" style={{ color: '#00E096' }}>
|
||||||
|
~${m.price}/call
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="text-[10px] mt-1" style={{ color: '#60A5FA' }}>✓</span>
|
<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' }}>
|
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||||
{t('modelConfig.walletPrivateKey', language)}
|
{t('modelConfig.walletPrivateKey', language)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="password"
|
<input
|
||||||
value={apiKey}
|
type="password"
|
||||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
value={apiKey}
|
||||||
placeholder="0x..."
|
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||||
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
|
placeholder="0x..."
|
||||||
style={{
|
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
|
||||||
background: '#0B0E11',
|
style={{
|
||||||
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
background: '#0B0E11',
|
||||||
color: '#EAECEF',
|
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
||||||
}}
|
color: '#EAECEF',
|
||||||
required
|
}}
|
||||||
/>
|
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' }}>
|
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||||
<span className="mt-px">🔒</span>
|
<span className="mt-px">🔒</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -528,20 +596,81 @@ function Claw402ConfigForm({
|
|||||||
{/* Success: address + balance + status */}
|
{/* Success: address + balance + status */}
|
||||||
{walletAddress && !validating && !keyError && (
|
{walletAddress && !validating && !keyError && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 text-xs" style={{ color: '#00E096' }}>
|
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
|
||||||
<span>✅</span>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span>{t('modelConfig.walletAddress', language)}: <span className="font-mono">{truncAddr(walletAddress)}</span></span>
|
<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>
|
</div>
|
||||||
{usdcBalance !== null && (
|
{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>💰</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{balanceNum === 0 && usdcBalance !== null && (
|
{showDeposit && (
|
||||||
<div className="flex items-center gap-2 text-[11px] pl-5" style={{ color: '#F59E0B' }}>
|
<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)' }}>
|
||||||
<span>👉</span>
|
<div className="text-xs font-semibold mb-2" style={{ color: '#00E096' }}>
|
||||||
{t('modelConfig.depositUsdc', language)}
|
💳 {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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{claw402Status && (
|
{claw402Status && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Claw402Model {
|
|||||||
provider: string
|
provider: string
|
||||||
desc: string
|
desc: string
|
||||||
icon: string
|
icon: string
|
||||||
|
price: number // USD per call
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIProviderConfig {
|
export interface AIProviderConfig {
|
||||||
@@ -52,18 +53,20 @@ export const BLOCKRUN_MODELS: BlockrunModel[] = [
|
|||||||
|
|
||||||
// Models available through Claw402 (x402 USDC payment protocol)
|
// Models available through Claw402 (x402 USDC payment protocol)
|
||||||
export const CLAW402_MODELS: Claw402Model[] = [
|
export const CLAW402_MODELS: Claw402Model[] = [
|
||||||
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: 'Flagship · Fast', icon: '⚡' },
|
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: '$0.003/call', icon: '🔥', price: 0.003 },
|
||||||
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: 'Reasoning · Pro', icon: '🧠' },
|
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: '$0.005/call', icon: '🤔', price: 0.005 },
|
||||||
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: 'Balanced', icon: '💡' },
|
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: '$0.005/call', icon: '🚀', price: 0.005 },
|
||||||
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: 'Fast · Cheap', icon: '🚀' },
|
{ id: 'qwen-turbo', name: 'Qwen Turbo', provider: 'Alibaba', desc: '$0.002/call', icon: '⚡', price: 0.002 },
|
||||||
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: 'Flagship · Deep', icon: '🎯' },
|
{ id: 'qwen-flash', name: 'Qwen Flash', provider: 'Alibaba', desc: '$0.002/call', icon: '⚡', price: 0.002 },
|
||||||
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: 'Best Value', icon: '🔥' },
|
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: '$0.005/call', icon: '✨', price: 0.005 },
|
||||||
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: 'Reasoning', icon: '🤔' },
|
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: '$0.008/call', icon: '🌙', price: 0.008 },
|
||||||
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: 'Flagship', icon: '🌟' },
|
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: '$0.01/call', icon: '💡', price: 0.01 },
|
||||||
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: 'Balanced', icon: '✨' },
|
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: '$0.01/call', icon: '🌟', price: 0.01 },
|
||||||
{ 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: '$0.03/call', icon: '💎', price: 0.03 },
|
||||||
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: 'Flagship', icon: '💎' },
|
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: '$0.05/call', icon: '⚡', price: 0.05 },
|
||||||
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: 'Balanced', icon: '🌙' },
|
{ 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
|
// AI Provider configuration - default models and API links
|
||||||
|
|||||||
Reference in New Issue
Block a user