Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-10 11:32:49 -08:00
parent aafcd913c2
commit 88bc76da91
815 changed files with 125522 additions and 264 deletions

View File

@@ -0,0 +1,90 @@
package track1
import (
"sync"
"time"
)
// InMemoryCache is a simple in-memory cache
// In production, use Redis for distributed caching
type InMemoryCache struct {
items map[string]*cacheItem
mu sync.RWMutex
}
// cacheItem represents a cached item
type cacheItem struct {
value []byte
expiresAt time.Time
}
// NewInMemoryCache creates a new in-memory cache
func NewInMemoryCache() *InMemoryCache {
cache := &InMemoryCache{
items: make(map[string]*cacheItem),
}
// Start cleanup goroutine
go cache.cleanup()
return cache
}
// Get retrieves a value from cache
func (c *InMemoryCache) Get(key string) ([]byte, error) {
c.mu.RLock()
defer c.mu.RUnlock()
item, exists := c.items[key]
if !exists {
return nil, ErrCacheMiss
}
if time.Now().After(item.expiresAt) {
return nil, ErrCacheMiss
}
return item.value, nil
}
// Set stores a value in cache with TTL
func (c *InMemoryCache) Set(key string, value []byte, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = &cacheItem{
value: value,
expiresAt: time.Now().Add(ttl),
}
return nil
}
// cleanup removes expired items
func (c *InMemoryCache) cleanup() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}
// ErrCacheMiss is returned when a cache key is not found
var ErrCacheMiss = &CacheError{Message: "cache miss"}
// CacheError represents a cache error
type CacheError struct {
Message string
}
func (e *CacheError) Error() string {
return e.Message
}

View File

@@ -0,0 +1,79 @@
package track1
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInMemoryCache_GetSet(t *testing.T) {
cache := NewInMemoryCache()
key := "test-key"
value := []byte("test-value")
ttl := 5 * time.Minute
// Test Set
err := cache.Set(key, value, ttl)
require.NoError(t, err)
// Test Get
retrieved, err := cache.Get(key)
require.NoError(t, err)
assert.Equal(t, value, retrieved)
}
func TestInMemoryCache_Expiration(t *testing.T) {
cache := NewInMemoryCache()
key := "test-key"
value := []byte("test-value")
ttl := 100 * time.Millisecond
err := cache.Set(key, value, ttl)
require.NoError(t, err)
// Should be available immediately
retrieved, err := cache.Get(key)
require.NoError(t, err)
assert.Equal(t, value, retrieved)
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired
_, err = cache.Get(key)
assert.Error(t, err)
assert.Equal(t, ErrCacheMiss, err)
}
func TestInMemoryCache_Miss(t *testing.T) {
cache := NewInMemoryCache()
_, err := cache.Get("non-existent-key")
assert.Error(t, err)
assert.Equal(t, ErrCacheMiss, err)
}
func TestInMemoryCache_Cleanup(t *testing.T) {
cache := NewInMemoryCache()
// Set multiple keys with short TTL
for i := 0; i < 10; i++ {
key := "test-key-" + string(rune(i))
cache.Set(key, []byte("value"), 50*time.Millisecond)
}
// Wait for expiration
time.Sleep(200 * time.Millisecond)
// All should be expired after cleanup
for i := 0; i < 10; i++ {
key := "test-key-" + string(rune(i))
_, err := cache.Get(key)
assert.Error(t, err)
}
}

View File

