Skip to content

Commit

Permalink
Add Sonic's base-fee computation algorithm
Browse files Browse the repository at this point in the history
  • Loading branch information
HerbertJordan committed Nov 11, 2024
1 parent 809f264 commit 9d825ab
Show file tree
Hide file tree
Showing 4 changed files with 363 additions and 0 deletions.
1 change: 1 addition & 0 deletions evmcore/dummy_block.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type (
Root common.Hash
TxHash common.Hash
Time inter.Timestamp
Duration inter.Duration // time since the last block
Coinbase common.Address

GasLimit uint64
Expand Down
98 changes: 98 additions & 0 deletions gossip/gasprice/base_fee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package gasprice

import (
"math/big"

"github.com/Fantom-foundation/go-opera/evmcore"
"github.com/Fantom-foundation/go-opera/opera"
)

// GetInitialBaseFee returns the initial base fee to be used in the genesis block.
func GetInitialBaseFee() *big.Int {
// The initial base fee is set to 1 Gwei. While a value of 0 would also be valid,
// this value was chosen to have non-zero prices in low-load test networks at least
// for the first several minutes. In case of no load on the network, the base fee
// will decrease to 0 within ~35 minutes.
const kInitialBaseFee = 1e9
return big.NewInt(kInitialBaseFee)
}

// GetBaseFeeForNextBlock computes the base fee for the next block based on the parent block.
func GetBaseFeeForNextBlock(parent *evmcore.EvmHeader, rules opera.EconomyRules) *big.Int {
// In general, this function computes the new base fee based on the following formula:
//
// newPrice := oldPrice * e^(((rate-targetRate/targetRate)*duration)/128)
//
// where:
// - oldPrice is the base fee of the parent block
// - rate is the gas rate per second observed in the parent block
// - targetRate is the target gas rate per second at which prices are stable
// - duration is the time in seconds between the parent and grand-parent blocks
//
// All computations are carried out using integers to avoid floating point errors.
// To that end, terms are re-arranged to fit the following shape:
//
// newPrice := oldPrice * e^(numerator/denominator)
//
// where numerator and denominator are integers. The final value is then computed
// using an approximation of the this function based on a Taylor expansion around 0.

oldPrice := new(big.Int).Set(parent.BaseFee)

// If the time gap between the parent and this block is more than
// 60 seconds, something significantly disturbed the chain and we
// keep the BaseFee constant.
duration := parent.Duration
if duration == 0 || duration > 60*1e9 {
return oldPrice
}

// If the target rate is zero, the new price is not defined.
targetRate := big.NewInt(int64(rules.ShortGasPower.AllocPerSec / 2))
if targetRate.Sign() == 0 {
return oldPrice
}

nanosPerSecond := big.NewInt(1e9)
usedGas := big.NewInt(int64(parent.GasUsed))

durationInNanos := big.NewInt(int64(duration)) // 63-bit is enough for a duration of 292 years

numerator := sub(mul(usedGas, nanosPerSecond), mul(targetRate, durationInNanos))
denominator := mul(big.NewInt(128), mul(targetRate, nanosPerSecond))

newPrice := approximateExponential(oldPrice, numerator, denominator)

// If the gas rate is higher than the target, increase the price by at least 1 wei.
// This is to ensure that the price is always increasing, even if the old price was 0.
if oldPrice.Cmp(newPrice) == 0 && numerator.Sign() > 0 {
newPrice.Add(newPrice, big.NewInt(1))
}

return newPrice
}

// approximateExponential approximates f * e ** (n/d) using
// Taylor expansion at a=0:
// f * e^(n/d) = f + af/b + a^2f/b^2/2! + a^3f/b^3/3! + ...
func approximateExponential(factor, numerator, denominator *big.Int) *big.Int {
var (
res = new(big.Int)
acc = new(big.Int).Mul(factor, denominator)
)
for i := 1; acc.Sign() != 0; i++ {
res.Add(res, acc)
acc.Mul(acc, numerator)
acc.Div(acc, denominator)
acc.Div(acc, big.NewInt(int64(i)))
}
return res.Div(res, denominator)
}

func sub(a, b *big.Int) *big.Int {
return new(big.Int).Sub(a, b)
}

func mul(a, b *big.Int) *big.Int {
return new(big.Int).Mul(a, b)
}
258 changes: 258 additions & 0 deletions gossip/gasprice/base_fee_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package gasprice

import (
"fmt"
"math"
"math/big"
"math/rand"
"testing"
"time"

"github.com/Fantom-foundation/go-opera/evmcore"
"github.com/Fantom-foundation/go-opera/inter"
"github.com/Fantom-foundation/go-opera/opera"
)

func TestBaseFee_ExamplePriceAdjustments(t *testing.T) {

approxExp := func(f, n, d int64) uint64 {
return uint64(float64(f) * math.Exp(float64(n)/float64(d)))
}

tests := map[string]struct {
parentBaseFee uint64
parentGasUsed uint64
parentDuration time.Duration
targetRate uint64
wantBaseFee uint64
}{
"base fee remains the same": {
parentBaseFee: 1e8,
parentGasUsed: 1e6,
parentDuration: 1 * time.Second,
targetRate: 1e6,
wantBaseFee: approxExp(1e8, 0, 128), // max change rate per second ~1/128
},
"base fee increases": {
parentBaseFee: 1e8,
parentGasUsed: 2e6,
parentDuration: 1 * time.Second,
targetRate: 1e6,
wantBaseFee: approxExp(1e8, 1, 128),
},
"base fee decreases": {
parentBaseFee: 1e8,
parentGasUsed: 0,
parentDuration: 1 * time.Second,
targetRate: 1e6,
wantBaseFee: approxExp(1e8, -1, 128),
},
"long durations are ignored": {
parentBaseFee: 123456789,
parentGasUsed: 0, // < no gas used, should reduce the price
parentDuration: 61 * time.Second,
targetRate: 1e6, // < since the duration is too long, the price should not change
wantBaseFee: 123456789,
},
"target rate is zero": {
parentBaseFee: 123456789,
parentGasUsed: 0, // < no gas used, should reduce the price
parentDuration: time.Second,
targetRate: 0, // < since the target rate is zero, the price should not change
wantBaseFee: 123456789,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {

header := &evmcore.EvmHeader{
BaseFee: big.NewInt(int64(test.parentBaseFee)),
GasUsed: test.parentGasUsed,
Duration: inter.Duration(test.parentDuration),
}

rules := opera.EconomyRules{
ShortGasPower: opera.GasPowerRules{
AllocPerSec: 2 * test.targetRate,
},
}

gotBaseFee := GetBaseFeeForNextBlock(header, rules)
wantBaseFee := big.NewInt(int64(test.wantBaseFee))
if gotBaseFee.Cmp(wantBaseFee) != 0 {
t.Fatalf("base fee is incorrect; got %v, want %v, diff %d", gotBaseFee, wantBaseFee, sub(gotBaseFee, wantBaseFee))
}

if header.BaseFee == gotBaseFee {
t.Fatalf("new base fee is not a copy; got %p, want %p", header.BaseFee, gotBaseFee)
}
})
}
}

func TestBaseFee_PriceCanRecoverFromPriceZero(t *testing.T) {

target := uint64(1e6)
header := &evmcore.EvmHeader{
BaseFee: big.NewInt(0),
GasUsed: target + 1,
Duration: inter.Duration(1e9), // 1 second
}

rules := opera.EconomyRules{
ShortGasPower: opera.GasPowerRules{
AllocPerSec: 2 * target,
},
}

newPrice := GetBaseFeeForNextBlock(header, rules)
if newPrice.Cmp(big.NewInt(1)) < 0 {
t.Errorf("failed to increase price from zero, new price %v", newPrice)
}
}

func TestBaseFee_DecayTimeFromInitialToZeroIsApproximately35Minutes(t *testing.T) {
rules := opera.EconomyRules{
ShortGasPower: opera.GasPowerRules{
AllocPerSec: 1e6,
},
}

// This property should be true for any block time.
blockTimes := []time.Duration{
100 * time.Millisecond,
500 * time.Millisecond,
1 * time.Second,
2 * time.Second,
5 * time.Second,
}
for _, blockTime := range blockTimes {
t.Run(fmt.Sprintf("blockTime=%s", blockTime.String()), func(t *testing.T) {
header := &evmcore.EvmHeader{
BaseFee: GetInitialBaseFee(),
GasUsed: 0,
Duration: inter.Duration(blockTime),
}
decayDuration := time.Duration(0)
for header.BaseFee.Sign() > 0 {
header.BaseFee = GetBaseFeeForNextBlock(header, rules)
decayDuration += header.Duration.Duration()
}

if decayDuration < 30*time.Minute || decayDuration > 40*time.Minute {
t.Errorf("time to decay from initial to zero is incorrect; got %v", decayDuration)
}
})
}
}

func TestApproximateExponential_KnownValues(t *testing.T) {
tests := map[string]struct {
factor int64
numerator int64
denominator int64
want int64
}{
"e^0": {
factor: 1,
numerator: 0,
denominator: 1,
want: 1,
},
"e^1": {
factor: 1,
numerator: 1,
denominator: 1,
want: 2,
},
"e^2": {
factor: 1,
numerator: 2,
denominator: 1,
want: 6, // < should be 7, but the function just approximates
},
"e^(1/2)": {
factor: 1,
numerator: 1,
denominator: 2,
want: 1,
},
"e^-1": {
factor: 1,
numerator: -1,
denominator: 1,
want: 0,
},
"10*e^2": {
factor: 10,
numerator: 2,
denominator: 1,
want: 71, // < should be 73, but the function just approximates
},
"100*e^(1/2)": {
factor: 100,
numerator: 1,
denominator: 2,
want: 164,
},
"100*e^(-1/2)": {
factor: 100,
numerator: -1,
denominator: 2,
want: 60,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
gotBaseFee := approximateExponential(
big.NewInt(int64(test.factor)),
big.NewInt(test.numerator),
big.NewInt(test.denominator),
)
wantBaseFee := big.NewInt(int64(test.want))
if gotBaseFee.Cmp(wantBaseFee) != 0 {
t.Fatalf("base fee is incorrect; got %v, want %v", gotBaseFee, wantBaseFee)
}
})
}
}

