Skip to content

Commit

Permalink
introduced dynamic fee calculator
Browse files Browse the repository at this point in the history
  • Loading branch information
abi87 committed Jul 15, 2024
1 parent f6c3a49 commit 270ab49
Show file tree
Hide file tree
Showing 10 changed files with 1,722 additions and 116 deletions.
119 changes: 119 additions & 0 deletions vms/components/fee/calculator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package fee

import (
"errors"
"fmt"

safemath "github.com/ava-labs/avalanchego/utils/math"
)

var errGasBoundBreached = errors.New("gas bound breached")

// Calculator performs fee-related operations that are shared among P-chain and X-chain
// Calculator is supposed to be embedded within chain specific calculators.
type Calculator struct {
// feeWeights help consolidating complexity into gas
feeWeights Dimensions

// gas cap enforced with adding gas via AddFeesFor
gasCap Gas

// Avax denominated gas price, i.e. fee per unit of gas.
gasPrice GasPrice

// cumulatedGas helps aggregating the gas consumed in a single block
// so that we can verify it's not too big/build it properly.
cumulatedGas Gas

// latestTxComplexity tracks complexity of latest tx being processed.
// latestTxComplexity is especially helpful while building a tx.
latestTxComplexity Dimensions
}

func NewCalculator(feeWeights Dimensions, gasPrice GasPrice, gasCap Gas) *Calculator {
return &Calculator{
feeWeights: feeWeights,
gasCap: gasCap,
gasPrice: gasPrice,
}
}

func (c *Calculator) GetGasPrice() GasPrice {
return c.gasPrice
}

func (c *Calculator) GetBlockGas() (Gas, error) {
txGas, err := ToGas(c.feeWeights, c.latestTxComplexity)
if err != nil {
return ZeroGas, err
}
return c.cumulatedGas + txGas, nil
}

func (c *Calculator) GetGasCap() Gas {
return c.gasCap
}

// AddFeesFor updates latest tx complexity. It should be called once when tx is being verified
// and may be called multiple times when tx is being built (and tx components are added in time).
// AddFeesFor checks that gas cap is not breached. It also returns the updated tx fee for convenience.
func (c *Calculator) AddFeesFor(complexity Dimensions) (uint64, error) {
if complexity == Empty {
return c.GetLatestTxFee()
}

// Ensure we can consume (don't want partial update of values)
uc, err := Add(c.latestTxComplexity, complexity)
if err != nil {
return 0, fmt.Errorf("%w: %w", errGasBoundBreached, err)
}
c.latestTxComplexity = uc

totalGas, err := c.GetBlockGas()
if err != nil {
return 0, fmt.Errorf("%w: %w", errGasBoundBreached, err)
}
if totalGas > c.gasCap {
return 0, fmt.Errorf("%w: %w", errGasBoundBreached, err)
}

return c.GetLatestTxFee()
}

// Sometimes, e.g. while building a tx, we'd like freedom to speculatively add complexity
// and to remove it later on. [RemoveFeesFor] grants this freedom
func (c *Calculator) RemoveFeesFor(complexity Dimensions) (uint64, error) {
if complexity == Empty {
return c.GetLatestTxFee()
}

rc, err := Remove(c.latestTxComplexity, complexity)
if err != nil {
return 0, fmt.Errorf("%w: current Gas %d, gas to revert %d", err, c.cumulatedGas, complexity)
}
c.latestTxComplexity = rc
return c.GetLatestTxFee()
}

// DoneWithLatestTx should be invoked one a tx has been fully processed, before moving to the next one
func (c *Calculator) DoneWithLatestTx() error {
txGas, err := ToGas(c.feeWeights, c.latestTxComplexity)
if err != nil {
return err
}
c.cumulatedGas += txGas
c.latestTxComplexity = Empty
return nil
}

// CalculateFee must be a stateless method
func (c *Calculator) GetLatestTxFee() (uint64, error) {
gas, err := ToGas(c.feeWeights, c.latestTxComplexity)
if err != nil {
return 0, err
}
return safemath.Mul64(uint64(c.gasPrice), uint64(gas))
}
125 changes: 125 additions & 0 deletions vms/components/fee/calculator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package fee

