diff --git a/chainstore/cache.go b/chainstore/cache.go index 2d94ccd9f..368baeb08 100644 --- a/chainstore/cache.go +++ b/chainstore/cache.go @@ -2,6 +2,7 @@ package chainstore import ( "math/big" + "strconv" "sync" "time" ) @@ -13,6 +14,39 @@ type CachedValue[T any] struct { ttl time.Duration } +// feeHistoryCacheKey identifies an eth_feeHistory request. The percentile +// slice is serialized so the key does not retain caller-owned mutable memory. +type feeHistoryCacheKey struct { + blockCount int + newestBlock string + rewardPercentiles string +} + +func newFeeHistoryCacheKey(blockCount int, newestBlock string, rewardPercentiles []float64) feeHistoryCacheKey { + return feeHistoryCacheKey{ + blockCount: blockCount, + newestBlock: newestBlock, + rewardPercentiles: serializeRewardPercentiles(rewardPercentiles), + } +} + +func serializeRewardPercentiles(rewardPercentiles []float64) string { + if rewardPercentiles == nil { + return "null" + } + + serialized := make([]byte, 0, len(rewardPercentiles)*8+2) + serialized = append(serialized, '[') + for i, percentile := range rewardPercentiles { + if i > 0 { + serialized = append(serialized, ',') + } + serialized = strconv.AppendFloat(serialized, percentile, 'g', -1, 64) + } + serialized = append(serialized, ']') + return string(serialized) +} + // NewCachedValue creates a new cached value func NewCachedValue[T any](value T, ttl time.Duration) *CachedValue[T] { return &CachedValue[T]{ @@ -57,8 +91,8 @@ type ChainCache struct { baseFeeBlock *big.Int // Frequent data (30-60 second TTL) - gasPrice *CachedValue[*big.Int] - feeHistory *CachedValue[*FeeHistoryResult] + gasPrice *CachedValue[*big.Int] + feeHistories map[feeHistoryCacheKey]*CachedValue[*FeeHistoryResult] // Very frequent data (5-10 second TTL) pendingTxCount *CachedValue[*big.Int] @@ -71,7 +105,8 @@ type ChainCache struct { // NewChainCache creates a new chain cache func NewChainCache() *ChainCache { return &ChainCache{ - signatures: make(map[string]*CachedValue[[]Signature]), + feeHistories: make(map[feeHistoryCacheKey]*CachedValue[*FeeHistoryResult]), + signatures: make(map[string]*CachedValue[[]Signature]), } } @@ -200,20 +235,38 @@ func (cc *ChainCache) SetGasPrice(gasPrice *big.Int, ttl time.Duration) { } // GetFeeHistory gets cached fee history -func (cc *ChainCache) GetFeeHistory(ttl time.Duration) (*FeeHistoryResult, bool) { - cc.mu.RLock() - defer cc.mu.RUnlock() - if cc.feeHistory == nil { +func (cc *ChainCache) GetFeeHistory(blockCount int, newestBlock string, rewardPercentiles []float64) (*FeeHistoryResult, bool) { + key := newFeeHistoryCacheKey(blockCount, newestBlock, rewardPercentiles) + + cc.mu.Lock() + defer cc.mu.Unlock() + + cached, exists := cc.feeHistories[key] + if !exists { return nil, false } - return cc.feeHistory.Get() + + feeHistory, valid := cached.Get() + if !valid { + delete(cc.feeHistories, key) + return nil, false + } + return feeHistory, true } // SetFeeHistory caches fee history -func (cc *ChainCache) SetFeeHistory(feeHistory *FeeHistoryResult, ttl time.Duration) { +func (cc *ChainCache) SetFeeHistory(blockCount int, newestBlock string, rewardPercentiles []float64, feeHistory *FeeHistoryResult, ttl time.Duration) { + key := newFeeHistoryCacheKey(blockCount, newestBlock, rewardPercentiles) + cc.mu.Lock() defer cc.mu.Unlock() - cc.feeHistory = NewCachedValue(feeHistory, ttl) + + for cachedKey, cached := range cc.feeHistories { + if !cached.IsValid() { + delete(cc.feeHistories, cachedKey) + } + } + cc.feeHistories[key] = NewCachedValue(feeHistory, ttl) } // GetPendingTxCount gets cached pending transaction count diff --git a/chainstore/fee_history_test.go b/chainstore/fee_history_test.go new file mode 100644 index 000000000..be8746787 --- /dev/null +++ b/chainstore/fee_history_test.go @@ -0,0 +1,183 @@ +package chainstore + +import ( + "context" + "math/big" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type feeHistoryRPCCall struct { + blockCount string + newestBlock string + rewardPercentiles []float64 +} + +type feeHistoryRPCService struct { + mu sync.Mutex + calls []feeHistoryRPCCall +} + +func (s *feeHistoryRPCService) FeeHistory(_ context.Context, blockCount string, newestBlock string, rewardPercentiles []float64) (*FeeHistoryResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.calls = append(s.calls, feeHistoryRPCCall{ + blockCount: blockCount, + newestBlock: newestBlock, + rewardPercentiles: append([]float64(nil), rewardPercentiles...), + }) + callNumber := int64(len(s.calls)) + + return &FeeHistoryResult{ + OldestBlock: big.NewInt(callNumber), + BaseFeePerGas: []*big.Int{big.NewInt(callNumber)}, + GasUsedRatio: []float64{float64(callNumber)}, + }, nil +} + +func (s *feeHistoryRPCService) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.calls) +} + +func newFeeHistoryTestStore(t *testing.T, ttl time.Duration) (*PassthroughStore, *feeHistoryRPCService) { + t.Helper() + + server := rpc.NewServer() + service := &feeHistoryRPCService{} + require.NoError(t, server.RegisterName("eth", service)) + + client := rpc.DialInProc(server) + capabilities := NewCapabilityManager(client, time.Hour) + capabilities.capabilities["eth_feeHistory"] = true + capabilities.lastChecked = time.Now() + + store := &PassthroughStore{ + client: client, + cache: NewChainCache(), + capabilities: capabilities, + config: &ChainStoreConfig{ + FrequentTTL: ttl, + }, + } + + t.Cleanup(func() { + client.Close() + server.Stop() + }) + + return store, service +} + +func TestGetFeeHistoryCachesIdenticalRequests(t *testing.T) { + store, service := newFeeHistoryTestStore(t, time.Minute) + percentiles := []float64{10, 50, 90} + + first, err := store.GetFeeHistory(t.Context(), 10, "latest", percentiles) + require.NoError(t, err) + + percentiles[0] = 25 + second, err := store.GetFeeHistory(t.Context(), 10, "latest", []float64{10, 50, 90}) + require.NoError(t, err) + + require.Equal(t, 1, service.callCount()) + require.Equal(t, first, second) +} + +func TestGetFeeHistorySeparatesRequestsByParameters(t *testing.T) { + tests := []struct { + name string + firstBlockCount int + firstNewestBlock string + firstRewardPercentiles []float64 + secondBlockCount int + secondNewestBlock string + secondRewardPercentiles []float64 + }{ + { + name: "block count", + firstBlockCount: 1, + firstNewestBlock: "latest", + firstRewardPercentiles: []float64{10, 50, 90}, + secondBlockCount: 100, + secondNewestBlock: "latest", + secondRewardPercentiles: []float64{10, 50, 90}, + }, + { + name: "newest block", + firstBlockCount: 10, + firstNewestBlock: "latest", + firstRewardPercentiles: []float64{10, 50, 90}, + secondBlockCount: 10, + secondNewestBlock: "0x1234", + secondRewardPercentiles: []float64{10, 50, 90}, + }, + { + name: "reward percentiles", + firstBlockCount: 10, + firstNewestBlock: "latest", + firstRewardPercentiles: []float64{10, 50, 90}, + secondBlockCount: 10, + secondNewestBlock: "latest", + secondRewardPercentiles: []float64{25, 50, 75}, + }, + { + name: "nil and empty reward percentiles", + firstBlockCount: 10, + firstNewestBlock: "latest", + firstRewardPercentiles: nil, + secondBlockCount: 10, + secondNewestBlock: "latest", + secondRewardPercentiles: []float64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, service := newFeeHistoryTestStore(t, time.Minute) + + first, err := store.GetFeeHistory(t.Context(), tt.firstBlockCount, tt.firstNewestBlock, tt.firstRewardPercentiles) + require.NoError(t, err) + second, err := store.GetFeeHistory(t.Context(), tt.secondBlockCount, tt.secondNewestBlock, tt.secondRewardPercentiles) + require.NoError(t, err) + + require.Equal(t, 2, service.callCount()) + require.NotEqual(t, first.OldestBlock, second.OldestBlock) + }) + } +} + +func TestGetFeeHistoryRefetchesAfterTTLExpires(t *testing.T) { + const ttl = 10 * time.Millisecond + store, service := newFeeHistoryTestStore(t, ttl) + + first, err := store.GetFeeHistory(t.Context(), 10, "latest", []float64{10, 50, 90}) + require.NoError(t, err) + + time.Sleep(2 * ttl) + + second, err := store.GetFeeHistory(t.Context(), 10, "latest", []float64{10, 50, 90}) + require.NoError(t, err) + + require.Equal(t, 2, service.callCount()) + require.NotEqual(t, first.OldestBlock, second.OldestBlock) +} + +func TestSetFeeHistoryPrunesExpiredEntries(t *testing.T) { + cache := NewChainCache() + cache.SetFeeHistory(1, "latest", nil, &FeeHistoryResult{}, time.Nanosecond) + + require.Eventually(t, func() bool { + _, valid := cache.GetFeeHistory(1, "latest", nil) + return !valid + }, time.Second, time.Millisecond) + + cache.SetFeeHistory(2, "latest", nil, &FeeHistoryResult{}, time.Minute) + require.Len(t, cache.feeHistories, 1) +} diff --git a/chainstore/passthrough_store.go b/chainstore/passthrough_store.go index d77321afb..59d4b4649 100644 --- a/chainstore/passthrough_store.go +++ b/chainstore/passthrough_store.go @@ -362,8 +362,8 @@ func (s *PassthroughStore) GetGasPrice(ctx context.Context) (*big.Int, error) { // GetFeeHistory retrieves fee history (cached frequently) func (s *PassthroughStore) GetFeeHistory(ctx context.Context, blockCount int, newestBlock string, rewardPercentiles []float64) (*FeeHistoryResult, error) { - // Check cache first (simple cache key for now) - if feeHistory, valid := s.cache.GetFeeHistory(s.config.FrequentTTL); valid { + // Check cache first + if feeHistory, valid := s.cache.GetFeeHistory(blockCount, newestBlock, rewardPercentiles); valid { return feeHistory, nil } @@ -378,7 +378,7 @@ func (s *PassthroughStore) GetFeeHistory(ctx context.Context, blockCount int, ne } // Cache the result - s.cache.SetFeeHistory(&result, s.config.FrequentTTL) + s.cache.SetFeeHistory(blockCount, newestBlock, rewardPercentiles, &result, s.config.FrequentTTL) return &result, nil }