diff --git a/.gitignore b/.gitignore index cae87c9e..89880c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ dmypy.json # Pyre type checker .pyre/ PR_DESCRIPTION.md + +# Go build artifacts +/nofx-server diff --git a/mcp/payment/claw402.go b/mcp/payment/claw402.go index 3d8381f0..f28ca1ca 100644 --- a/mcp/payment/claw402.go +++ b/mcp/payment/claw402.go @@ -2,6 +2,7 @@ package payment import ( "crypto/ecdsa" + "fmt" "net/http" "strings" @@ -9,8 +10,44 @@ import ( "nofx/mcp" "nofx/mcp/provider" + "nofx/store" + "nofx/wallet" ) +// Per-call cost buffers for preflight. Reasoner models emit long chain-of-thought +// tokens whose cost can far exceed the flat per-call estimate in store.GetModelPrice, +// so they use a larger multiplier. +const ( + preflightSafetyMultiplier = 1.5 + preflightReasonerSafetyMultiplier = 4.0 +) + +// ErrInsufficientFunds is returned when the claw402 wallet does not hold +// enough USDC to cover the estimated cost of a call. Callers can type-assert +// to surface balance/needed/address to the UI. +type ErrInsufficientFunds struct { + Address string + Balance float64 + Needed float64 + Model string +} + +func (e *ErrInsufficientFunds) Error() string { + return fmt.Sprintf( + "claw402 insufficient USDC: wallet=%s balance=$%.4f needed=$%.4f model=%s", + shortAddr(e.Address), e.Balance, e.Needed, e.Model, + ) +} + +// shortAddr renders 0x1234…abcd for log/error strings that may leak into +// telemetry bundles. The full address stays on the struct for programmatic use. +func shortAddr(addr string) string { + if len(addr) < 10 { + return addr + } + return addr[:6] + "…" + addr[len(addr)-4:] +} + const ( DefaultClaw402URL = "https://claw402.ai" DefaultClaw402Model = "glm-5" @@ -128,13 +165,57 @@ func (c *Claw402Client) resolveEndpoint() string { func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) } func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) { + if err := c.preflightBalance(); err != nil { + return "", err + } return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil) } func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) { + if err := c.preflightBalance(); err != nil { + return nil, err + } return X402CallFull(c.Client, c.signPayment, "Claw402", req) } +// walletAddress derives the EVM address from the configured private key. +// Returns "" when no key has been set (client unconfigured). +func (c *Claw402Client) walletAddress() string { + if c.privateKey == nil { + return "" + } + return crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex() +} + +// preflightBalance short-circuits a call when the wallet cannot cover the +// estimated cost. RPC failures fall through — x402 will still reject an +// actually-empty wallet, so we prefer availability over extra strictness. +func (c *Claw402Client) preflightBalance() error { + addr := c.walletAddress() + if addr == "" { + return nil + } + balance, err := wallet.QueryUSDCBalanceCached(addr) + if err != nil { + c.Log.Warnf("⚠️ [MCP] Claw402 balance preflight skipped (RPC error): %v", err) + return nil + } + multiplier := preflightSafetyMultiplier + if strings.Contains(strings.ToLower(c.Model), "reasoner") { + multiplier = preflightReasonerSafetyMultiplier + } + needed := store.GetModelPrice(c.Model) * multiplier + if balance < needed { + return &ErrInsufficientFunds{ + Address: addr, + Balance: balance, + Needed: needed, + Model: c.Model, + } + } + return nil +} + // signPayment signs x402 v2 EIP-712 payment on Base chain + USDC. func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) { return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402") diff --git a/wallet/balance_cache.go b/wallet/balance_cache.go new file mode 100644 index 00000000..54e1a9b5 --- /dev/null +++ b/wallet/balance_cache.go @@ -0,0 +1,67 @@ +package wallet + +import ( + "strings" + "sync" + "time" +) + +// balanceCacheTTL is how long a balance reading is trusted before re-querying. +const balanceCacheTTL = 30 * time.Second + +type balanceEntry struct { + value float64 + fetchedAt time.Time +} + +var ( + balanceCache sync.Map + balanceFetchMu sync.Map +) + +// QueryUSDCBalanceCached returns the USDC balance for an address, using a +// short-lived cache to avoid hammering the Base RPC. Addresses are +// case-insensitive. +func QueryUSDCBalanceCached(address string) (float64, error) { + key := strings.ToLower(strings.TrimSpace(address)) + if key == "" { + return 0, nil + } + + if v, ok := balanceCache.Load(key); ok { + e := v.(balanceEntry) + if time.Since(e.fetchedAt) < balanceCacheTTL { + return e.value, nil + } + } + + muAny, _ := balanceFetchMu.LoadOrStore(key, &sync.Mutex{}) + mu := muAny.(*sync.Mutex) + mu.Lock() + defer mu.Unlock() + + if v, ok := balanceCache.Load(key); ok { + e := v.(balanceEntry) + if time.Since(e.fetchedAt) < balanceCacheTTL { + return e.value, nil + } + } + + balance, err := QueryUSDCBalance(address) + if err != nil { + return 0, err + } + balanceCache.Store(key, balanceEntry{value: balance, fetchedAt: time.Now()}) + return balance, nil +} + +// InvalidateBalanceCache drops the cached balance for an address, forcing the +// next query to hit the chain. Use after a known-spending action or when the +// caller suspects the cache is stale. +func InvalidateBalanceCache(address string) { + key := strings.ToLower(strings.TrimSpace(address)) + if key == "" { + return + } + balanceCache.Delete(key) +} diff --git a/wallet/usdc.go b/wallet/usdc.go index 41952724..e8643daf 100644 --- a/wallet/usdc.go +++ b/wallet/usdc.go @@ -18,21 +18,26 @@ const ( USDCDecimals = 6 ) -// QueryUSDCBalance queries USDC balance on Base chain and returns as float64 +// QueryUSDCBalance queries USDC balance on Base chain. RPC / decode failures +// are surfaced as errors so callers can distinguish a real zero balance from +// an unreachable RPC. 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 + return queryUSDCBalanceRPC(address) } -// QueryUSDCBalanceStr queries USDC balance on Base chain and returns as formatted string +// QueryUSDCBalanceStr is the display-oriented counterpart to QueryUSDCBalance: +// it swallows errors and returns "0.00" so UI handlers always have a string to +// render. Use QueryUSDCBalance when you need to react to failure. func QueryUSDCBalanceStr(address string) string { - // Build balanceOf(address) call data - // Function selector: 0x70a08231 + balance, err := queryUSDCBalanceRPC(address) + if err != nil { + return "0.00" + } + return fmt.Sprintf("%.6f", balance) +} + +func queryUSDCBalanceRPC(address string) (float64, error) { + // Build balanceOf(address) call data — function selector 0x70a08231. addrNoPre := strings.TrimPrefix(strings.ToLower(address), "0x") data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre) @@ -51,41 +56,50 @@ func QueryUSDCBalanceStr(address string) string { body, err := json.Marshal(payload) if err != nil { - return "0.00" + return 0, fmt.Errorf("marshal rpc payload: %w", err) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Post(BaseRPCURL, "application/json", bytes.NewReader(body)) if err != nil { - return "0.00" + return 0, fmt.Errorf("rpc post: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return "0.00" + return 0, fmt.Errorf("read rpc response: %w", err) } var rpcResp struct { - Result string `json:"result"` + Result string `json:"result"` + Error json.RawMessage `json:"error"` } if err := json.Unmarshal(respBody, &rpcResp); err != nil { - return "0.00" + return 0, fmt.Errorf("decode rpc response: %w", err) + } + if len(rpcResp.Error) > 0 { + return 0, fmt.Errorf("rpc error: %s", string(rpcResp.Error)) } - // Parse hex result hexStr := strings.TrimPrefix(rpcResp.Result, "0x") - if hexStr == "" || hexStr == "0" { - return "0.00" + if hexStr == "" { + return 0, nil + } + balance, ok := new(big.Int).SetString(hexStr, 16) + if !ok { + return 0, fmt.Errorf("invalid hex balance: %q", rpcResp.Result) } - 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) + whole := new(big.Int).Quo(balance, divisor) remainder := new(big.Int).Mod(balance, divisor) - - return fmt.Sprintf("%d.%06d", whole, remainder) + // Preserve 6-decimal precision without float drift. + frac := fmt.Sprintf("%06d", remainder.Int64()) + combined := whole.String() + "." + frac + var out float64 + if _, err := fmt.Sscanf(combined, "%f", &out); err != nil { + return 0, fmt.Errorf("parse balance %q: %w", combined, err) + } + return out, nil }