diff --git a/token-price-oracle/client/gas.go b/token-price-oracle/client/gas.go new file mode 100644 index 00000000..2d1c4892 --- /dev/null +++ b/token-price-oracle/client/gas.go @@ -0,0 +1,76 @@ +package client + +import ( + "context" + "fmt" + "math/big" + + "github.com/morph-l2/go-ethereum/log" +) + +// GasCaps holds the calculated gas tip cap and gas fee cap +type GasCaps struct { + TipCap *big.Int + FeeCap *big.Int +} + +// CalculateGasCaps calculates dynamic gas caps with optional max limits. +// It fetches the suggested tip and base fee from the network, applies configured +// max limits, and ensures the EIP-1559 invariant (tipCap <= feeCap) is maintained. +func CalculateGasCaps(ctx context.Context, client *L2Client) (*GasCaps, error) { + maxTipCap := client.GetMaxGasTipCap() + maxFeeCap := client.GetMaxGasFeeCap() + + return doCalculateGasCaps(ctx, client, maxTipCap, maxFeeCap) +} + +func doCalculateGasCaps(ctx context.Context, client *L2Client, maxTipCap, maxFeeCap *big.Int) (*GasCaps, error) { + + // Get dynamic gas tip cap from network + tip, err := client.GetClient().SuggestGasTipCap(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get suggested gas tip cap: %w", err) + } + + // Apply tip cap limit if configured + if maxTipCap != nil && tip.Cmp(maxTipCap) > 0 { + log.Debug("Applying gas tip cap limit", "dynamic", tip, "max", maxTipCap) + tip = new(big.Int).Set(maxTipCap) + } + + // Get base fee from latest block + head, err := client.GetClient().HeaderByNumber(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to get block header: %w", err) + } + + // Calculate dynamic gas fee cap: tip + baseFee * 2 + var feeCap *big.Int + if head.BaseFee != nil { + feeCap = new(big.Int).Add( + tip, + new(big.Int).Mul(head.BaseFee, big.NewInt(2)), + ) + } else { + feeCap = new(big.Int).Set(tip) + } + + // Apply fee cap limit if configured + if maxFeeCap != nil && feeCap.Cmp(maxFeeCap) > 0 { + log.Debug("Applying gas fee cap limit", "dynamic", feeCap, "max", maxFeeCap) + feeCap = new(big.Int).Set(maxFeeCap) + } + + // Ensure tipCap <= feeCap (EIP-1559 invariant) + if tip.Cmp(feeCap) > 0 { + log.Debug("Clamping tip to feeCap for EIP-1559 invariant", "tip", tip, "feeCap", feeCap) + tip = new(big.Int).Set(feeCap) + } + + log.Debug("Gas caps calculated", "tipCap", tip, "feeCap", feeCap) + + return &GasCaps{ + TipCap: tip, + FeeCap: feeCap, + }, nil +} diff --git a/token-price-oracle/client/l2_client.go b/token-price-oracle/client/l2_client.go index a69ea044..1568b068 100644 --- a/token-price-oracle/client/l2_client.go +++ b/token-price-oracle/client/l2_client.go @@ -22,6 +22,8 @@ type L2Client struct { opts *bind.TransactOpts signer *Signer externalSign bool + gasFeeCap *big.Int // Max gas fee cap (nil means no cap) + gasTipCap *big.Int // Max gas tip cap (nil means no cap) } // NewL2Client creates new L2 client @@ -50,6 +52,16 @@ func NewL2Client(rpcURL string, cfg *config.Config) (*L2Client, error) { externalSign: cfg.ExternalSign, } + // Set gas fee caps if configured (used as max cap, not fixed value) + if cfg.GasFeeCap != nil { + l2Client.gasFeeCap = new(big.Int).SetUint64(*cfg.GasFeeCap) + log.Info("Using gas fee cap limit", "maxGasFeeCap", *cfg.GasFeeCap) + } + if cfg.GasTipCap != nil { + l2Client.gasTipCap = new(big.Int).SetUint64(*cfg.GasTipCap) + log.Info("Using gas tip cap limit", "maxGasTipCap", *cfg.GasTipCap) + } + if cfg.ExternalSign { // External sign mode rsaPriv, err := externalsign.ParseRsaPrivateKey(cfg.ExternalSignRsaPriv) @@ -127,14 +139,14 @@ func (c *L2Client) GetClient() *ethclient.Client { // GetOpts returns a copy of transaction options // Returns a new instance to prevent concurrent modification +// Note: Gas caps are applied by TxManager.applyGasCaps(), not here func (c *L2Client) GetOpts() *bind.TransactOpts { - // Return a copy to prevent shared state issues return &bind.TransactOpts{ - From: c.opts.From, - Nonce: c.opts.Nonce, - Signer: c.opts.Signer, - Value: c.opts.Value, - GasPrice: c.opts.GasPrice, + From: c.opts.From, + Nonce: c.opts.Nonce, + Signer: c.opts.Signer, + Value: c.opts.Value, + GasPrice: c.opts.GasPrice, GasFeeCap: c.opts.GasFeeCap, GasTipCap: c.opts.GasTipCap, GasLimit: c.opts.GasLimit, @@ -167,3 +179,13 @@ func (c *L2Client) GetSigner() *Signer { func (c *L2Client) GetChainID() *big.Int { return c.chainID } + +// GetMaxGasFeeCap returns the max gas fee cap (nil if not configured) +func (c *L2Client) GetMaxGasFeeCap() *big.Int { + return c.gasFeeCap +} + +// GetMaxGasTipCap returns the max gas tip cap (nil if not configured) +func (c *L2Client) GetMaxGasTipCap() *big.Int { + return c.gasTipCap +} diff --git a/token-price-oracle/client/sign.go b/token-price-oracle/client/sign.go index 340e3214..392879a4 100644 --- a/token-price-oracle/client/sign.go +++ b/token-price-oracle/client/sign.go @@ -96,34 +96,18 @@ func (s *Signer) CreateAndSignTx( return nil, fmt.Errorf("failed to get nonce: %w", err) } - // Get gas tip cap - tip, err := client.GetClient().SuggestGasTipCap(ctx) + // Calculate gas caps (dynamic values with optional max limits) + caps, err := CalculateGasCaps(ctx, client) if err != nil { - return nil, fmt.Errorf("failed to get gas tip cap: %w", err) - } - - // Get base fee from latest block - head, err := client.GetClient().HeaderByNumber(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to get block header: %w", err) - } - - var gasFeeCap *big.Int - if head.BaseFee != nil { - gasFeeCap = new(big.Int).Add( - tip, - new(big.Int).Mul(head.BaseFee, big.NewInt(2)), - ) - } else { - gasFeeCap = new(big.Int).Set(tip) + return nil, fmt.Errorf("failed to calculate gas caps: %w", err) } // Estimate gas gas, err := client.GetClient().EstimateGas(ctx, ethereum.CallMsg{ From: from, To: &to, - GasFeeCap: gasFeeCap, - GasTipCap: tip, + GasFeeCap: caps.FeeCap, + GasTipCap: caps.TipCap, Data: callData, }) if err != nil { @@ -137,8 +121,8 @@ func (s *Signer) CreateAndSignTx( tx := types.NewTx(&types.DynamicFeeTx{ ChainID: s.chainID, Nonce: nonce, - GasTipCap: tip, - GasFeeCap: gasFeeCap, + GasTipCap: caps.TipCap, + GasFeeCap: caps.FeeCap, Gas: gas, To: &to, Data: callData, @@ -149,10 +133,9 @@ func (s *Signer) CreateAndSignTx( "to", to.Hex(), "nonce", nonce, "gas", gas, - "gasFeeCap", gasFeeCap, - "gasTipCap", tip) + "gasFeeCap", caps.FeeCap, + "gasTipCap", caps.TipCap) // Sign transaction return s.Sign(tx) } - diff --git a/token-price-oracle/config/config.go b/token-price-oracle/config/config.go index c66b69e9..ef092332 100644 --- a/token-price-oracle/config/config.go +++ b/token-price-oracle/config/config.go @@ -85,6 +85,10 @@ type Config struct { LogFileMaxSize int LogFileMaxAge int LogCompress bool + + // Gas fee caps (optional - if set, use as max cap) + GasFeeCap *uint64 // Max gas fee cap in wei (nil means no cap) + GasTipCap *uint64 // Max gas tip cap in wei (nil means no cap) } // LoadConfig loads configuration from cli.Context @@ -112,6 +116,21 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { LogCompress: ctx.Bool(flags.LogCompressFlag.Name), } + // Gas fee caps (only set if flag is explicitly provided) + if ctx.IsSet(flags.GasFeeCapFlag.Name) { + v := ctx.Uint64(flags.GasFeeCapFlag.Name) + cfg.GasFeeCap = &v + } + if ctx.IsSet(flags.GasTipCapFlag.Name) { + v := ctx.Uint64(flags.GasTipCapFlag.Name) + cfg.GasTipCap = &v + } + + // Validate GasFeeCap >= GasTipCap when both are set (EIP-1559 invariant) + if cfg.GasFeeCap != nil && cfg.GasTipCap != nil && *cfg.GasFeeCap < *cfg.GasTipCap { + return nil, fmt.Errorf("--gas-fee-cap (%d) must be >= --gas-tip-cap (%d)", *cfg.GasFeeCap, *cfg.GasTipCap) + } + // Parse token registry address (optional) cfg.L2TokenRegistryAddr = predeploys.L2TokenRegistryAddr diff --git a/token-price-oracle/flags/flags.go b/token-price-oracle/flags/flags.go index 785783e1..1692806b 100644 --- a/token-price-oracle/flags/flags.go +++ b/token-price-oracle/flags/flags.go @@ -176,6 +176,19 @@ var ( Usage: "The RSA private key for external sign", EnvVar: prefixEnvVar("EXTERNAL_SIGN_RSA_PRIV"), } + + // Gas fee flags (optional - if set, use as max cap instead of dynamic) + GasFeeCapFlag = cli.Uint64Flag{ + Name: "gas-fee-cap", + Usage: "Max gas fee cap in wei (if set, actual fee = min(dynamic, this value))", + EnvVar: prefixEnvVar("GAS_FEE_CAP"), + } + + GasTipCapFlag = cli.Uint64Flag{ + Name: "gas-tip-cap", + Usage: "Max gas tip cap in wei (if set, actual tip = min(dynamic, this value))", + EnvVar: prefixEnvVar("GAS_TIP_CAP"), + } ) var requiredFlags = []cli.Flag{ @@ -210,6 +223,10 @@ var optionalFlags = []cli.Flag{ ExternalSignChainFlag, ExternalSignUrlFlag, ExternalSignRsaPrivFlag, + + // Gas fee + GasFeeCapFlag, + GasTipCapFlag, } // Flags contains the list of configuration options available to the binary. diff --git a/token-price-oracle/updater/tx_manager.go b/token-price-oracle/updater/tx_manager.go index ebed4939..0ae0c841 100644 --- a/token-price-oracle/updater/tx_manager.go +++ b/token-price-oracle/updater/tx_manager.go @@ -72,6 +72,11 @@ func (m *TxManager) sendWithLocalSign(ctx context.Context, txFunc func(*bind.Tra auth := m.l2Client.GetOpts() auth.Context = ctx + // Apply gas caps if configured (same logic as external sign) + if err := m.applyGasCaps(ctx, auth); err != nil { + return nil, fmt.Errorf("failed to apply gas caps: %w", err) + } + // First, estimate gas with GasLimit = 0 auth.GasLimit = 0 auth.NoSend = true @@ -193,6 +198,19 @@ func (m *TxManager) sendWithExternalSign(ctx context.Context, txFunc func(*bind. return receipt, nil } +// applyGasCaps applies configured gas caps as upper limits to dynamic gas prices +// This ensures consistent behavior between local sign and external sign +func (m *TxManager) applyGasCaps(ctx context.Context, auth *bind.TransactOpts) error { + caps, err := client.CalculateGasCaps(ctx, m.l2Client) + if err != nil { + return err + } + + auth.GasTipCap = caps.TipCap + auth.GasFeeCap = caps.FeeCap + return nil +} + // waitForReceipt waits for a transaction receipt with timeout and custom polling interval func (m *TxManager) waitForReceipt(ctx context.Context, txHash common.Hash, timeout, pollInterval time.Duration) (*types.Receipt, error) { deadline := time.Now().Add(timeout)