@@ -0,0 +1,391 @@
package track1
import (
"encoding/json"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
"time"
)
// Server handles Track 1 endpoints
type Server struct {
rpcGateway *RPCGateway
}
// NewServer creates a new Track 1 server
func NewServer(rpcGateway *RPCGateway) *Server {
return &Server{
rpcGateway: rpcGateway,
}
}
// HandleLatestBlocks handles GET /api/v1/track1/blocks/latest
func (s *Server) HandleLatestBlocks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 10
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
limit = l
}
}
// Get latest block number
blockNumResp, err := s.rpcGateway.GetBlockNumber(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
return
}
blockNumHex, ok := blockNumResp.Result.(string)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block number response")
return
}
// Parse block number
blockNum, err := hexToInt(blockNumHex)
if err != nil {
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
return
}
// Fetch blocks
blocks := []map[string]interface{}{}
for i := 0; i < limit && blockNum-int64(i) >= 0; i++ {
blockNumStr := fmt.Sprintf("0x%x", blockNum-int64(i))
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
if err != nil {
continue // Skip failed blocks
}
blockData, ok := blockResp.Result.(map[string]interface{})
if !ok {
continue
}
// Transform to our format
block := transformBlock(blockData)
blocks = append(blocks, block)
}
response := map[string]interface{}{
"data": blocks,
"pagination": map[string]interface{}{
"page": 1,
"limit": limit,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleLatestTransactions handles GET /api/v1/track1/txs/latest
func (s *Server) HandleLatestTransactions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 10
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
limit = l
}
}
// Get latest block number
blockNumResp, err := s.rpcGateway.GetBlockNumber(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
return
}
blockNumHex, ok := blockNumResp.Result.(string)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block number response")
return
}
blockNum, err := hexToInt(blockNumHex)
if err != nil {
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
return
}
// Fetch transactions from recent blocks
transactions := []map[string]interface{}{}
for i := 0; i < 20 && len(transactions) < limit && blockNum-int64(i) >= 0; i++ {
blockNumStr := fmt.Sprintf("0x%x", blockNum-int64(i))
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, true)
if err != nil {
continue
}
blockData, ok := blockResp.Result.(map[string]interface{})
if !ok {
continue
}
txs, ok := blockData["transactions"].([]interface{})
if !ok {
continue
}
for _, tx := range txs {
if len(transactions) >= limit {
break
}
txData, ok := tx.(map[string]interface{})
if !ok {
continue
}
transactions = append(transactions, transformTransaction(txData))
}
}
response := map[string]interface{}{
"data": transactions,
"pagination": map[string]interface{}{
"page": 1,
"limit": limit,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleBlockDetail handles GET /api/v1/track1/block/:number
func (s *Server) HandleBlockDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/block/")
blockNumStr := fmt.Sprintf("0x%x", parseBlockNumber(path))
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "Block not found")
return
}
blockData, ok := blockResp.Result.(map[string]interface{})
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block response")
return
}
response := map[string]interface{}{
"data": transformBlock(blockData),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleTransactionDetail handles GET /api/v1/track1/tx/:hash
func (s *Server) HandleTransactionDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/tx/")
txHash := path
txResp, err := s.rpcGateway.GetTransactionByHash(r.Context(), txHash)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "Transaction not found")
return
}
txData, ok := txResp.Result.(map[string]interface{})
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid transaction response")
return
}
response := map[string]interface{}{
"data": transformTransaction(txData),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleAddressBalance handles GET /api/v1/track1/address/:addr/balance
func (s *Server) HandleAddressBalance(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/address/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[1] != "balance" {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid path")
return
}
address := parts[0]
balanceResp, err := s.rpcGateway.GetBalance(r.Context(), address, "latest")
if err != nil {
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
return
}
balanceHex, ok := balanceResp.Result.(string)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid balance response")
return
}
balance, err := hexToBigInt(balanceHex)
if err != nil {
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
return
}
response := map[string]interface{}{
"data": map[string]interface{}{
"address": address,
"balance": balance.String(),
"balance_wei": balance.String(),
"balance_ether": weiToEther(balance),
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleBridgeStatus handles GET /api/v1/track1/bridge/status
func (s *Server) HandleBridgeStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
// Return bridge status (simplified - in production, query bridge contracts)
response := map[string]interface{}{
"data": map[string]interface{}{
"status": "operational",
"chains": map[string]interface{}{
"138": map[string]interface{}{
"name": "Defi Oracle Meta Mainnet",
"status": "operational",
"last_sync": time.Now().UTC().Format(time.RFC3339),
},
"1": map[string]interface{}{
"name": "Ethereum Mainnet",
"status": "operational",
"last_sync": time.Now().UTC().Format(time.RFC3339),
},
},
"total_transfers_24h": 150,
"total_volume_24h": "5000000000000000000000",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Helper functions
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"code": code,
"message": message,
},
})
}
func hexToInt(hex string) (int64, error) {
hex = strings.TrimPrefix(hex, "0x")
return strconv.ParseInt(hex, 16, 64)
}
func parseBlockNumber(s string) int64 {
num, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return num
}
func transformBlock(blockData map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"number": parseHexField(blockData["number"]),
"hash": blockData["hash"],
"parent_hash": blockData["parentHash"],
"timestamp": parseHexTimestamp(blockData["timestamp"]),
"transaction_count": countTransactions(blockData["transactions"]),
"gas_used": parseHexField(blockData["gasUsed"]),
"gas_limit": parseHexField(blockData["gasLimit"]),
"miner": blockData["miner"],
}
}
func transformTransaction(txData map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"hash": txData["hash"],
"from": txData["from"],
"to": txData["to"],
"value": txData["value"],
"block_number": parseHexField(txData["blockNumber"]),
"timestamp": parseHexTimestamp(txData["timestamp"]),
}
}
func parseHexField(field interface{}) interface{} {
if str, ok := field.(string); ok {
if num, err := hexToInt(str); err == nil {
return num
}
}
return field
}
func parseHexTimestamp(field interface{}) string {
if str, ok := field.(string); ok {
if num, err := hexToInt(str); err == nil {
return time.Unix(num, 0).Format(time.RFC3339)
}
}
return ""
}
func countTransactions(txs interface{}) int {
if txsList, ok := txs.([]interface{}); ok {
return len(txsList)
}
return 0
}
func hexToBigInt(hex string) (*big.Int, error) {
hex = strings.TrimPrefix(hex, "0x")
bigInt := new(big.Int)
bigInt, ok := bigInt.SetString(hex, 16)
if !ok {
return nil, fmt.Errorf("invalid hex number")
}
return bigInt, nil
}
func weiToEther(wei *big.Int) string {
ether := new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(1e18))
return ether.Text('f', 18)
}

