From 79a513470b49858b79521e338459db6782d5d21c Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Sat, 21 Mar 2026 01:08:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20real-time=20wallet=20validation=20?= =?UTF-8?q?=E2=80=94=20key=20check,=20address=20display,=20USDC=20balance,?= =?UTF-8?q?=20claw402=20health?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/wallet/validate: validate key, derive address, query Base USDC, check claw402 - Claw402ConfigForm: debounced validation, balance display, connection test button - i18n: 11 new keys (en/zh/id) - Private key never logged or stored --- api/handler_wallet.go | 174 +++++++++++++++++ api/server.go | 3 + .../components/trader/ModelConfigModal.tsx | 179 +++++++++++++++++- web/src/i18n/translations.ts | 33 ++++ 4 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 api/handler_wallet.go diff --git a/api/handler_wallet.go b/api/handler_wallet.go new file mode 100644 index 00000000..7c430696 --- /dev/null +++ b/api/handler_wallet.go @@ -0,0 +1,174 @@ +package api + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/gin-gonic/gin" +) + +type walletValidateRequest struct { + PrivateKey string `json:"private_key"` +} + +type walletValidateResponse struct { + Valid bool `json:"valid"` + Address string `json:"address,omitempty"` + BalanceUSDC string `json:"balance_usdc,omitempty"` + Claw402Status string `json:"claw402_status"` // "ok", "unreachable", "error" + Error string `json:"error,omitempty"` +} + +const ( + baseRPCURL = "https://mainnet.base.org" + usdcContractBase = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + usdcDecimals = 6 +) + +func (s *Server) handleWalletValidate(c *gin.Context) { + var req walletValidateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, walletValidateResponse{ + Valid: false, + Error: "invalid request body", + }) + return + } + + pk := req.PrivateKey + + // Validate format + if !strings.HasPrefix(pk, "0x") { + c.JSON(http.StatusOK, walletValidateResponse{ + Valid: false, + Error: "missing 0x prefix", + }) + return + } + + if len(pk) != 66 { + c.JSON(http.StatusOK, walletValidateResponse{ + Valid: false, + Error: fmt.Sprintf("should be 66 characters, got %d", len(pk)), + }) + return + } + + hexPart := pk[2:] + if _, err := hex.DecodeString(hexPart); err != nil { + c.JSON(http.StatusOK, walletValidateResponse{ + Valid: false, + Error: "contains invalid hex characters", + }) + return + } + + // Derive address + privateKey, err := crypto.HexToECDSA(hexPart) + if err != nil { + c.JSON(http.StatusOK, walletValidateResponse{ + Valid: false, + Error: "invalid private key", + }) + return + } + + address := crypto.PubkeyToAddress(privateKey.PublicKey) + addrHex := address.Hex() + + // Query USDC balance (async-ish, but sequential for simplicity) + balanceStr := queryUSDCBalance(addrHex) + + // Check claw402 health + claw402Status := checkClaw402Health() + + c.JSON(http.StatusOK, walletValidateResponse{ + Valid: true, + Address: addrHex, + BalanceUSDC: balanceStr, + Claw402Status: claw402Status, + }) +} + +func queryUSDCBalance(address string) string { + // Build balanceOf(address) call data + // Function selector: 0x70a08231 + // Pad address to 32 bytes + 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.00" + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(baseRPCURL, "application/json", bytes.NewReader(body)) + if err != nil { + return "0.00" + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "0.00" + } + + var rpcResp struct { + Result string `json:"result"` + } + if err := json.Unmarshal(respBody, &rpcResp); err != nil { + return "0.00" + } + + // Parse hex result + hexStr := strings.TrimPrefix(rpcResp.Result, "0x") + if hexStr == "" || hexStr == "0" { + return "0.00" + } + + 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) + remainder := new(big.Int).Mod(balance, divisor) + + return fmt.Sprintf("%d.%06d", whole, remainder) +} + +func checkClaw402Health() string { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://claw402.ai/health") + if err != nil { + return "unreachable" + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return "ok" + } + return "error" +} diff --git a/api/server.go b/api/server.go index cfe8be2d..f2ea3ab5 100644 --- a/api/server.go +++ b/api/server.go @@ -87,6 +87,9 @@ func (s *Server) setupRoutes() { // System config (no authentication required, for frontend to determine admin mode/registration status) s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig) + // Wallet validation (no authentication required — used by frontend config form) + api.POST("/wallet/validate", s.handleWalletValidate) + // Crypto related endpoints (no authentication required, not exposed to bot) api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig) api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey) diff --git a/web/src/components/trader/ModelConfigModal.tsx b/web/src/components/trader/ModelConfigModal.tsx index cfa03258..f58d89b4 100644 --- a/web/src/components/trader/ModelConfigModal.tsx +++ b/web/src/components/trader/ModelConfigModal.tsx @@ -297,6 +297,102 @@ function Claw402ConfigForm({ onSubmit: (e: React.FormEvent) => void language: Language }) { + const [walletAddress, setWalletAddress] = useState('') + const [usdcBalance, setUsdcBalance] = useState(null) + const [keyError, setKeyError] = useState('') + const [validating, setValidating] = useState(false) + const [claw402Status, setClaw402Status] = useState(null) + const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null) + const [testing, setTesting] = useState(false) + + // Client-side validation helper + const getClientError = (key: string): string => { + if (!key) return '' + if (!key.startsWith('0x')) return t('modelConfig.invalidKeyPrefix', language) + if (key.length !== 66) return `${t('modelConfig.invalidKeyLength', language)} ${key.length}` + if (!/^0x[0-9a-fA-F]{64}$/.test(key)) return t('modelConfig.invalidKeyChars', language) + return '' + } + + const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey) + + // Truncate address for display + const truncAddr = (addr: string) => addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : '' + + // Debounced validation when apiKey changes + useEffect(() => { + setWalletAddress('') + setUsdcBalance(null) + setClaw402Status(null) + setTestResult(null) + + const clientErr = getClientError(apiKey) + setKeyError(clientErr) + + if (clientErr || !apiKey) { + setValidating(false) + return + } + + setValidating(true) + const timer = setTimeout(async () => { + try { + const res = await fetch('/api/wallet/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ private_key: apiKey }), + }) + const data = await res.json() + if (data.valid) { + setWalletAddress(data.address || '') + setUsdcBalance(data.balance_usdc || '0.00') + setClaw402Status(data.claw402_status || 'unknown') + setKeyError('') + } else { + setKeyError(data.error || 'Invalid key') + } + } catch { + setKeyError('Validation request failed') + } finally { + setValidating(false) + } + }, 500) + + return () => clearTimeout(timer) + }, [apiKey]) + + const handleTestConnection = async () => { + setTesting(true) + setTestResult(null) + try { + const res = await fetch('/api/wallet/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ private_key: apiKey }), + }) + const data = await res.json() + if (data.valid) { + setWalletAddress(data.address || '') + setUsdcBalance(data.balance_usdc || '0.00') + setClaw402Status(data.claw402_status || 'unknown') + setTestResult({ + status: data.claw402_status === 'ok' ? 'ok' : 'error', + message: data.claw402_status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language), + }) + } else { + setTestResult({ status: 'error', message: data.error || 'Invalid key' }) + } + } catch { + setTestResult({ status: 'error', message: t('modelConfig.claw402Unreachable', language) }) + } finally { + setTesting(false) + } + } + + const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0 + return (
{/* Claw402 Hero Header */} @@ -395,7 +491,11 @@ function Claw402ConfigForm({ onChange={(e) => onApiKeyChange(e.target.value)} placeholder="0x..." className="w-full px-4 py-3 rounded-xl font-mono text-sm" - style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + style={{ + background: '#0B0E11', + border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139', + color: '#EAECEF', + }} required />
@@ -405,6 +505,79 @@ function Claw402ConfigForm({
+ + {/* Wallet Validation Results */} + {apiKey && ( +
+ {/* Validating spinner */} + {validating && ( +
+ + {t('modelConfig.validating', language)} +
+ )} + + {/* Error message */} + {keyError && !validating && ( +
+ + {keyError} +
+ )} + + {/* Success: address + balance + status */} + {walletAddress && !validating && !keyError && ( + <> +
+ + {t('modelConfig.walletAddress', language)}: {truncAddr(walletAddress)} +
+ {usdcBalance !== null && ( +
0 ? '#00E096' : '#F59E0B' }}> + 💰 + {t('modelConfig.usdcBalance', language)}: ${usdcBalance} +
+ )} + {balanceNum === 0 && usdcBalance !== null && ( +
+ 👉 + {t('modelConfig.depositUsdc', language)} +
+ )} + {claw402Status && ( +
+ {claw402Status === 'ok' ? '🟢' : '🔴'} + {claw402Status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language)} +
+ )} + + )} + + {/* Test Connection button */} + {isKeyValid && !validating && ( + + )} + + {/* Test result */} + {testResult && !testing && ( +
+ {testResult.status === 'ok' ? '✅' : '❌'} + {testResult.message} +
+ )} +
+ )} {/* USDC Recharge Guide */} @@ -435,9 +608,9 @@ function Claw402ConfigForm({ diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index e85fc70f..25c89a5e 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -1230,6 +1230,17 @@ export const translations = { getApiKey: 'Get API Key', walletPrivateKeyLabel: 'Wallet Private Key *', selectModelLabel: 'Select Model', + validating: 'Validating...', + walletAddress: 'Wallet Address', + usdcBalance: 'Base USDC Balance', + claw402Connected: 'claw402 Connected', + claw402Unreachable: 'claw402 Unreachable', + depositUsdc: 'Deposit USDC to this address on Base chain', + invalidKeyPrefix: 'Please add 0x at the beginning', + invalidKeyLength: 'Should be 66 characters, currently', + invalidKeyChars: 'Contains invalid characters', + testConnection: 'Test Connection', + testingConnection: 'Testing...', }, // ExchangeConfigModal @@ -2508,6 +2519,17 @@ export const translations = { getApiKey: '获取 API Key', walletPrivateKeyLabel: '钱包私钥 *', selectModelLabel: '选择模型', + validating: '验证中...', + walletAddress: '钱包地址', + usdcBalance: 'Base USDC 余额', + claw402Connected: 'claw402 已连接', + claw402Unreachable: 'claw402 不可达', + depositUsdc: '请往此地址充值 Base 链 USDC', + invalidKeyPrefix: '请在开头加 0x', + invalidKeyLength: '应为 66 个字符,当前', + invalidKeyChars: '包含非法字符', + testConnection: '测试连接', + testingConnection: '测试中...', }, exchangeConfig: { @@ -3591,6 +3613,17 @@ export const translations = { getApiKey: 'Dapatkan API Key', walletPrivateKeyLabel: 'Private Key Wallet *', selectModelLabel: 'Pilih Model', + validating: 'Memvalidasi...', + walletAddress: 'Alamat Wallet', + usdcBalance: 'Saldo Base USDC', + claw402Connected: 'claw402 Terhubung', + claw402Unreachable: 'claw402 Tidak Dapat Dijangkau', + depositUsdc: 'Deposit USDC ke alamat ini di Base chain', + invalidKeyPrefix: 'Tambahkan 0x di awal', + invalidKeyLength: 'Harus 66 karakter, saat ini', + invalidKeyChars: 'Mengandung karakter tidak valid', + testConnection: 'Tes Koneksi', + testingConnection: 'Menguji...', }, exchangeConfig: {