import (
"math"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/ava-labs/avalanchego/utils/units"

safemath "github.com/ava-labs/avalanchego/utils/math"
)

var (
testDynamicFeeCfg = DynamicFeesConfig{
GasPrice: GasPrice(10 * units.NanoAvax),
FeeDimensionWeights: Dimensions{6, 10, 10, 1},
}
testGasCap = Gas(math.MaxUint64)
)

func TestAddAndRemoveFees(t *testing.T) {
require := require.New(t)

var (
fc = NewCalculator(testDynamicFeeCfg.FeeDimensionWeights, testDynamicFeeCfg.GasPrice, testGasCap)

complexity = Dimensions{1, 2, 3, 4}
extraComplexity = Dimensions{2, 3, 4, 5}
overComplexity = Dimensions{math.MaxUint64, math.MaxUint64, math.MaxUint64, math.MaxUint64}
)

fee0, err := fc.GetLatestTxFee()
require.NoError(err)
require.Zero(fee0)
require.NoError(fc.DoneWithLatestTx())
gas, err := fc.GetBlockGas()
require.NoError(err)
require.Zero(gas)

fee1, err := fc.AddFeesFor(complexity)
require.NoError(err)
require.Greater(fee1, fee0)
gas, err = fc.GetBlockGas()
require.NoError(err)
require.NotZero(gas)

// complexity can't overflow
_, err = fc.AddFeesFor(overComplexity)
require.ErrorIs(err, safemath.ErrOverflow)
gas, err = fc.GetBlockGas()
require.NoError(err)
require.NotZero(gas)

// can't remove more complexity than it was added
_, err = fc.RemoveFeesFor(extraComplexity)
require.ErrorIs(err, safemath.ErrUnderflow)
gas, err = fc.GetBlockGas()
require.NoError(err)
require.NotZero(gas)

rFee, err := fc.RemoveFeesFor(complexity)
require.NoError(err)
require.Equal(rFee, fee0)
gas, err = fc.GetBlockGas()
require.NoError(err)
require.Zero(gas)
}

func TestGasCap(t *testing.T) {
require := require.New(t)

var (
now = time.Now().Truncate(time.Second)
parentBlkTime = now
// childBlkTime = parentBlkTime.Add(time.Second)
// grandChildBlkTime = childBlkTime.Add(5 * time.Second)

cfg = DynamicFeesConfig{
MaxGasPerSecond: Gas(1_000),
LeakGasCoeff: Gas(5),
}

currCap = cfg.MaxGasPerSecond
)

// A block whose gas matches cap, will consume full available cap
blkGas := cfg.MaxGasPerSecond
currCap = UpdateGasCap(currCap, blkGas)
require.Equal(ZeroGas, currCap)

// capacity grows linearly in time till MaxGas
for i := 1; i <= 5; i++ {
childBlkTime := parentBlkTime.Add(time.Duration(i) * time.Second)
nextCap, err := GasCap(cfg, currCap, parentBlkTime, childBlkTime)
require.NoError(err)
require.Equal(Gas(i)*cfg.MaxGasPerSecond/cfg.LeakGasCoeff, nextCap)
}

// capacity won't grow beyond MaxGas
childBlkTime := parentBlkTime.Add(time.Duration(6) * time.Second)
nextCap, err := GasCap(cfg, currCap, parentBlkTime, childBlkTime)
require.NoError(err)
require.Equal(cfg.MaxGasPerSecond, nextCap)

// Arrival of a block will reduce GasCap of block Gas content
blkGas = cfg.MaxGasPerSecond / 4
currCap = UpdateGasCap(nextCap, blkGas)
require.Equal(3*cfg.MaxGasPerSecond/4, currCap)

// capacity keeps growing again in time after block
childBlkTime = parentBlkTime.Add(time.Second)
nextCap, err = GasCap(cfg, currCap, parentBlkTime, childBlkTime)
require.NoError(err)
require.Equal(currCap+cfg.MaxGasPerSecond/cfg.LeakGasCoeff, nextCap)

// time can only grow forward with capacity
childBlkTime = parentBlkTime.Add(-1 * time.Second)
_, err = GasCap(cfg, currCap, parentBlkTime, childBlkTime)
require.ErrorIs(err, errUnexpectedBlockTimes)
}
60 changes: 60 additions & 0 deletions vms/components/fee/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package fee

