Skip to content

Commit

Permalink
block history estimator
Browse files Browse the repository at this point in the history
  • Loading branch information
aalu1418 committed May 24, 2024
1 parent bd0c027 commit 1a46fe4
Show file tree
Hide file tree
Showing 12 changed files with 119,680 additions and 678 deletions.
27 changes: 26 additions & 1 deletion pkg/solana/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Reader interface {
LatestBlockhash() (*rpc.GetLatestBlockhashResult, error)
ChainID() (string, error)
GetFeeForMessage(msg string) (uint64, error)
GetLatestBlock() (*rpc.GetBlockResult, error)
}

// AccountReader is an interface that allows users to pass either the solana rpc client or the relay client
Expand Down Expand Up @@ -89,10 +90,14 @@ func (c *Client) Balance(addr solana.PublicKey) (uint64, error) {
}

func (c *Client) SlotHeight() (uint64, error) {
return c.SlotHeightWithCommitment(rpc.CommitmentProcessed) // get the latest slot height
}

func (c *Client) SlotHeightWithCommitment(commitment rpc.CommitmentType) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), c.contextDuration)
defer cancel()
v, err, _ := c.requestGroup.Do("GetSlotHeight", func() (interface{}, error) {
return c.rpc.GetSlot(ctx, rpc.CommitmentProcessed) // get the latest slot height
return c.rpc.GetSlot(ctx, commitment)
})
return v.(uint64), err
}
Expand Down Expand Up @@ -210,3 +215,23 @@ func (c *Client) SendTx(ctx context.Context, tx *solana.Transaction) (solana.Sig

return c.rpc.SendTransactionWithOpts(ctx, tx, opts)
}

func (c *Client) GetLatestBlock() (*rpc.GetBlockResult, error) {
// get latest confirmed slot
slot, err := c.SlotHeightWithCommitment(c.commitment)
if err != nil {
return nil, fmt.Errorf("GetLatestBlock.SlotHeight: %w", err)
}

// get block based on slot
ctx, cancel := context.WithTimeout(context.Background(), c.txTimeout)
defer cancel()
v, err, _ := c.requestGroup.Do("GetBlockWithOpts", func() (interface{}, error) {
version := uint64(0) // pull all tx types (legacy + v0)
return c.rpc.GetBlockWithOpts(ctx, slot, &rpc.GetBlockOpts{
Commitment: c.commitment,
MaxSupportedTransactionVersion: &version,
})
})
return v.(*rpc.GetBlockResult), err
}
7 changes: 7 additions & 0 deletions pkg/solana/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ func TestClient_Reader_Integration(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, uint64(1), res.Value.Lamports)
assert.Equal(t, "NativeLoader1111111111111111111111111111111", res.Value.Owner.String())

// get block + check for nonzero values
block, err := c.GetLatestBlock()
require.NoError(t, err)
assert.NotEqual(t, solana.Hash{}, block.Blockhash)
assert.NotEqual(t, uint64(0), block.ParentSlot)
assert.NotEqual(t, uint64(0), block.ParentSlot)
}

