Commit c612a903 authored by Mark Tyneway's avatar Mark Tyneway

l2geth: sequencer fee buffer

The fees are currently calculated as a sum of the L1 fee and the L2 fee
where the L1 fee is the approximate cost of the batch submission of the
transaction (L1 gas price * L1 gas used) and the L2 fee is the
approximate cost of the execution on L2 taking into account congestion
(L2 gas price * L2 gas limit).

When either the L1 or L2 gas price is volatile, it can result in the
quote that the user receives from `eth_estimateGas` to be rejected
as the fee that the Sequencer is expecting has gone up.

This PR adds logic to set a buffer in either direction of the current
price that will allow the sequencer to still accept transactions within.

Two new config options are added:

- `--rollup.feethresholddown` or `ROLLUP_FEE_THRESHOLD_DOWN`
- `--rollup.feethresholdup` or `ROLLUP_FEE_THRESHOLD_UP`

Note that these config options are only useful for when running
in Sequencer mode, they are not useful for replicas/verifiers.
This is because the Sequencer is the only write node in the network.

These config options are interpreted as floating point numbers and are
multiplied against the current fee that the sequencer is expecting.
To allow for a downward buffer of 10% and an upward buffer of 100%,
use the options:

- `ROLLUP_FEE_THRESHOLD_DOWN=0.9`
- `ROLLUP_FEE_THRESHOLD_UP=2`

This will allow for slight fee volatility downwards and prevent users
from excessively overpaying on fees accidentally.

This is a UX and profit tradeoff for the sequencer and can be exploited
by bots. If bots are consistently taking advantage of this, the max
threshold down will have to be calibrated to what the normal fee is
today.

