Files
shinchan-zhai 851f152c50 fix(wallet): handle JSON-RPC null error field in balance query
Some RPC implementations return explicit "error": null on success.
json.RawMessage deserializes this as the 4-byte literal "null", so
len() > 0 was true, causing every balance query to fail with
"rpc error: null". Skip the null literal to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:26:49 +08:00

106 lines
2.9 KiB
Go

// 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. 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) {
return queryUSDCBalanceRPC(address)
}
// 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 {
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)
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, 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, fmt.Errorf("rpc post: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("read rpc response: %w", err)
}
var rpcResp struct {
Result string `json:"result"`
Error json.RawMessage `json:"error"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return 0, fmt.Errorf("decode rpc response: %w", err)
}
if len(rpcResp.Error) > 0 && string(rpcResp.Error) != "null" {
return 0, fmt.Errorf("rpc error: %s", string(rpcResp.Error))
}
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
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)
}
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(USDCDecimals), nil)
whole := new(big.Int).Quo(balance, divisor)
remainder := new(big.Int).Mod(balance, divisor)
// 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
}