mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
feat(claw402): preflight USDC balance before AI calls (#1479)
* chore: ignore nofx-server build artifact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(claw402): preflight USDC balance before AI calls Short-circuit claw402 Call/CallWithRequestFull when the wallet balance can't cover the estimated cost of the call, surfacing ErrInsufficientFunds instead of letting x402 fail mid-flight after the sign step. - wallet: cached balance lookup (30s TTL, per-address mutex) to avoid hammering the Base RPC; separate error-returning and display-only APIs so callers can distinguish zero balance from an unreachable RPC. - claw402: 1.5× safety multiplier on the flat per-call estimate, 4.0× for reasoner models whose chain-of-thought cost can blow past the flat rate. Fail-open on RPC errors — x402 still gates actually-empty wallets, and we prefer availability over extra strictness. - shortAddr redacts the wallet in error strings to avoid leaking the full address into telemetry bundles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -126,3 +126,6 @@ dmypy.json
|
|||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
PR_DESCRIPTION.md
|
PR_DESCRIPTION.md
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
/nofx-server
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package payment
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,8 +10,44 @@ import (
|
|||||||
|
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/mcp/provider"
|
"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 (
|
const (
|
||||||
DefaultClaw402URL = "https://claw402.ai"
|
DefaultClaw402URL = "https://claw402.ai"
|
||||||
DefaultClaw402Model = "glm-5"
|
DefaultClaw402Model = "glm-5"
|
||||||
@@ -128,13 +165,57 @@ func (c *Claw402Client) resolveEndpoint() string {
|
|||||||
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
|
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
|
||||||
|
|
||||||
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
|
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)
|
return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
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)
|
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.
|
// signPayment signs x402 v2 EIP-712 payment on Base chain + USDC.
|
||||||
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||||
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
||||||
|
|||||||
67
wallet/balance_cache.go
Normal file
67
wallet/balance_cache.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -18,21 +18,26 @@ const (
|
|||||||
USDCDecimals = 6
|
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) {
|
func QueryUSDCBalance(address string) (float64, error) {
|
||||||
balanceStr := QueryUSDCBalanceStr(address)
|
return queryUSDCBalanceRPC(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
|
// 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 {
|
func QueryUSDCBalanceStr(address string) string {
|
||||||
// Build balanceOf(address) call data
|
balance, err := queryUSDCBalanceRPC(address)
|
||||||
// Function selector: 0x70a08231
|
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")
|
addrNoPre := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||||
data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre)
|
data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre)
|
||||||
|
|
||||||
@@ -51,41 +56,50 @@ func QueryUSDCBalanceStr(address string) string {
|
|||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "0.00"
|
return 0, fmt.Errorf("marshal rpc payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
resp, err := client.Post(BaseRPCURL, "application/json", bytes.NewReader(body))
|
resp, err := client.Post(BaseRPCURL, "application/json", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "0.00"
|
return 0, fmt.Errorf("rpc post: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "0.00"
|
return 0, fmt.Errorf("read rpc response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rpcResp struct {
|
var rpcResp struct {
|
||||||
Result string `json:"result"`
|
Result string `json:"result"`
|
||||||
|
Error json.RawMessage `json:"error"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
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")
|
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
|
||||||
if hexStr == "" || hexStr == "0" {
|
if hexStr == "" {
|
||||||
return "0.00"
|
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)
|
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)
|
remainder := new(big.Int).Mod(balance, divisor)
|
||||||
|
// Preserve 6-decimal precision without float drift.
|
||||||
return fmt.Sprintf("%d.%06d", whole, remainder)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user