func TestApproximateExponential_RandomInputs(t *testing.T) {
r := rand.New(rand.NewSource(0))
for range 100 {
factor := int64(r.Int31n(100))

// Our practical use cases for gas computations are fractions in the range [-1,1]
denominator := int64(r.Int31n(1e9) + 1)
numerator := int64((r.Float64()*2 - 1) * float64(denominator))

want := big.NewInt(int64(float64(factor) * math.Exp(float64(numerator)/float64(denominator))))
got := approximateExponential(big.NewInt(factor), big.NewInt(numerator), big.NewInt(denominator))

diff := new(big.Int).Abs(sub(got, want))
if diff.Cmp(big.NewInt(1)) > 0 {
t.Errorf(
"incorrect approximation for f=%d, n=%d, d=%d; got %v, want %v, error %v",
factor, numerator, denominator, got, want, diff,
)
}
}
}

func BenchmarkBaseFeeComputation(b *testing.B) {
header := &evmcore.EvmHeader{
BaseFee: big.NewInt(1e9),
GasUsed: 1e6,
Duration: inter.Duration(1e9),
}
rules := opera.EconomyRules{
ShortGasPower: opera.GasPowerRules{
AllocPerSec: 1e6,
},
}
for range b.N {
GetBaseFeeForNextBlock(header, rules)
}
}
6 changes: 6 additions & 0 deletions inter/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
type (
// Timestamp is a UNIX nanoseconds timestamp
Timestamp uint64
// Duration is a UNIX nanoseconds duration
Duration uint64
)

// Bytes gets the byte representation of the index.
Expand Down Expand Up @@ -43,3 +45,7 @@ func MaxTimestamp(x, y Timestamp) Timestamp {
}
return y
}

func (d Duration) Duration() time.Duration {
return time.Duration(d)
}

0 comments on commit 9d825ab

Please sign in to comment.