func TestClient_Reader_ChainID(t *testing.T) {
Expand Down
26 changes: 26 additions & 0 deletions pkg/solana/client/mocks/ReaderWriter.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 141 additions & 0 deletions pkg/solana/fees/block_history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package fees

import (
"context"
"fmt"
"sync"
"time"

"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/services"
"github.com/smartcontractkit/chainlink-common/pkg/utils"
"github.com/smartcontractkit/chainlink-common/pkg/utils/mathutil"

"github.com/smartcontractkit/chainlink-solana/pkg/solana/client"
"github.com/smartcontractkit/chainlink-solana/pkg/solana/config"
)

var (
feePolling = 5 * time.Second // TODO: make configurable
)

var _ Estimator = &blockHistoryEstimator{}

type blockHistoryEstimator struct {
starter services.StateMachine
chStop chan struct{}
done sync.WaitGroup

client *utils.LazyLoad[client.ReaderWriter]
cfg config.Config
lgr logger.Logger

price uint64
lock sync.RWMutex
}

// NewBlockHistoryEstimator creates a new fee estimator that parses historical fees from a fetched block
// Note: getRecentPrioritizationFees is not used because it provides the lowest prioritization fee for an included tx in the block
// which is not effective enough for increasing the chances of block inclusion
func NewBlockHistoryEstimator(c *utils.LazyLoad[client.ReaderWriter], cfg config.Config, lgr logger.Logger) (*blockHistoryEstimator, error) {
return &blockHistoryEstimator{
chStop: make(chan struct{}),
client: c,
cfg: cfg,
lgr: lgr,
price: cfg.ComputeUnitPriceDefault(), // use default value
}, nil
}

func (bhe *blockHistoryEstimator) Start(ctx context.Context) error {
return bhe.starter.StartOnce("solana_blockHistoryEstimator", func() error {
bhe.done.Add(1)
go bhe.run()
bhe.lgr.Debugw("BlockHistoryEstimator: started")
return nil
})
}

func (bhe *blockHistoryEstimator) run() {
defer bhe.done.Done()

tick := time.After(0)
for {
select {
case <-bhe.chStop:
return
case <-tick:
if err := bhe.calculatePrice(); err != nil {
bhe.lgr.Error(fmt.Errorf("BlockHistoryEstimator failed to fetch price: %w", err))
}
}

tick = time.After(utils.WithJitter(feePolling))
}
}

func (bhe *blockHistoryEstimator) Close() error {
close(bhe.chStop)
bhe.done.Wait()
bhe.lgr.Debugw("BlockHistoryEstimator: stopped")
return nil
}

func (bhe *blockHistoryEstimator) BaseComputeUnitPrice() uint64 {
price := bhe.readRawPrice()
if price >= bhe.cfg.ComputeUnitPriceMin() && price <= bhe.cfg.ComputeUnitPriceMax() {
return price
}

if price < bhe.cfg.ComputeUnitPriceMin() {
bhe.lgr.Warnw("BlockHistoryEstimator: estimation below minimum consider lowering ComputeUnitPriceMin", "min", bhe.cfg.ComputeUnitPriceMin(), "calculated", price)
return bhe.cfg.ComputeUnitPriceMin()
}

bhe.lgr.Warnw("BlockHistoryEstimator: estimation above maximum consider increasing ComputeUnitPriceMax", "min", bhe.cfg.ComputeUnitPriceMax(), "calculated", price)
return bhe.cfg.ComputeUnitPriceMax()
}

func (bhe *blockHistoryEstimator) readRawPrice() uint64 {
bhe.lock.RLock()
defer bhe.lock.RUnlock()
return bhe.price
}

func (bhe *blockHistoryEstimator) calculatePrice() error {
// fetch client
c, err := bhe.client.Get()
if err != nil {
return fmt.Errorf("failed to get client in blockHistoryEstimator.getFee: %w", err)
}

// get latest block based on configured confirmation
block, err := c.GetLatestBlock()
if err != nil {
return fmt.Errorf("failed to get block in blockHistoryEstimator.getFee: %w", err)
}

// parse block for fee data
feeData, err := ParseBlock(block)
if err != nil {
return fmt.Errorf("failed to parse block in blockHistoryEstimator.getFee: %w", err)
}

// take median of returned fee values
v, err := mathutil.Median(feeData.Prices...)
if err != nil {
return fmt.Errorf("failed to find median in blockHistoryEstimator.getFee: %w", err)
}

// set data
bhe.lock.Lock()
bhe.price = uint64(v) // ComputeUnitPrice is uint64 underneath
bhe.lock.Unlock()
bhe.lgr.Debugw("BlockHistoryEstimator: updated",
"computeUnitPrice", v,
"blockhash", block.Blockhash,
"slot", block.ParentSlot+1,
"count", len(feeData.Prices),
)
return nil
}
95 changes: 95 additions & 0 deletions pkg/solana/fees/block_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package fees

import (
"encoding/json"
"fmt"
"io/ioutil"
"testing"
"time"

"github.com/gagliardetto/solana-go/rpc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"

"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/utils"
"github.com/smartcontractkit/chainlink-common/pkg/utils/tests"

"github.com/smartcontractkit/chainlink-solana/pkg/solana/client"
clientmock "github.com/smartcontractkit/chainlink-solana/pkg/solana/client/mocks"
cfgmock "github.com/smartcontractkit/chainlink-solana/pkg/solana/config/mocks"
)

func TestBlockHistoryEstimator(t *testing.T) {
feePolling = 100 * time.Millisecond // TODO: make this part of cfg mock
min := uint64(10)
max := uint64(1000)

rw := clientmock.NewReaderWriter(t)
rwLoader := utils.NewLazyLoad(func() (client.ReaderWriter, error) {
return rw, nil
})
cfg := cfgmock.NewConfig(t)
cfg.On("ComputeUnitPriceDefault").Return(uint64(100))
cfg.On("ComputeUnitPriceMin").Return(min)
cfg.On("ComputeUnitPriceMax").Return(max)
lgr, logs := logger.TestObserved(t, zapcore.DebugLevel)
ctx := tests.Context(t)

// file contains legacy + v0 transactions
testBlockData, err := ioutil.ReadFile("./blockdata.json")
require.NoError(t, err)
blockRes := &rpc.GetBlockResult{}
require.NoError(t, json.Unmarshal(testBlockData, blockRes))

// happy path
estimator, err := NewBlockHistoryEstimator(rwLoader, cfg, lgr)
require.NoError(t, err)

rw.On("GetLatestBlock").Return(blockRes, nil).Once()
require.NoError(t, estimator.Start(ctx))
tests.AssertLogEventually(t, logs, "BlockHistoryEstimator: updated")
assert.Equal(t, uint64(55000), estimator.readRawPrice())

// min/max gates
assert.Equal(t, max, estimator.BaseComputeUnitPrice())
estimator.price = 0
assert.Equal(t, min, estimator.BaseComputeUnitPrice())
validPrice := uint64(100)
estimator.price = validPrice
assert.Equal(t, estimator.readRawPrice(), estimator.BaseComputeUnitPrice())

// failed to get latest block
rw.On("GetLatestBlock").Return(nil, fmt.Errorf("fail rpc call")).Once()
tests.AssertLogEventually(t, logs, "failed to get block")
assert.Equal(t, validPrice, estimator.BaseComputeUnitPrice(), "price should not change when getPrice fails")

// failed to parse block
rw.On("GetLatestBlock").Return(nil, nil).Once()
tests.AssertLogEventually(t, logs, "failed to parse block")
assert.Equal(t, validPrice, estimator.BaseComputeUnitPrice(), "price should not change when getPrice fails")

// failed to calculate median
rw.On("GetLatestBlock").Return(&rpc.GetBlockResult{}, nil).Once()
tests.AssertLogEventually(t, logs, "failed to find median")
assert.Equal(t, validPrice, estimator.BaseComputeUnitPrice(), "price should not change when getPrice fails")

// back to happy path
rw.On("GetLatestBlock").Return(blockRes, nil).Once()
tests.AssertEventually(t, func() bool {
return logs.FilterMessageSnippet("BlockHistoryEstimator: updated").Len() == 2
})
assert.Equal(t, uint64(55000), estimator.readRawPrice())
require.NoError(t, estimator.Close())

// failed to get client
rwFail := utils.NewLazyLoad(func() (client.ReaderWriter, error) {
return nil, fmt.Errorf("fail client load")
})
estimator, err = NewBlockHistoryEstimator(rwFail, cfg, lgr)
require.NoError(t, err)
require.NoError(t, estimator.Start(ctx))
tests.AssertLogEventually(t, logs, "failed to get client")
require.NoError(t, estimator.Close())
}
Loading

0 comments on commit 1a46fe4

Please sign in to comment.