View File

@@ -0,0 +1,83 @@
package track1
import (
"sync"
"time"
)
// InMemoryRateLimiter is a simple in-memory rate limiter
// In production, use Redis for distributed rate limiting
type InMemoryRateLimiter struct {
limits map[string]*limitEntry
mu sync.RWMutex
config RateLimitConfig
}
// RateLimitConfig defines rate limit configuration
type RateLimitConfig struct {
RequestsPerSecond int
RequestsPerMinute int
BurstSize int
}
// limitEntry tracks rate limit state for a key
type limitEntry struct {
count int
resetAt time.Time
lastReset time.Time
}
// NewInMemoryRateLimiter creates a new in-memory rate limiter
func NewInMemoryRateLimiter(config RateLimitConfig) *InMemoryRateLimiter {
return &InMemoryRateLimiter{
limits: make(map[string]*limitEntry),
config: config,
}
}
// Allow checks if a request is allowed for the given key
func (rl *InMemoryRateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
entry, exists := rl.limits[key]
if !exists {
rl.limits[key] = &limitEntry{
count: 1,
resetAt: now.Add(time.Minute),
lastReset: now,
}
return true
}
// Reset if minute has passed
if now.After(entry.resetAt) {
entry.count = 1
entry.resetAt = now.Add(time.Minute)
entry.lastReset = now
return true
}
// Check limits
if entry.count >= rl.config.RequestsPerMinute {
return false
}
entry.count++
return true
}
// Cleanup removes old entries (call periodically)
func (rl *InMemoryRateLimiter) Cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for key, entry := range rl.limits {
if now.After(entry.resetAt.Add(5 * time.Minute)) {
delete(rl.limits, key)
}
}
}

View File

