Commit 93a3d5a5 authored by Mark Tyneway's avatar Mark Tyneway

go: implement gasprices package

The `gasprices` package implements the logic that updates
L2 gasprices on the Optimistic Ethereum Network.
Co-authored-by: default avatarKarl Floersch <karl@karlfloersch.com>
parent 99e3adba
package gasprices
import (
"errors"
"sync"
"github.com/ethereum/go-ethereum/log"
)
type GetLatestBlockNumberFn func() (uint64, error)
type UpdateL2GasPriceFn func(uint64) error
type GasPriceUpdater struct {
mu *sync.RWMutex
gasPricer *GasPricer
epochStartBlockNumber uint64
averageBlockGasLimit float64
epochLengthSeconds uint64
getLatestBlockNumberFn GetLatestBlockNumberFn
updateL2GasPriceFn UpdateL2GasPriceFn
}
func GetAverageGasPerSecond(
epochStartBlockNumber uint64,
latestBlockNumber uint64,
epochLengthSeconds uint64,
averageBlockGasLimit uint64,
) float64 {
blocksPassed := latestBlockNumber - epochStartBlockNumber
return float64(blocksPassed * averageBlockGasLimit / epochLengthSeconds)
}
func NewGasPriceUpdater(
gasPricer *GasPricer,
epochStartBlockNumber uint64,
averageBlockGasLimit float64,
epochLengthSeconds uint64,
getLatestBlockNumberFn GetLatestBlockNumberFn,
updateL2GasPriceFn UpdateL2GasPriceFn,
) (*GasPriceUpdater, error) {
if averageBlockGasLimit < 1 {
return nil, errors.New("averageBlockGasLimit cannot be less than 1 gas")
}
if epochLengthSeconds < 1 {
return nil, errors.New("epochLengthSeconds cannot be less than 1 second")
}
return &GasPriceUpdater{
mu: new(sync.RWMutex),
gasPricer: gasPricer,
epochStartBlockNumber: epochStartBlockNumber,
epochLengthSeconds: epochLengthSeconds,
averageBlockGasLimit: averageBlockGasLimit,
getLatestBlockNumberFn: getLatestBlockNumberFn,
updateL2GasPriceFn: updateL2GasPriceFn,
}, nil
}
func (g *GasPriceUpdater) UpdateGasPrice() error {
g.mu.Lock()
defer g.mu.Unlock()
latestBlockNumber, err := g.getLatestBlockNumberFn()
if err != nil {
return err
}
if latestBlockNumber < uint64(g.epochStartBlockNumber) {
return errors.New("Latest block number less than the last epoch's block number")
}
averageGasPerSecond := GetAverageGasPerSecond(
g.epochStartBlockNumber,
latestBlockNumber,
uint64(g.epochLengthSeconds),
uint64(g.averageBlockGasLimit),
)
log.Debug("UpdateGasPrice", "averageGasPerSecond", averageGasPerSecond, "current-price", g.gasPricer.curPrice)
_, err = g.gasPricer.CompleteEpoch(averageGasPerSecond)
if err != nil {
return err
}
g.epochStartBlockNumber = latestBlockNumber
err = g.updateL2GasPriceFn(g.gasPricer.curPrice)
if err != nil {
return err
}
return nil
}
func (g *GasPriceUpdater) GetGasPrice() uint64 {
g.mu.RLock()
defer g.mu.RUnlock()
return g.gasPricer.curPrice
}
package gasprices
import (
"testing"
)
type MockEpoch struct {
numBlocks uint64
repeatCount uint64
postHook func(prevGasPrice uint64, gasPriceUpdater *GasPriceUpdater)
}
func TestGetAverageGasPerSecond(t *testing.T) {
// Let's sanity check this function with some simple inputs.
// A 10 block epoch
epochStartBlockNumber := 10
latestBlockNumber := 20
// That lasts 10 seconds (1 block per second)
epochLengthSeconds := 10
// And each block has a gas limit of 1
averageBlockGasLimit := 1
// We expect a gas per second to be 1!
expectedGps := 1.0
gps := GetAverageGasPerSecond(uint64(epochStartBlockNumber), uint64(latestBlockNumber), uint64(epochLengthSeconds), uint64(averageBlockGasLimit))
if gps != expectedGps {
t.Fatalf("Gas per second not calculated correctly. Got: %v expected: %v", gps, expectedGps)
}
}
// Return a gas pricer that targets 3 blocks per epoch & 10% max change per epoch.
func makeTestGasPricerAndUpdater(curPrice uint64) (*GasPricer, *GasPriceUpdater, func(uint64), error) {
gpsTarget := 3300000.0
getGasTarget := func() float64 { return gpsTarget }
epochLengthSeconds := uint64(10)
averageBlockGasLimit := 11000000.0
// Based on our 10 second epoch, we are targetting 3 blocks per epoch.
gasPricer, err := NewGasPricer(curPrice, 1, getGasTarget, 10)
if err != nil {
return nil, nil, nil, err
}
curBlock := uint64(10)
incrementCurrentBlock := func(newBlockNum uint64) { curBlock += newBlockNum }
getLatestBlockNumber := func() (uint64, error) { return curBlock, nil }
updateL2GasPrice := func(x uint64) error {
return nil
}
startBlock, _ := getLatestBlockNumber()
gasUpdater, err := NewGasPriceUpdater(
gasPricer,
startBlock,
averageBlockGasLimit,
epochLengthSeconds,
getLatestBlockNumber,
updateL2GasPrice,
)
if err != nil {
return nil, nil, nil, err
}
return gasPricer, gasUpdater, incrementCurrentBlock, nil
}
func TestUpdateGasPriceCallsUpdateL2GasPriceFn(t *testing.T) {
_, gasUpdater, incrementCurrentBlock, err := makeTestGasPricerAndUpdater(1)
if err != nil {
t.Fatal(err)
}
wasCalled := false
gasUpdater.updateL2GasPriceFn = func(gasPrice uint64) error {
wasCalled = true
return nil
}
incrementCurrentBlock(3)
if err := gasUpdater.UpdateGasPrice(); err != nil {
t.Fatal(err)
}
if wasCalled != true {
t.Fatalf("Expected updateL2GasPrice to be called.")
}
}
func TestUpdateGasPriceCorrectlyUpdatesAZeroBlockEpoch(t *testing.T) {
gasPricer, gasUpdater, _, err := makeTestGasPricerAndUpdater(100)
if err != nil {
t.Fatal(err)
}
gasPriceBefore := gasPricer.curPrice
gasPriceAfter := gasPricer.curPrice
gasUpdater.updateL2GasPriceFn = func(gasPrice uint64) error {
gasPriceAfter = gasPrice
return nil
}
if err := gasUpdater.UpdateGasPrice(); err != nil {
t.Fatal(err)
}
if gasPriceBefore < gasPriceAfter {
t.Fatalf("Expected gasPrice to go down because we had fewer than 3 blocks in the epoch.")
}
}
func TestUpdateGasPriceFailsIfBlockNumberGoesBackwards(t *testing.T) {
_, gasUpdater, _, err := makeTestGasPricerAndUpdater(1)
if err != nil {
t.Fatal(err)
}
gasUpdater.epochStartBlockNumber = 10
gasUpdater.getLatestBlockNumberFn = func() (uint64, error) { return 0, nil }
err = gasUpdater.UpdateGasPrice()
if err == nil {
t.Fatalf("Expected UpdateGasPrice to fail when block number goes backwards.")
}
}
func TestUsageOfGasPriceUpdater(t *testing.T) {
_, gasUpdater, incrementCurrentBlock, err := makeTestGasPricerAndUpdater(1000)
if err != nil {
t.Fatal(err)
}
// In these mock epochs the gas price shold go up and then down again after the time has passed
mockEpochs := []MockEpoch{
// First jack up the price to show that it will grow over time
MockEpoch{
numBlocks: 10,
repeatCount: 3,
// Make sure the gas price is increasing
postHook: func(prevGasPrice uint64, gasPriceUpdater *GasPriceUpdater) {
curPrice := gasPriceUpdater.gasPricer.curPrice
if prevGasPrice >= curPrice {
t.Fatalf("Expected gas price to increase.")
}
},
},
// Then stabilize around the GPS we want
MockEpoch{
numBlocks: 3,
repeatCount: 5,
postHook: func(prevGasPrice uint64, gasPriceUpdater *GasPriceUpdater) {},
},
MockEpoch{
numBlocks: 3,
repeatCount: 0,
postHook: func(prevGasPrice uint64, gasPriceUpdater *GasPriceUpdater) {
curPrice := gasPriceUpdater.gasPricer.curPrice
if prevGasPrice != curPrice {
t.Fatalf("Expected gas price to stablize.")
}
},
},
// Then reduce the demand to show the fee goes back down to the floor
MockEpoch{
numBlocks: 1,
repeatCount: 5,
postHook: func(prevGasPrice uint64, gasPriceUpdater *GasPriceUpdater) {
curPrice := gasPriceUpdater.gasPricer.curPrice
if prevGasPrice <= curPrice && curPrice != gasPriceUpdater.gasPricer.floorPrice {
t.Fatalf("Expected gas price either reduce or be at the floor.")
}
},
},
}
loop := func(epoch MockEpoch) {
prevGasPrice := gasUpdater.gasPricer.curPrice
incrementCurrentBlock(epoch.numBlocks)
err = gasUpdater.UpdateGasPrice()
if err != nil {
t.Fatal(err)
}
epoch.postHook(prevGasPrice, gasUpdater)
}
for _, epoch := range mockEpochs {
for i := 0; i < int(epoch.repeatCount)+1; i++ {
loop(epoch)
}
}
}
package gasprices
import (
"errors"
"fmt"
"math"
"github.com/ethereum/go-ethereum/log"
)
type GetTargetGasPerSecond func() float64
type GasPricer struct {
curPrice uint64
floorPrice uint64
getTargetGasPerSecond GetTargetGasPerSecond
maxChangePerEpoch float64
}
// LinearInterpolation can be used to dynamically update target gas per second
func GetLinearInterpolationFn(getX func() float64, x1 float64, x2 float64, y1 float64, y2 float64) func() float64 {
return func() float64 {
return y1 + ((getX()-x1)/(x2-x1))*(y2-y1)
}
}
// NewGasPricer creates a GasPricer and checks its config beforehand
func NewGasPricer(curPrice, floorPrice uint64, getTargetGasPerSecond GetTargetGasPerSecond, maxPercentChangePerEpoch float64) (*GasPricer, error) {
if floorPrice < 1 {
return nil, errors.New("floorPrice must be greater than or equal to 1")
}
if maxPercentChangePerEpoch <= 0 {
return nil, errors.New("maxPercentChangePerEpoch must be between (0,100]")
}
return &GasPricer{
curPrice: max(curPrice, floorPrice),
floorPrice: floorPrice,
getTargetGasPerSecond: getTargetGasPerSecond,
maxChangePerEpoch: maxPercentChangePerEpoch / 100,
}, nil
}
// CalcNextEpochGasPrice calculates the next gas price given some average
// gas per second over the last epoch
func (p *GasPricer) CalcNextEpochGasPrice(avgGasPerSecondLastEpoch float64) (uint64, error) {
targetGasPerSecond := p.getTargetGasPerSecond()
if avgGasPerSecondLastEpoch < 0 {
return 0.0, fmt.Errorf("avgGasPerSecondLastEpoch cannot be negative, got %f", avgGasPerSecondLastEpoch)
}
if targetGasPerSecond < 1 {
return 0.0, fmt.Errorf("gasPerSecond cannot be less than 1, got %f", targetGasPerSecond)
}
// The percent difference between our current average gas & our target gas
proportionOfTarget := avgGasPerSecondLastEpoch / targetGasPerSecond
// The percent that we should adjust the gas price to reach our target gas
proportionToChangeBy := 0.0
if proportionOfTarget >= 1 { // If average avgGasPerSecondLastEpoch is GREATER than our target
proportionToChangeBy = math.Min(proportionOfTarget, 1+p.maxChangePerEpoch)
} else {
proportionToChangeBy = math.Max(proportionOfTarget, 1-p.maxChangePerEpoch)
}
log.Debug("CalcNextEpochGasPrice", "proportionToChangeBy", proportionToChangeBy, "proportionOfTarget", proportionOfTarget)
updated := float64(max(1, p.curPrice)) * proportionToChangeBy
return max(p.floorPrice, uint64(math.Ceil(updated))), nil
}
// CompleteEpoch ends the current epoch and updates the current gas price for the next epoch
func (p *GasPricer) CompleteEpoch(avgGasPerSecondLastEpoch float64) (uint64, error) {
gp, err := p.CalcNextEpochGasPrice(avgGasPerSecondLastEpoch)
if err != nil {
return gp, err
}
p.curPrice = gp
return gp, nil
}
func max(a, b uint64) uint64 {
if a >= b {
return a
}
return b
}
package gasprices
import (
"math"
"testing"
)
type CalcGasPriceTestCase struct {
name string
avgGasPerSecondLastEpoch float64
expectedNextGasPrice uint64
}
func returnConstFn(retVal uint64) func() float64 {
return func() float64 { return float64(retVal) }
}
func runCalcGasPriceTests(gp GasPricer, tcs []CalcGasPriceTestCase, t *testing.T) {
for _, tc := range tcs {
nextEpochGasPrice, err := gp.CalcNextEpochGasPrice(tc.avgGasPerSecondLastEpoch)
if tc.expectedNextGasPrice != nextEpochGasPrice || err != nil {
t.Fatalf("failed on test: %s", tc.name)
}
}
}
func TestCalcGasPriceFarFromFloor(t *testing.T) {
gp := GasPricer{
curPrice: 100,
floorPrice: 1,
getTargetGasPerSecond: returnConstFn(10),
maxChangePerEpoch: 0.5,
}
tcs := []CalcGasPriceTestCase{
// No change
{
name: "No change expected when already at target",
avgGasPerSecondLastEpoch: 10,
expectedNextGasPrice: 100,
},
// Price reduction
{
name: "Max % change bounds the reduction in price",
avgGasPerSecondLastEpoch: 1,
expectedNextGasPrice: 50,
},
{
// We're half of our target, so reduce by half
name: "Reduce fee by half if at 50% capacity",
avgGasPerSecondLastEpoch: 5,
expectedNextGasPrice: 50,
},
{
name: "Reduce fee by 75% if at 75% capacity",
avgGasPerSecondLastEpoch: 7.5,
expectedNextGasPrice: 75,
},
// Price increase
{
name: "Max % change bounds the increase in price",
avgGasPerSecondLastEpoch: 100,
expectedNextGasPrice: 150,
},
{
name: "Increase fee by 25% if at 125% capacity",
avgGasPerSecondLastEpoch: 12.5,
expectedNextGasPrice: 125,
},
}
runCalcGasPriceTests(gp, tcs, t)
}
func TestCalcGasPriceAtFloor(t *testing.T) {
gp := GasPricer{
curPrice: 100,
floorPrice: 100,
getTargetGasPerSecond: returnConstFn(10),
maxChangePerEpoch: 0.5,
}
tcs := []CalcGasPriceTestCase{
// No change
{
name: "No change expected when already at target",
avgGasPerSecondLastEpoch: 10,
expectedNextGasPrice: 100,
},
// Price reduction
{
name: "No change expected when at floorPrice",
avgGasPerSecondLastEpoch: 1,
expectedNextGasPrice: 100,
},
// Price increase
{
name: "Max % change bounds the increase in price",
avgGasPerSecondLastEpoch: 100,
expectedNextGasPrice: 150,
},
}
runCalcGasPriceTests(gp, tcs, t)
}
func TestGasPricerUpdates(t *testing.T) {
gp := GasPricer{
curPrice: 100,
floorPrice: 100,
getTargetGasPerSecond: returnConstFn(10),
maxChangePerEpoch: 0.5,
}
_, err := gp.CompleteEpoch(12.5)
if err != nil {
t.Fatal(err)
}
if gp.curPrice != 125 {
t.Fatalf("gp.curPrice not updated correctly. Got: %v, expected: %v", gp.curPrice, 125)
}
}
func TestGetLinearInterpolationFn(t *testing.T) {
mockTimestamp := float64(0) // start at timestamp 0
// TargetGasPerSecond is dynamic based on the current "mocktimestamp"
mockTimeNow := func() float64 {
return mockTimestamp
}
l := GetLinearInterpolationFn(mockTimeNow, 0, 10, 0, 100)
for expected := 0.0; expected < 100; expected += 10 {
mockTimestamp = expected / 10 // To prove this is not identity function, divide by 10
got := l()
if got != expected {
t.Fatalf("linear interpolation incorrect. Got: %v expected: %v", got, expected)
}
}
}
func TestGasPricerDynamicTarget(t *testing.T) {
// In prod we will be committing to a gas per second schedule in order to
// meter usage over time. This linear interpolation between a start time, end time,
// start gas per second, and end gas per second is an example of how we can introduce
// acceleration in our gas pricer
startTimestamp := float64(0)
startGasPerSecond := float64(10)
endTimestamp := float64(100)
endGasPerSecond := float64(100)
mockTimestamp := float64(0) // start at timestamp 0
// TargetGasPerSecond is dynamic based on the current "mocktimestamp"
mockTimeNow := func() float64 {
return mockTimestamp
}
// TargetGasPerSecond is dynamic based on the current "mocktimestamp"
dynamicGetTarget := GetLinearInterpolationFn(mockTimeNow, startTimestamp, endTimestamp, startGasPerSecond, endGasPerSecond)
gp := GasPricer{
curPrice: 100,
floorPrice: 1,
getTargetGasPerSecond: dynamicGetTarget,
maxChangePerEpoch: 0.5,
}
gasPerSecondDemanded := returnConstFn(15)
for i := 0; i < 10; i++ {
mockTimestamp = float64(i * 10)
expectedPrice := math.Ceil(float64(gp.curPrice) * math.Max(0.5, gasPerSecondDemanded()/dynamicGetTarget()))
_, err := gp.CompleteEpoch(gasPerSecondDemanded())
if err != nil {
t.Fatal(err)
}
if gp.curPrice != uint64(expectedPrice) {
t.Fatalf("gp.curPrice not updated correctly. Got: %v expected: %v", gp.curPrice, expectedPrice)
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment