mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user