@@ -0,0 +1,87 @@
package track1
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestInMemoryRateLimiter_Allow(t *testing.T) {
config := RateLimitConfig{
RequestsPerSecond: 10,
RequestsPerMinute: 100,
BurstSize: 20,
}
limiter := NewInMemoryRateLimiter(config)
key := "test-key"
// Should allow first 100 requests
for i := 0; i < 100; i++ {
assert.True(t, limiter.Allow(key), "Request %d should be allowed", i)
}
// 101st request should be denied
assert.False(t, limiter.Allow(key), "Request 101 should be denied")
}
func TestInMemoryRateLimiter_Reset(t *testing.T) {
config := RateLimitConfig{
RequestsPerMinute: 10,
}
limiter := NewInMemoryRateLimiter(config)
key := "test-key"
// Exhaust limit
for i := 0; i < 10; i++ {
limiter.Allow(key)
}
assert.False(t, limiter.Allow(key))
// Wait for reset (1 minute)
time.Sleep(61 * time.Second)
// Should allow again after reset
assert.True(t, limiter.Allow(key))
}
func TestInMemoryRateLimiter_DifferentKeys(t *testing.T) {
config := RateLimitConfig{
RequestsPerMinute: 10,
}
limiter := NewInMemoryRateLimiter(config)
key1 := "key1"
key2 := "key2"
// Exhaust limit for key1
for i := 0; i < 10; i++ {
limiter.Allow(key1)
}
assert.False(t, limiter.Allow(key1))
// key2 should still have full limit
for i := 0; i < 10; i++ {
assert.True(t, limiter.Allow(key2), "Request %d for key2 should be allowed", i)
}
}
func TestInMemoryRateLimiter_Cleanup(t *testing.T) {
config := RateLimitConfig{
RequestsPerMinute: 10,
}
limiter := NewInMemoryRateLimiter(config)
key := "test-key"
limiter.Allow(key)
// Cleanup should remove old entries
limiter.Cleanup()
// Entry should still exist if not old enough
// This test verifies cleanup doesn't break functionality
assert.NotNil(t, limiter)
}

View File

@@ -0,0 +1,88 @@
package track1
import (
"context"
"os"
"time"
"github.com/redis/go-redis/v9"
)
// RedisCache is a Redis-based cache implementation
// Use this in production for distributed caching
type RedisCache struct {
client *redis.Client
ctx context.Context
}
// NewRedisCache creates a new Redis cache
func NewRedisCache(redisURL string) (*RedisCache, error) {
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
ctx := context.Background()
// Test connection
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisCache{
client: client,
ctx: ctx,
}, nil
}
// NewRedisCacheFromClient creates a new Redis cache from an existing client
func NewRedisCacheFromClient(client *redis.Client) *RedisCache {
return &RedisCache{
client: client,
ctx: context.Background(),
}
}
// Get retrieves a value from cache
func (c *RedisCache) Get(key string) ([]byte, error) {
val, err := c.client.Get(c.ctx, key).Bytes()
if err == redis.Nil {
return nil, ErrCacheMiss
}
if err != nil {
return nil, err
}
return val, nil
}
// Set stores a value in cache with TTL
func (c *RedisCache) Set(key string, value []byte, ttl time.Duration) error {
return c.client.Set(c.ctx, key, value, ttl).Err()
}
// Delete removes a key from cache
func (c *RedisCache) Delete(key string) error {
return c.client.Del(c.ctx, key).Err()
}
// Clear clears all cache keys (use with caution)
func (c *RedisCache) Clear() error {
return c.client.FlushDB(c.ctx).Err()
}
// Close closes the Redis connection
func (c *RedisCache) Close() error {
return c.client.Close()
}
// NewCache creates a cache based on environment
// Returns Redis cache if REDIS_URL is set, otherwise in-memory cache
func NewCache() (Cache, error) {
redisURL := os.Getenv("REDIS_URL")
if redisURL != "" {
return NewRedisCache(redisURL)
}
return NewInMemoryCache(), nil
}

View File

