mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
feat: real-time wallet validation — key check, address display, USDC balance, claw402 health
- 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
This commit is contained in:
174
api/handler_wallet.go
Normal file
174
api/handler_wallet.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -297,6 +297,102 @@ function Claw402ConfigForm({
|
||||
onSubmit: (e: React.FormEvent) => void
|
||||
language: Language
|
||||
}) {
|
||||
const [walletAddress, setWalletAddress] = useState('')
|
||||
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||
const [keyError, setKeyError] = useState('')
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [claw402Status, setClaw402Status] = useState<string | null>(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 (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{/* 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
|
||||
/>
|
||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||
@@ -405,6 +505,79 @@ function Claw402ConfigForm({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Validation Results */}
|
||||
{apiKey && (
|
||||
<div className="space-y-2 pl-1">
|
||||
{/* Validating spinner */}
|
||||
{validating && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: '#60A5FA' }}>
|
||||
<span className="animate-spin">⏳</span>
|
||||
{t('modelConfig.validating', language)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{keyError && !validating && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: '#EF4444' }}>
|
||||
<span>❌</span>
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success: address + balance + status */}
|
||||
{walletAddress && !validating && !keyError && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: '#00E096' }}>
|
||||
<span>✅</span>
|
||||
<span>{t('modelConfig.walletAddress', language)}: <span className="font-mono">{truncAddr(walletAddress)}</span></span>
|
||||
</div>
|
||||
{usdcBalance !== null && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
||||
<span>💰</span>
|
||||
<span>{t('modelConfig.usdcBalance', language)}: ${usdcBalance}</span>
|
||||
</div>
|
||||
)}
|
||||
{balanceNum === 0 && usdcBalance !== null && (
|
||||
<div className="flex items-center gap-2 text-[11px] pl-5" style={{ color: '#F59E0B' }}>
|
||||
<span>👉</span>
|
||||
{t('modelConfig.depositUsdc', language)}
|
||||
</div>
|
||||
)}
|
||||
{claw402Status && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
||||
{claw402Status === 'ok'
|
||||
? t('modelConfig.claw402Connected', language)
|
||||
: t('modelConfig.claw402Unreachable', language)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Test Connection button */}
|
||||
{isKeyValid && !validating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
|
||||
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
|
||||
>
|
||||
<span>🔗</span>
|
||||
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && !testing && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: testResult.status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||
<span>{testResult.status === 'ok' ? '✅' : '❌'}</span>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* USDC Recharge Guide */}
|
||||
@@ -435,9 +608,9 @@ function Claw402ConfigForm({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!apiKey.trim()}
|
||||
disabled={!isKeyValid}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: apiKey.trim() ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }}
|
||||
style={{ background: isKeyValid ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }}
|
||||
>
|
||||
{'🚀 ' + t('modelConfig.startTrading', language)}
|
||||
</button>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user