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:
Lance
2026-04-16 21:17:45 +08:00
committed by GitHub
parent e1b5a5d833
commit 2f483633ed
4 changed files with 191 additions and 26 deletions

67
wallet/balance_cache.go Normal file
View 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)
}

View File

@@ -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
}