@@ -0,0 +1,135 @@
package track1
import (
"context"
"os"
"time"
"github.com/redis/go-redis/v9"
)
// RedisRateLimiter is a Redis-based rate limiter implementation
// Use this in production for distributed rate limiting
type RedisRateLimiter struct {
client *redis.Client
ctx context.Context
config RateLimitConfig
}
// NewRedisRateLimiter creates a new Redis rate limiter
func NewRedisRateLimiter(redisURL string, config RateLimitConfig) (*RedisRateLimiter, error) {
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
ctx := context.Background()
// Test connection
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisRateLimiter{
client: client,
ctx: ctx,
config: config,
}, nil
}
// NewRedisRateLimiterFromClient creates a new Redis rate limiter from an existing client
func NewRedisRateLimiterFromClient(client *redis.Client, config RateLimitConfig) *RedisRateLimiter {
return &RedisRateLimiter{
client: client,
ctx: context.Background(),
config: config,
}
}
// Allow checks if a request is allowed for the given key
// Uses sliding window algorithm with Redis
func (rl *RedisRateLimiter) Allow(key string) bool {
now := time.Now()
windowStart := now.Add(-time.Minute)
// Use sorted set to track requests in the current window
zsetKey := "ratelimit:" + key
// Remove old entries (outside the window)
rl.client.ZRemRangeByScore(rl.ctx, zsetKey, "0", formatTime(windowStart))
// Count requests in current window
count, err := rl.client.ZCard(rl.ctx, zsetKey).Result()
if err != nil {
// On error, allow the request (fail open)
return true
}
// Check if limit exceeded
if int(count) >= rl.config.RequestsPerMinute {
return false
}
// Add current request to the window
member := formatTime(now)
score := float64(now.Unix())
rl.client.ZAdd(rl.ctx, zsetKey, redis.Z{
Score: score,
Member: member,
})
// Set expiration on the key (cleanup)
rl.client.Expire(rl.ctx, zsetKey, time.Minute*2)
return true
}
// GetRemaining returns the number of requests remaining in the current window
func (rl *RedisRateLimiter) GetRemaining(key string) int {
now := time.Now()
windowStart := now.Add(-time.Minute)
zsetKey := "ratelimit:" + key
// Remove old entries
rl.client.ZRemRangeByScore(rl.ctx, zsetKey, "0", formatTime(windowStart))
// Count requests in current window
count, err := rl.client.ZCard(rl.ctx, zsetKey).Result()
if err != nil {
return rl.config.RequestsPerMinute
}
remaining := rl.config.RequestsPerMinute - int(count)
if remaining < 0 {
return 0
}
return remaining
}
// Reset resets the rate limit for a key
func (rl *RedisRateLimiter) Reset(key string) error {
zsetKey := "ratelimit:" + key
return rl.client.Del(rl.ctx, zsetKey).Err()
}
// Close closes the Redis connection
func (rl *RedisRateLimiter) Close() error {
return rl.client.Close()
}
// formatTime formats time for Redis sorted set
func formatTime(t time.Time) string {
return t.Format(time.RFC3339Nano)
}
// NewRateLimiter creates a rate limiter based on environment
// Returns Redis rate limiter if REDIS_URL is set, otherwise in-memory rate limiter
func NewRateLimiter(config RateLimitConfig) (RateLimiter, error) {
redisURL := os.Getenv("REDIS_URL")
if redisURL != "" {
return NewRedisRateLimiter(redisURL, config)
}
return NewInMemoryRateLimiter(config), nil
}

View File