Both config options are sanity checked in the `SyncService` constructor
and will result in errors if they are bad. The threshold down must
be less than 1 and the threshold up must be greater than 1.
parent 4c776d16
---
'@eth-optimism/l2geth': patch
---
Add sequencer fee buffer with config options `ROLLUP_FEE_THRESHOLD_UP` and `ROLLUP_FEE_THRESHOLD_DOWN` that are interpreted as floating point numbers
...@@ -166,6 +166,8 @@ var ( ...@@ -166,6 +166,8 @@ var (
utils.RollupMaxCalldataSizeFlag, utils.RollupMaxCalldataSizeFlag,
utils.RollupBackendFlag, utils.RollupBackendFlag,
utils.RollupEnforceFeesFlag, utils.RollupEnforceFeesFlag,
utils.RollupFeeThresholdDownFlag,
utils.RollupFeeThresholdUpFlag,
utils.GasPriceOracleOwnerAddress, utils.GasPriceOracleOwnerAddress,
} }
......
...@@ -81,6 +81,8 @@ var AppHelpFlagGroups = []flagGroup{ ...@@ -81,6 +81,8 @@ var AppHelpFlagGroups = []flagGroup{
utils.RollupMaxCalldataSizeFlag, utils.RollupMaxCalldataSizeFlag,
utils.RollupBackendFlag, utils.RollupBackendFlag,
utils.RollupEnforceFeesFlag, utils.RollupEnforceFeesFlag,
utils.RollupFeeThresholdDownFlag,
utils.RollupFeeThresholdUpFlag,
utils.GasPriceOracleOwnerAddress, utils.GasPriceOracleOwnerAddress,
}, },
}, },
......
...@@ -898,6 +898,16 @@ var ( ...@@ -898,6 +898,16 @@ var (
Usage: "Disable transactions with 0 gas price", Usage: "Disable transactions with 0 gas price",
EnvVar: "ROLLUP_ENFORCE_FEES", EnvVar: "ROLLUP_ENFORCE_FEES",
} }
RollupFeeThresholdDownFlag = cli.BoolFlag{
Name: "rollup.feethresholddown",
Usage: "Allow txs with fees below the current fee up to this amount, must be < 1",
EnvVar: "ROLLUP_FEE_THRESHOLD_DOWN",
}
RollupFeeThresholdUpFlag = cli.BoolFlag{
Name: "rollup.feethresholdup",
Usage: "Allow txs with fees above the current fee up to this amount, must be > 1",
EnvVar: "ROLLUP_FEE_THRESHOLD_UP",
}
GasPriceOracleOwnerAddress = cli.StringFlag{ GasPriceOracleOwnerAddress = cli.StringFlag{
Name: "rollup.gaspriceoracleowneraddress", Name: "rollup.gaspriceoracleowneraddress",
Usage: "Owner of the OVM_GasPriceOracle", Usage: "Owner of the OVM_GasPriceOracle",
...@@ -1196,6 +1206,14 @@ func setRollup(ctx *cli.Context, cfg *rollup.Config) { ...@@ -1196,6 +1206,14 @@ func setRollup(ctx *cli.Context, cfg *rollup.Config) {
if ctx.GlobalIsSet(RollupEnforceFeesFlag.Name) { if ctx.GlobalIsSet(RollupEnforceFeesFlag.Name) {
cfg.EnforceFees = true cfg.EnforceFees = true
} }
if ctx.GlobalIsSet(RollupFeeThresholdDownFlag.Name) {
val := ctx.GlobalFloat64(RollupFeeThresholdDownFlag.Name)
cfg.FeeThresholdDown = new(big.Float).SetFloat64(val)
}
if ctx.GlobalIsSet(RollupFeeThresholdUpFlag.Name) {
val := ctx.GlobalFloat64(RollupFeeThresholdUpFlag.Name)
cfg.FeeThresholdUp = new(big.Float).SetFloat64(val)
}
} }
// setLes configures the les server and ultra light client settings from the command line flags. // setLes configures the les server and ultra light client settings from the command line flags.
......
...@@ -39,4 +39,9 @@ type Config struct { ...@@ -39,4 +39,9 @@ type Config struct {
Backend Backend Backend Backend
// Only accept transactions with fees // Only accept transactions with fees
EnforceFees bool EnforceFees bool
// Allow fees within a buffer upwards or downwards
// to take fee volatility into account between being
// quoted and the transaction being executed
FeeThresholdDown *big.Float
FeeThresholdUp *big.Float
} }
package fees package fees
import ( import (
"errors"
"fmt"
"math/big" "math/big"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/params"
) )
var (
// errFeeTooLow represents the error case of then the user pays too little
ErrFeeTooLow = errors.New("fee too low")
// errFeeTooHigh represents the error case of when the user pays too much
ErrFeeTooHigh = errors.New("fee too high")
// errMissingInput represents the error case of missing required input to
// PaysEnough
errMissingInput = errors.New("missing input")
)
// overhead represents the fixed cost of batch submission of a single // overhead represents the fixed cost of batch submission of a single
// transaction in gas. // transaction in gas.
const overhead uint64 = 4200 + 200*params.TxDataNonZeroGasEIP2028 const overhead uint64 = 4200 + 200*params.TxDataNonZeroGasEIP2028
...@@ -87,6 +99,52 @@ func DecodeL2GasLimitU64(gasLimit uint64) uint64 { ...@@ -87,6 +99,52 @@ func DecodeL2GasLimitU64(gasLimit uint64) uint64 {
return scaled * tenThousand return scaled * tenThousand
} }
// PaysEnoughOpts represent the options to PaysEnough
type PaysEnoughOpts struct {
UserFee, ExpectedFee *big.Int
ThresholdUp, ThresholdDown *big.Float
}
// PaysEnough returns an error if the fee is not large enough
// `GasPrice` and `Fee` are required arguments.
func PaysEnough(opts *PaysEnoughOpts) error {
if opts.UserFee == nil {
return fmt.Errorf("%w: no user fee", errMissingInput)
}
if opts.ExpectedFee == nil {
return fmt.Errorf("%w: no expected fee", errMissingInput)
}
fee := opts.ExpectedFee
// Allow for a downward buffer to protect against L1 gas price volatility
if opts.ThresholdDown != nil {
fee = mulByFloat(fee, opts.ThresholdDown)
}
// Protect the sequencer from being underpaid
// if user fee < expected fee, return error
if opts.UserFee.Cmp(fee) == -1 {
return ErrFeeTooLow
}
// Protect users from overpaying by too much
if opts.ThresholdUp != nil {
// overpaying = user fee - expected fee
overpaying := new(big.Int).Sub(opts.UserFee, opts.ExpectedFee)
threshold := mulByFloat(overpaying, opts.ThresholdUp)
// if overpaying > threshold, return error
if overpaying.Cmp(threshold) == 1 {
return ErrFeeTooHigh
}
}
return nil
}
func mulByFloat(num *big.Int, float *big.Float) *big.Int {
n := new(big.Float).SetUint64(num.Uint64())
n = n.Mul(n, float)
value, _ := float.Uint64()
return new(big.Int).SetUint64(value)
}
// calculateL1GasLimit computes the L1 gasLimit based on the calldata and // calculateL1GasLimit computes the L1 gasLimit based on the calldata and
// constant sized overhead. The overhead can be decreased as the cost of the // constant sized overhead. The overhead can be decreased as the cost of the
// batch submission goes down via contract optimizations. This will not overflow // batch submission goes down via contract optimizations. This will not overflow
......
package fees package fees
import ( import (
"errors"
"math/big" "math/big"
"testing" "testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/params"
) )
...@@ -102,3 +104,74 @@ func TestCalculateRollupFee(t *testing.T) { ...@@ -102,3 +104,74 @@ func TestCalculateRollupFee(t *testing.T) {
}) })
} }
} }
func TestPaysEnough(t *testing.T) {
tests := map[string]struct {
opts *PaysEnoughOpts
err error
}{
"missing-gas-price": {
opts: &PaysEnoughOpts{
UserFee: nil,
ExpectedFee: new(big.Int),
ThresholdUp: nil,
ThresholdDown: nil,
},
err: errMissingInput,
},
"missing-fee": {
opts: &PaysEnoughOpts{
UserFee: nil,
ExpectedFee: nil,
ThresholdUp: nil,
ThresholdDown: nil,
},
err: errMissingInput,
},
"equal-fee": {
opts: &PaysEnoughOpts{
UserFee: common.Big1,
ExpectedFee: common.Big1,
ThresholdUp: nil,
ThresholdDown: nil,
},
err: nil,
},
"fee-too-low": {
opts: &PaysEnoughOpts{
UserFee: common.Big1,
ExpectedFee: common.Big2,
ThresholdUp: nil,
ThresholdDown: nil,
},
err: ErrFeeTooLow,
},
"fee-threshold-down": {
opts: &PaysEnoughOpts{
UserFee: common.Big1,
ExpectedFee: common.Big2,
ThresholdUp: nil,
ThresholdDown: new(big.Float).SetFloat64(0.5),
},
err: nil,
},
"fee-threshold-up": {
opts: &PaysEnoughOpts{
UserFee: common.Big3,
ExpectedFee: common.Big1,
ThresholdUp: new(big.Float).SetFloat64(1.5),
ThresholdDown: nil,
},
err: ErrFeeTooHigh,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
err := PaysEnough(tt.opts)
if !errors.Is(err, tt.err) {
t.Fatalf("%s: got %s, expected %s", name, err, tt.err)
}
})
}
}
...@@ -26,7 +26,11 @@ import ( ...@@ -26,7 +26,11 @@ import (
// errShortRemoteTip is an error for when the remote tip is shorter than the // errShortRemoteTip is an error for when the remote tip is shorter than the
// local tip // local tip
var errShortRemoteTip = errors.New("Unexpected remote less than tip") var (
errShortRemoteTip = errors.New("unexpected remote less than tip")
errBadConfig = errors.New("bad config")
float1 = big.NewFloat(1)
)
// L2GasPrice slot refers to the storage slot that the execution price is stored // L2GasPrice slot refers to the storage slot that the execution price is stored
// in the L2 predeploy contract, the GasPriceOracle // in the L2 predeploy contract, the GasPriceOracle
...@@ -59,6 +63,8 @@ type SyncService struct { ...@@ -59,6 +63,8 @@ type SyncService struct {
chainHeadCh chan core.ChainHeadEvent chainHeadCh chan core.ChainHeadEvent
backend Backend backend Backend
enforceFees bool enforceFees bool
feeThresholdUp *big.Float
feeThresholdDown *big.Float
} }
// NewSyncService returns an initialized sync service // NewSyncService returns an initialized sync service
...@@ -74,6 +80,9 @@ func NewSyncService(ctx context.Context, cfg Config, txpool *core.TxPool, bc *co ...@@ -74,6 +80,9 @@ func NewSyncService(ctx context.Context, cfg Config, txpool *core.TxPool, bc *co
log.Info("Running in verifier mode", "sync-backend", cfg.Backend.String()) log.Info("Running in verifier mode", "sync-backend", cfg.Backend.String())
} else { } else {
log.Info("Running in sequencer mode", "sync-backend", cfg.Backend.String()) log.Info("Running in sequencer mode", "sync-backend", cfg.Backend.String())
log.Info("Fees", "gas-price", fees.BigTxGasPrice, "threshold-up", cfg.FeeThresholdUp,
"threshold-down", cfg.FeeThresholdDown)
log.Info("Enforce Fees", "set", cfg.EnforceFees)
} }
pollInterval := cfg.PollInterval pollInterval := cfg.PollInterval
...@@ -95,7 +104,23 @@ func NewSyncService(ctx context.Context, cfg Config, txpool *core.TxPool, bc *co ...@@ -95,7 +104,23 @@ func NewSyncService(ctx context.Context, cfg Config, txpool *core.TxPool, bc *co
// Initialize the rollup client // Initialize the rollup client
client := NewClient(cfg.RollupClientHttp, chainID) client := NewClient(cfg.RollupClientHttp, chainID)
log.Info("Configured rollup client", "url", cfg.RollupClientHttp, "chain-id", chainID.Uint64(), "ctc-deploy-height", cfg.CanonicalTransactionChainDeployHeight) log.Info("Configured rollup client", "url", cfg.RollupClientHttp, "chain-id", chainID.Uint64(), "ctc-deploy-height", cfg.CanonicalTransactionChainDeployHeight)
log.Info("Enforce Fees", "set", cfg.EnforceFees)
// Ensure sane values for the fee thresholds
if cfg.FeeThresholdDown != nil {
// The fee threshold down should be less than 1
if cfg.FeeThresholdDown.Cmp(float1) != -1 {
return nil, fmt.Errorf("%w: fee threshold down not lower than 1: %f", errBadConfig,
cfg.FeeThresholdDown)
}
}
if cfg.FeeThresholdUp != nil {
// The fee threshold up should be greater than 1
if cfg.FeeThresholdUp.Cmp(float1) != 1 {
return nil, fmt.Errorf("%w: fee threshold up not larger than 1: %f", errBadConfig,
cfg.FeeThresholdUp)
}
}
service := SyncService{ service := SyncService{
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
...@@ -112,6 +137,8 @@ func NewSyncService(ctx context.Context, cfg Config, txpool *core.TxPool, bc *co ...@@ -112,6 +137,8 @@ func NewSyncService(ctx context.Context, cfg Config, txpool *core.TxPool, bc *co
timestampRefreshThreshold: timestampRefreshThreshold, timestampRefreshThreshold: timestampRefreshThreshold,
backend: cfg.Backend, backend: cfg.Backend,
enforceFees: cfg.EnforceFees, enforceFees: cfg.EnforceFees,
feeThresholdDown: cfg.FeeThresholdDown,
feeThresholdUp: cfg.FeeThresholdUp,
} }
// The chainHeadSub is used to synchronize the SyncService with the chain. // The chainHeadSub is used to synchronize the SyncService with the chain.
...@@ -750,28 +777,36 @@ func (s *SyncService) verifyFee(tx *types.Transaction) error { ...@@ -750,28 +777,36 @@ func (s *SyncService) verifyFee(tx *types.Transaction) error {
// Calculate the fee based on decoded L2 gas limit // Calculate the fee based on decoded L2 gas limit
gas := new(big.Int).SetUint64(tx.Gas()) gas := new(big.Int).SetUint64(tx.Gas())
l2GasLimit := fees.DecodeL2GasLimit(gas) l2GasLimit := fees.DecodeL2GasLimit(gas)
// Only count the calldata here as the overhead of the fully encoded // Only count the calldata here as the overhead of the fully encoded
// RLP transaction is handled inside of EncodeL2GasLimit // RLP transaction is handled inside of EncodeL2GasLimit
fee := fees.EncodeTxGasLimit(tx.Data(), l1GasPrice, l2GasLimit, l2GasPrice) expectedTxGasLimit := fees.EncodeTxGasLimit(tx.Data(), l1GasPrice, l2GasLimit, l2GasPrice)
if err != nil { if err != nil {
return err return err
} }
// This should only happen if the transaction fee is greater than 18.44 ETH
if !fee.IsUint64() { // This should only happen if the unscaled transaction fee is greater than 18.44 ETH
return fmt.Errorf("fee overflow: %s", fee.String()) if !expectedTxGasLimit.IsUint64() {
} return fmt.Errorf("fee overflow: %s", expectedTxGasLimit.String())
// Compute the user's fee }
paying := new(big.Int).Mul(new(big.Int).SetUint64(tx.Gas()), tx.GasPrice())
// Compute the minimum expected fee userFee := new(big.Int).Mul(new(big.Int).SetUint64(tx.Gas()), tx.GasPrice())
expecting := new(big.Int).Mul(fee, fees.BigTxGasPrice) opts := fees.PaysEnoughOpts{
if paying.Cmp(expecting) == -1 { UserFee: userFee,
return fmt.Errorf("fee too low: %d, use at least tx.gasLimit = %d and tx.gasPrice = %d", paying, fee.Uint64(), fees.BigTxGasPrice) ExpectedFee: expectedTxGasLimit.Mul(expectedTxGasLimit, fees.BigTxGasPrice),
} ThresholdUp: s.feeThresholdUp,
// Protect users from overpaying by too much ThresholdDown: s.feeThresholdDown,
overpaying := new(big.Int).Sub(paying, expecting) }
threshold := new(big.Int).Mul(expecting, common.Big3) // Check the error type and return the correct error message to the user
if overpaying.Cmp(threshold) == 1 { if err := fees.PaysEnough(&opts); err != nil {
return fmt.Errorf("fee too large: %d", paying) if errors.Is(err, fees.ErrFeeTooLow) {
return fmt.Errorf("%w: %d, use at least tx.gasLimit = %d and tx.gasPrice = %d",
fees.ErrFeeTooLow, userFee, expectedTxGasLimit, fees.BigTxGasPrice)
}
if errors.Is(err, fees.ErrFeeTooHigh) {
return fmt.Errorf("%w: %d", fees.ErrFeeTooHigh, userFee)
}
return err
} }
return nil return nil
} }
......
...@@ -18,6 +18,7 @@ import ( ...@@ -18,6 +18,7 @@ import (
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/eth/gasprice" "github.com/ethereum/go-ethereum/eth/gasprice"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/params"
) )
...@@ -664,7 +665,54 @@ func TestInitializeL1ContextPostGenesis(t *testing.T) { ...@@ -664,7 +665,54 @@ func TestInitializeL1ContextPostGenesis(t *testing.T) {
} }
} }
func newTestSyncService(isVerifier bool) (*SyncService, chan core.NewTxsEvent, event.Subscription, error) { func TestBadFeeThresholds(t *testing.T) {
// Create the deps for the sync service
cfg, txPool, chain, db, err := newTestSyncServiceDeps(false)
if err != nil {
t.Fatal(err)
}
tests := map[string]struct {
thresholdUp *big.Float
thresholdDown *big.Float
err error
}{
"nil-values": {
thresholdUp: nil,
thresholdDown: nil,
err: nil,
},
"good-values": {
thresholdUp: new(big.Float).SetFloat64(2),
thresholdDown: new(big.Float).SetFloat64(0.8),
err: nil,
},
"bad-value-up": {
thresholdUp: new(big.Float).SetFloat64(0.8),
thresholdDown: nil,
err: errBadConfig,
},
"bad-value-down": {
thresholdUp: nil,
thresholdDown: new(big.Float).SetFloat64(1.1),
err: errBadConfig,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
cfg.FeeThresholdDown = tt.thresholdDown
cfg.FeeThresholdUp = tt.thresholdUp
_, err := NewSyncService(context.Background(), cfg, txPool, chain, db)
if !errors.Is(err, tt.err) {
t.Fatalf("%s: %s", name, err)
}
})
}
}
func newTestSyncServiceDeps(isVerifier bool) (Config, *core.TxPool, *core.BlockChain, ethdb.Database, error) {
chainCfg := params.AllEthashProtocolChanges chainCfg := params.AllEthashProtocolChanges
chainID := big.NewInt(420) chainID := big.NewInt(420)
chainCfg.ChainID = chainID chainCfg.ChainID = chainID
...@@ -674,7 +722,7 @@ func newTestSyncService(isVerifier bool) (*SyncService, chan core.NewTxsEvent, e ...@@ -674,7 +722,7 @@ func newTestSyncService(isVerifier bool) (*SyncService, chan core.NewTxsEvent, e
_ = new(core.Genesis).MustCommit(db) _ = new(core.Genesis).MustCommit(db)
chain, err := core.NewBlockChain(db, nil, chainCfg, engine, vm.Config{}, nil) chain, err := core.NewBlockChain(db, nil, chainCfg, engine, vm.Config{}, nil)
if err != nil { if err != nil {
return nil, nil, nil, fmt.Errorf("Cannot initialize blockchain: %w", err) return Config{}, nil, nil, nil, fmt.Errorf("Cannot initialize blockchain: %w", err)
} }
chaincfg := params.ChainConfig{ChainID: chainID} chaincfg := params.ChainConfig{ChainID: chainID}
...@@ -687,7 +735,14 @@ func newTestSyncService(isVerifier bool) (*SyncService, chan core.NewTxsEvent, e ...@@ -687,7 +735,14 @@ func newTestSyncService(isVerifier bool) (*SyncService, chan core.NewTxsEvent, e
RollupClientHttp: "", RollupClientHttp: "",
Backend: BackendL2, Backend: BackendL2,
} }
return cfg, txPool, chain, db, nil
}
func newTestSyncService(isVerifier bool) (*SyncService, chan core.NewTxsEvent, event.Subscription, error) {
cfg, txPool, chain, db, err := newTestSyncServiceDeps(isVerifier)
if err != nil {
return nil, nil, nil, fmt.Errorf("Cannot initialize syncservice: %w", err)
}
service, err := NewSyncService(context.Background(), cfg, txPool, chain, db) service, err := NewSyncService(context.Background(), cfg, txPool, chain, db)
if err != nil { if err != nil {
return nil, nil, nil, fmt.Errorf("Cannot initialize syncservice: %w", err) return nil, nil, nil, fmt.Errorf("Cannot initialize syncservice: %w", err)
......
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