import (
"errors"
"fmt"
"time"
)

var (
errZeroLeakGasCoeff = errors.New("zero leak gas coefficient")
errUnexpectedBlockTimes = errors.New("unexpected block times")
)

type DynamicFeesConfig struct {
// At this state this is the fixed gas price applied to each block
// In the next PRs, gas price will float and this will become the
// minimum gas price
GasPrice GasPrice `json:"gas-price"`

// weights to merge fees dimensions complexities into a single gas value
FeeDimensionWeights Dimensions `json:"fee-dimension-weights"`

// Leaky bucket parameters to calculate gas cap
MaxGasPerSecond Gas // techically the unit of measure is Gas/sec, but picking Gas reduces casts needed
LeakGasCoeff Gas // techically the unit of measure is sec^{-1}, but picking Gas reduces casts needed
}

func (c *DynamicFeesConfig) Validate() error {
if c.LeakGasCoeff == 0 {
return errZeroLeakGasCoeff
}

return nil
}

// We cap the maximum gas consumed by time with a leaky bucket approach
// GasCap = min (GasCap + MaxGasPerSecond/LeakGasCoeff*ElapsedTime, MaxGasPerSecond)
func GasCap(cfg DynamicFeesConfig, currentGasCapacity Gas, parentBlkTime, childBlkTime time.Time) (Gas, error) {
if parentBlkTime.Compare(childBlkTime) > 0 {
return ZeroGas, fmt.Errorf("%w, parentBlkTim %v, childBlkTime %v", errUnexpectedBlockTimes, parentBlkTime, childBlkTime)
}

elapsedTime := uint64(childBlkTime.Unix() - parentBlkTime.Unix())
if elapsedTime > uint64(cfg.LeakGasCoeff) {
return cfg.MaxGasPerSecond, nil
}

return min(cfg.MaxGasPerSecond, currentGasCapacity+cfg.MaxGasPerSecond*Gas(elapsedTime)/cfg.LeakGasCoeff), nil
}

func UpdateGasCap(currentGasCap, blkGas Gas) Gas {
nextGasCap := Gas(0)
if currentGasCap > blkGas {
nextGasCap = currentGasCap - blkGas
}
return nextGasCap
}
70 changes: 70 additions & 0 deletions vms/components/fee/dimensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package fee

import (
safemath "github.com/ava-labs/avalanchego/utils/math"
)

const (
Bandwidth Dimension = 0
DBRead Dimension = 1
DBWrite Dimension = 2 // includes deletes
Compute Dimension = 3

FeeDimensions = 4
)

var (
ZeroGas = Gas(0)
ZeroGasPrice = GasPrice(0)
Empty = Dimensions{}
)

type (
GasPrice uint64
Gas uint64

Dimension int
Dimensions [FeeDimensions]uint64
)

func Add(lhs, rhs Dimensions) (Dimensions, error) {
var res Dimensions
for i := 0; i < FeeDimensions; i++ {
v, err := safemath.Add64(lhs[i], rhs[i])
if err != nil {
return res, err
}
res[i] = v
}
return res, nil
}

func Remove(lhs, rhs Dimensions) (Dimensions, error) {
var res Dimensions
for i := 0; i < FeeDimensions; i++ {
v, err := safemath.Sub(lhs[i], rhs[i])
if err != nil {
return res, err
}
res[i] = v
}
return res, nil
}

func ToGas(weights, dimensions Dimensions) (Gas, error) {
res := uint64(0)
for i := 0; i < FeeDimensions; i++ {
v, err := safemath.Mul64(weights[i], dimensions[i])
if err != nil {
return ZeroGas, err
}
res, err = safemath.Add64(res, v)
if err != nil {
return ZeroGas, err
}
}
return Gas(res) / 10, nil
}
Loading

0 comments on commit 270ab49

Please sign in to comment.