@@ -0,0 +1,178 @@
package track1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// RPCGateway handles RPC passthrough with caching
type RPCGateway struct {
rpcURL string
httpClient *http.Client
cache Cache
rateLimit RateLimiter
}
// Cache interface for caching RPC responses
type Cache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, ttl time.Duration) error
}
// RateLimiter interface for rate limiting
type RateLimiter interface {
Allow(key string) bool
}
// NewRPCGateway creates a new RPC gateway
func NewRPCGateway(rpcURL string, cache Cache, rateLimit RateLimiter) *RPCGateway {
return &RPCGateway{
rpcURL: rpcURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
cache: cache,
rateLimit: rateLimit,
}
}
// RPCRequest represents a JSON-RPC request
type RPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params []interface{} `json:"params"`
ID int `json:"id"`
}
// RPCResponse represents a JSON-RPC response
type RPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
ID int `json:"id"`
}
// RPCError represents an RPC error
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Call makes an RPC call with caching and rate limiting
func (g *RPCGateway) Call(ctx context.Context, method string, params []interface{}, cacheKey string, cacheTTL time.Duration) (*RPCResponse, error) {
// Check cache first
if cacheKey != "" {
if cached, err := g.cache.Get(cacheKey); err == nil {
var response RPCResponse
if err := json.Unmarshal(cached, &response); err == nil {
return &response, nil
}
}
}
// Check rate limit
if !g.rateLimit.Allow("rpc") {
return nil, fmt.Errorf("rate limit exceeded")
}
// Make RPC call
req := RPCRequest{
JSONRPC: "2.0",
Method: method,
Params: params,
ID: 1,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", g.rpcURL, bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := g.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("RPC call failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("RPC returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var rpcResp RPCResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC error: %s (code: %d)", rpcResp.Error.Message, rpcResp.Error.Code)
}
// Cache response if cache key provided
if cacheKey != "" && rpcResp.Result != nil {
if cacheData, err := json.Marshal(rpcResp); err == nil {
g.cache.Set(cacheKey, cacheData, cacheTTL)
}
}
return &rpcResp, nil
}
// GetBlockByNumber gets a block by number
func (g *RPCGateway) GetBlockByNumber(ctx context.Context, blockNumber string, includeTxs bool) (*RPCResponse, error) {
cacheKey := fmt.Sprintf("block:%s:%v", blockNumber, includeTxs)
return g.Call(ctx, "eth_getBlockByNumber", []interface{}{blockNumber, includeTxs}, cacheKey, 10*time.Second)
}
// GetBlockByHash gets a block by hash
func (g *RPCGateway) GetBlockByHash(ctx context.Context, blockHash string, includeTxs bool) (*RPCResponse, error) {
cacheKey := fmt.Sprintf("block_hash:%s:%v", blockHash, includeTxs)
return g.Call(ctx, "eth_getBlockByHash", []interface{}{blockHash, includeTxs}, cacheKey, 10*time.Second)
}
// GetTransactionByHash gets a transaction by hash
func (g *RPCGateway) GetTransactionByHash(ctx context.Context, txHash string) (*RPCResponse, error) {
cacheKey := fmt.Sprintf("tx:%s", txHash)
return g.Call(ctx, "eth_getTransactionByHash", []interface{}{txHash}, cacheKey, 30*time.Second)
}
// GetBalance gets an address balance
func (g *RPCGateway) GetBalance(ctx context.Context, address string, blockNumber string) (*RPCResponse, error) {
if blockNumber == "" {
blockNumber = "latest"
}
cacheKey := fmt.Sprintf("balance:%s:%s", address, blockNumber)
return g.Call(ctx, "eth_getBalance", []interface{}{address, blockNumber}, cacheKey, 10*time.Second)
}
// GetBlockNumber gets the latest block number
func (g *RPCGateway) GetBlockNumber(ctx context.Context) (*RPCResponse, error) {
return g.Call(ctx, "eth_blockNumber", []interface{}{}, "block_number", 5*time.Second)
}
// GetTransactionCount gets transaction count for an address
func (g *RPCGateway) GetTransactionCount(ctx context.Context, address string, blockNumber string) (*RPCResponse, error) {
if blockNumber == "" {
blockNumber = "latest"
}
cacheKey := fmt.Sprintf("tx_count:%s:%s", address, blockNumber)
return g.Call(ctx, "eth_getTransactionCount", []interface{}{address, blockNumber}, cacheKey, 10*time.Second)
}