diff --git a/contracts/src/v0.8/ccip/libraries/Internal.sol b/contracts/src/v0.8/ccip/libraries/Internal.sol index d314553f20..903069ac75 100644 --- a/contracts/src/v0.8/ccip/libraries/Internal.sol +++ b/contracts/src/v0.8/ccip/libraries/Internal.sol @@ -65,8 +65,8 @@ library Internal { uint256 public constant MESSAGE_FIXED_BYTES = 32 * 17; /// @dev Each token transfer adds 1 EVMTokenAmount and 1 bytes. - /// When abiEncoded, each EVMTokenAmount takes 2 slots, each bytes takes 2 slots. - uint256 public constant MESSAGE_BYTES_PER_TOKEN = 32 * 4; + /// When abiEncoded, each EVMTokenAmount takes 2 slots, each bytes takes 2 slots, excl bytes contents + uint256 public constant MESSAGE_FIXED_BYTES_PER_TOKEN = 32 * 4; function _toAny2EVMMessage( EVM2EVMMessage memory original, diff --git a/contracts/src/v0.8/ccip/onRamp/EVM2EVMOnRamp.sol b/contracts/src/v0.8/ccip/onRamp/EVM2EVMOnRamp.sol index 7385e7c055..619f6da79b 100644 --- a/contracts/src/v0.8/ccip/onRamp/EVM2EVMOnRamp.sol +++ b/contracts/src/v0.8/ccip/onRamp/EVM2EVMOnRamp.sol @@ -81,9 +81,9 @@ contract EVM2EVMOnRamp is IEVM2AnyOnRamp, ILinkAvailable, AggregateRateLimiter, address router; // ─────────────────────────╮ Router address uint16 maxTokensLength; // │ Maximum number of ERC20 token transfers per message uint32 destGasOverhead; // │ Extra gas charged on top of the gasLimit - uint16 destGasPerPayloadByte; // │ Destination chain gas charged per byte of `data` payload - uint32 destDataAvailabilityOverheadGas; // │ Extra data availability gas charged on top of message data - uint16 destGasPerDataAvailabilityByte; // ──╯ Amount of gas to charge per byte of data that needs availability + uint16 destGasPerPayloadByte; // │ Destination chain gas charged for passing each byte of `data` payload to receiver + uint32 destDataAvailabilityOverheadGas; // │ Extra data availability gas charged on top of the message, e.g. for OCR + uint16 destGasPerDataAvailabilityByte; // ──╯ Amount of gas to charge per byte of message data that needs availability uint16 destDataAvailabilityMultiplier; // ──╮ Multiplier for data availability gas, multiples of 1e-4, or 0.0001 address priceRegistry; // │ Price registry address uint32 maxDataSize; // │ Maximum payload data size, max 4GB @@ -116,7 +116,7 @@ contract EVM2EVMOnRamp is IEVM2AnyOnRamp, ILinkAvailable, AggregateRateLimiter, struct TokenTransferFeeConfig { uint16 ratio; // ───────────────────╮ Ratio of token transfer value to charge as fee, multiples of 0.1bps, or 1e-5 uint32 destGasOverhead; // │ Gas charged to execute the token transfer on the destination chain - uint32 destBytesOverhead; // ───────╯ Extra data availability bytes on top of transfer data, e.g. USDC offchain data + uint32 destBytesOverhead; // ───────╯ Extra data availability bytes on top of fixed transfer data, e.g. USDC source token data and offchain data } /// @dev Same as TokenTransferFeeConfig @@ -125,7 +125,7 @@ contract EVM2EVMOnRamp is IEVM2AnyOnRamp, ILinkAvailable, AggregateRateLimiter, address token; // ──────────────────╮ Token address uint16 ratio; // │ Ratio of token transfer value to charge as fee, multiples of 0.1bps, or 1e-5 uint32 destGasOverhead; // │ Gas charged to execute the token transfer on the destination chain - uint32 destBytesOverhead; // ───────╯ Extra data availability bytes on top of transfer data, e.g. USDC offchain data + uint32 destBytesOverhead; // ───────╯ Extra data availability bytes on top of fixed transfer data, e.g. USDC source token data and offchain data } /// @dev Nop address and weight, used to set the nops and their weights @@ -559,9 +559,10 @@ contract EVM2EVMOnRamp is IEVM2AnyOnRamp, ILinkAvailable, AggregateRateLimiter, ) internal view returns (uint256 dataAvailabilityCostUSD) { uint256 dataAvailabilityLengthBytes = Internal.MESSAGE_FIXED_BYTES + messageDataLength + - (numberOfTokens * Internal.MESSAGE_BYTES_PER_TOKEN) + + (numberOfTokens * Internal.MESSAGE_FIXED_BYTES_PER_TOKEN) + tokenTransferBytesOverhead; + // destDataAvailabilityOverheadGas is a separate config value for flexibility to be updated independently of message cost. uint256 dataAvailabilityGas = (dataAvailabilityLengthBytes * s_dynamicConfig.destGasPerDataAvailabilityByte) + s_dynamicConfig.destDataAvailabilityOverheadGas; diff --git a/contracts/src/v0.8/ccip/test/onRamp/EVM2EVMOnRamp.t.sol b/contracts/src/v0.8/ccip/test/onRamp/EVM2EVMOnRamp.t.sol index 838c0e3cf9..b7e8c34ab0 100644 --- a/contracts/src/v0.8/ccip/test/onRamp/EVM2EVMOnRamp.t.sol +++ b/contracts/src/v0.8/ccip/test/onRamp/EVM2EVMOnRamp.t.sol @@ -705,7 +705,7 @@ contract EVM2EVMOnRamp_getDataAvailabilityCostUSD is EVM2EVMOnRamp_getFeeSetup { uint256 dataAvailabilityLengthBytes = Internal.MESSAGE_FIXED_BYTES + 100 + - (5 * Internal.MESSAGE_BYTES_PER_TOKEN) + + (5 * Internal.MESSAGE_FIXED_BYTES_PER_TOKEN) + 50; uint256 dataAvailabilityGas = dynamicConfig.destDataAvailabilityOverheadGas + dynamicConfig.destGasPerDataAvailabilityByte * @@ -757,7 +757,7 @@ contract EVM2EVMOnRamp_getDataAvailabilityCostUSD is EVM2EVMOnRamp_getFeeSetup { uint256 dataAvailabilityLengthBytes = Internal.MESSAGE_FIXED_BYTES + messageDataLength + - (numberOfTokens * Internal.MESSAGE_BYTES_PER_TOKEN) + + (numberOfTokens * Internal.MESSAGE_FIXED_BYTES_PER_TOKEN) + tokenTransferBytesOverhead; uint256 dataAvailabilityGas = destDataAvailabilityOverheadGas + diff --git a/core/services/ocr2/plugins/ccip/commit_inflight.go b/core/services/ocr2/plugins/ccip/commit_inflight.go index 3bc66f593c..b9540bd2c0 100644 --- a/core/services/ocr2/plugins/ccip/commit_inflight.go +++ b/core/services/ocr2/plugins/ccip/commit_inflight.go @@ -67,20 +67,22 @@ func (c *inflightCommitReportsContainer) maxInflightSeqNr() uint64 { return max } -// latestGasPriceUpdate return the latest inflight gas price update or nil if there is no inflight gas price update. -func (c *inflightCommitReportsContainer) getLatestInflightGasPriceUpdate() *update { +// getLatestInflightGasPriceUpdate returns the latest inflight gas price update, and bool flag on if update exists. +func (c *inflightCommitReportsContainer) getLatestInflightGasPriceUpdate() (update, bool) { c.locker.RLock() defer c.locker.RUnlock() - var latestGasPriceUpdate *update + updateFound := false + latestGasPriceUpdate := update{} var latestEpochAndRound uint64 for _, inflight := range c.inFlightPriceUpdates { if inflight.priceUpdates.DestChainSelector == 0 { // Price updates did not include a gas price continue } - if latestGasPriceUpdate == nil || inflight.epochAndRound > latestEpochAndRound { + if !updateFound || inflight.epochAndRound > latestEpochAndRound { // First price found or found later update, set it - latestGasPriceUpdate = &update{ + updateFound = true + latestGasPriceUpdate = update{ timestamp: inflight.createdAt, value: inflight.priceUpdates.UsdPerUnitGas, } @@ -88,7 +90,7 @@ func (c *inflightCommitReportsContainer) getLatestInflightGasPriceUpdate() *upda continue } } - return latestGasPriceUpdate + return latestGasPriceUpdate, updateFound } // latestInflightTokenPriceUpdates returns a map of the latest token price updates diff --git a/core/services/ocr2/plugins/ccip/commit_inflight_test.go b/core/services/ocr2/plugins/ccip/commit_inflight_test.go index f5c926134b..39d6a1ea7a 100644 --- a/core/services/ocr2/plugins/ccip/commit_inflight_test.go +++ b/core/services/ocr2/plugins/ccip/commit_inflight_test.go @@ -27,7 +27,9 @@ func TestCommitInflight(t *testing.T) { }) // Initially should be empty - assert.Nil(t, c.getLatestInflightGasPriceUpdate()) + inflightUpdate, hasUpdate := c.getLatestInflightGasPriceUpdate() + assert.Equal(t, inflightUpdate, update{}) + assert.False(t, hasUpdate) assert.Equal(t, uint64(0), c.maxInflightSeqNr()) epochAndRound := uint64(1) @@ -35,14 +37,18 @@ func TestCommitInflight(t *testing.T) { // Add a single report inflight root1 := utils.Keccak256Fixed(hexutil.MustDecode("0xaa")) require.NoError(t, c.add(lggr, commit_store.CommitStoreCommitReport{Interval: commit_store.CommitStoreInterval{Min: 1, Max: 2}, MerkleRoot: root1}, epochAndRound)) - assert.Nil(t, c.getLatestInflightGasPriceUpdate()) + inflightUpdate, hasUpdate = c.getLatestInflightGasPriceUpdate() + assert.Equal(t, inflightUpdate, update{}) + assert.False(t, hasUpdate) assert.Equal(t, uint64(2), c.maxInflightSeqNr()) epochAndRound++ // Add another price report root2 := utils.Keccak256Fixed(hexutil.MustDecode("0xab")) require.NoError(t, c.add(lggr, commit_store.CommitStoreCommitReport{Interval: commit_store.CommitStoreInterval{Min: 3, Max: 4}, MerkleRoot: root2}, epochAndRound)) - assert.Nil(t, c.getLatestInflightGasPriceUpdate()) + inflightUpdate, hasUpdate = c.getLatestInflightGasPriceUpdate() + assert.Equal(t, inflightUpdate, update{}) + assert.False(t, hasUpdate) assert.Equal(t, uint64(4), c.maxInflightSeqNr()) epochAndRound++ @@ -51,8 +57,10 @@ func TestCommitInflight(t *testing.T) { DestChainSelector: uint64(1), UsdPerUnitGas: big.NewInt(1), }}, epochAndRound)) - latest := c.getLatestInflightGasPriceUpdate() - assert.Equal(t, big.NewInt(1), latest.value) + + inflightUpdate, hasUpdate = c.getLatestInflightGasPriceUpdate() + assert.Equal(t, big.NewInt(1), inflightUpdate.value) + assert.True(t, hasUpdate) assert.Equal(t, uint64(4), c.maxInflightSeqNr()) epochAndRound++ diff --git a/core/services/ocr2/plugins/ccip/commit_plugin.go b/core/services/ocr2/plugins/ccip/commit_plugin.go index bda5f3b0e0..d786bc59c1 100644 --- a/core/services/ocr2/plugins/ccip/commit_plugin.go +++ b/core/services/ocr2/plugins/ccip/commit_plugin.go @@ -63,7 +63,7 @@ func jobSpecToCommitPluginConfig(lggr logger.Logger, jb job.Job, pr pipeline.Run if err != nil { return nil, nil, errors.Wrap(err, "get chainset") } - commitStore, err := contractutil.LoadCommitStore(common.HexToAddress(spec.ContractID), CommitPluginLabel, destChain.Client()) + commitStore, commitStoreVersion, err := contractutil.LoadCommitStore(common.HexToAddress(spec.ContractID), CommitPluginLabel, destChain.Client()) if err != nil { return nil, nil, errors.Wrap(err, "failed loading commitStore") } @@ -79,7 +79,7 @@ func jobSpecToCommitPluginConfig(lggr logger.Logger, jb job.Job, pr pipeline.Run if err != nil { return nil, nil, errors.Wrap(err, "unable to open source chain") } - offRamp, err := contractutil.LoadOffRamp(common.HexToAddress(pluginConfig.OffRamp), CommitPluginLabel, destChain.Client()) + offRamp, _, err := contractutil.LoadOffRamp(common.HexToAddress(pluginConfig.OffRamp), CommitPluginLabel, destChain.Client()) if err != nil { return nil, nil, errors.Wrap(err, "failed loading offRamp") } @@ -121,6 +121,7 @@ func jobSpecToCommitPluginConfig(lggr logger.Logger, jb job.Job, pr pipeline.Run sourceChainSelector: staticConfig.SourceChainSelector, destClient: destChain.Client(), commitStore: commitStore, + commitStoreVersion: commitStoreVersion, }, &BackfillArgs{ sourceLP: sourceChain.LogPoller(), destLP: destChain.LogPoller(), diff --git a/core/services/ocr2/plugins/ccip/commit_reporting_plugin.go b/core/services/ocr2/plugins/ccip/commit_reporting_plugin.go index 745ac421e1..14023e694c 100644 --- a/core/services/ocr2/plugins/ccip/commit_reporting_plugin.go +++ b/core/services/ocr2/plugins/ccip/commit_reporting_plugin.go @@ -10,13 +10,13 @@ import ( "sync" "time" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/pkg/errors" "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - "github.com/smartcontractkit/chainlink/v2/core/assets" evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" @@ -31,6 +31,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/contractutil" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/logpollerutil" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/pricegetter" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/prices" "github.com/smartcontractkit/chainlink/v2/core/utils/mathutil" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/cache" @@ -66,12 +67,13 @@ type CommitPluginConfig struct { sourceNative common.Address sourceFeeEstimator gas.EvmFeeEstimator // Dest - destLP logpoller.LogPoller - destReader ccipdata.Reader - offRamp evm_2_evm_offramp.EVM2EVMOffRampInterface - commitStore commit_store.CommitStoreInterface - destClient evmclient.Client - destChainEVMID *big.Int + destLP logpoller.LogPoller + destReader ccipdata.Reader + offRamp evm_2_evm_offramp.EVM2EVMOffRampInterface + commitStore commit_store.CommitStoreInterface + commitStoreVersion semver.Version + destClient evmclient.Client + destChainEVMID *big.Int // Offchain priceGetter pricegetter.PriceGetter } @@ -85,6 +87,7 @@ type CommitReportingPlugin struct { offchainConfig ccipconfig.CommitOffchainConfig onchainConfig ccipconfig.CommitOnchainConfig tokenDecimalsCache cache.AutoSync[map[common.Address]uint8] + gasPriceEstimator prices.GasPriceEstimatorCommit } type CommitReportingPluginFactory struct { @@ -110,7 +113,7 @@ func (rf *CommitReportingPluginFactory) NewReportingPlugin(config types.Reportin if err != nil { return nil, types.ReportingPluginInfo{}, err } - offchainConfig, err := ccipconfig.DecodeOffchainConfig[ccipconfig.CommitOffchainConfig](config.OffchainConfig) + offchainConfig, err := contractutil.DecodeCommitStoreOffchainConfig(rf.config.commitStoreVersion, config.OffchainConfig) if err != nil { return nil, types.ReportingPluginInfo{}, err } @@ -128,6 +131,17 @@ func (rf *CommitReportingPluginFactory) NewReportingPlugin(config types.Reportin "onchainConfig", onchainConfig, ) + gasPriceEstimator, err := prices.NewGasPriceEstimatorForCommitPlugin( + rf.config.commitStoreVersion, + rf.config.sourceFeeEstimator, + big.NewInt(int64(offchainConfig.MaxGasPrice)), + int64(offchainConfig.DAGasPriceDeviationPPB), + int64(offchainConfig.ExecGasPriceDeviationPPB), + ) + if err != nil { + return nil, types.ReportingPluginInfo{}, err + } + return &CommitReportingPlugin{ config: rf.config, F: config.F, @@ -144,6 +158,7 @@ func (rf *CommitReportingPluginFactory) NewReportingPlugin(config types.Reportin rf.config.destClient, int64(offchainConfig.DestFinalityDepth), ), + gasPriceEstimator: gasPriceEstimator, }, types.ReportingPluginInfo{ Name: "CCIPCommit", @@ -163,7 +178,8 @@ func (r *CommitReportingPlugin) Query(context.Context, types.ReportTimestamp) (t // Observation calculates the sequence number interval ready to be committed and // the token and gas price updates required. A valid report could contain a merkle -// root and/or price updates. +// root and price updates. Price updates should never contain nil values, otherwise +// the observation will be considered invalid and rejected. func (r *CommitReportingPlugin) Observation(ctx context.Context, epochAndRound types.ReportTimestamp, _ types.Query) (types.Observation, error) { lggr := r.lggr.Named("CommitObservation") // If the commit store is down the protocol should halt. @@ -286,13 +302,13 @@ func (r *CommitReportingPlugin) nextMinSeqNum(ctx context.Context, lggr logger.L return mathutil.Max(nextMinOnChain, maxInflight+1), nextMinOnChain, nil } -// All prices are USD ($1=1e18) denominated. We only generate prices we think should be updated; -// otherwise, omitting values means voting to skip updating them +// All prices are USD ($1=1e18) denominated. All prices must be not nil. +// Return token prices should contain the exact same tokens as in tokenDecimals. func (r *CommitReportingPlugin) generatePriceUpdates( ctx context.Context, lggr logger.Logger, tokenDecimals map[common.Address]uint8, -) (sourceGasPriceUSD *big.Int, tokenPricesUSD map[common.Address]*big.Int, err error) { +) (sourceGasPriceUSD prices.GasPrice, tokenPricesUSD map[common.Address]*big.Int, err error) { tokensWithDecimal := make([]common.Address, 0, len(tokenDecimals)) for token := range tokenDecimals { tokensWithDecimal = append(tokensWithDecimal, token) @@ -332,22 +348,19 @@ func (r *CommitReportingPlugin) generatePriceUpdates( tokenPricesUSD[token] = calculateUsdPer1e18TokenAmount(rawTokenPricesUSD[token], decimals) } - // Observe a source chain price for pricing. - sourceGasPriceWei, _, err := r.config.sourceFeeEstimator.GetFee(ctx, nil, 0, assets.NewWei(big.NewInt(int64(r.offchainConfig.MaxGasPrice)))) + sourceGasPrice, err := r.gasPriceEstimator.GetGasPrice(ctx) if err != nil { return nil, nil, err } - // Use legacy if no dynamic is available. - gasPrice := sourceGasPriceWei.Legacy.ToInt() - if sourceGasPriceWei.DynamicFeeCap != nil { - gasPrice = sourceGasPriceWei.DynamicFeeCap.ToInt() + if sourceGasPrice == nil { + return nil, nil, errors.Errorf("missing gas price") } - if gasPrice == nil { - return nil, nil, fmt.Errorf("missing gas price %+v", sourceGasPriceWei) + sourceGasPriceUSD, err = r.gasPriceEstimator.DenoteInUSD(sourceGasPrice, sourceNativePriceUSD) + if err != nil { + return nil, nil, err } - sourceGasPriceUSD = ccipcalc.CalculateUsdPerUnitGas(gasPrice, sourceNativePriceUSD) - lggr.Infow("Observing gas price", "observedGasPriceWei", gasPrice, "observedGasPriceUSD", sourceGasPriceUSD) + lggr.Infow("Observing gas price", "observedGasPriceWei", sourceGasPrice, "observedGasPriceUSD", sourceGasPriceUSD) lggr.Infow("Observing token prices", "tokenPrices", tokenPricesUSD, "sourceNativePriceUSD", sourceNativePriceUSD) return sourceGasPriceUSD, tokenPricesUSD, nil } @@ -366,7 +379,7 @@ func (r *CommitReportingPlugin) getLatestTokenPriceUpdates(ctx context.Context, tokenPriceUpdates, err := r.config.destReader.GetTokenPriceUpdatesCreatedAfter( ctx, r.destPriceRegistry.Address(), - now.Add(-r.offchainConfig.FeeUpdateHeartBeat.Duration()), + now.Add(-r.offchainConfig.TokenPriceHeartBeat.Duration()), 0, ) if err != nil { @@ -374,8 +387,8 @@ func (r *CommitReportingPlugin) getLatestTokenPriceUpdates(ctx context.Context, } latestUpdates := make(map[common.Address]update) - for _, tokenPriceUpdate := range tokenPriceUpdates { - priceUpdate := tokenPriceUpdate.Data + for _, tokenUpdate := range tokenPriceUpdates { + priceUpdate := tokenUpdate.Data // Ordered by ascending timestamps timestamp := time.Unix(priceUpdate.Timestamp.Int64(), 0) if priceUpdate.Value != nil && !timestamp.Before(latestUpdates[priceUpdate.Token].timestamp) { @@ -402,19 +415,18 @@ func (r *CommitReportingPlugin) getLatestTokenPriceUpdates(ctx context.Context, return latestUpdates, nil } -// Gets the latest gas price updates based on logs within the heartbeat -func (r *CommitReportingPlugin) getLatestGasPriceUpdate(ctx context.Context, now time.Time, checkInflight bool) (gasPriceUpdate update, error error) { +// getLatestGasPriceUpdate returns the latest gas price update based on logs within the heartbeat. +// If an update is found, it is not expected to contain a nil value. If no updates found, empty update with nil value is returned. +func (r *CommitReportingPlugin) getLatestGasPriceUpdate(ctx context.Context, now time.Time, checkInflight bool) (gasUpdate update, error error) { if checkInflight { - latestInflightGasPriceUpdate := r.inflightReports.getLatestInflightGasPriceUpdate() - if latestInflightGasPriceUpdate != nil && latestInflightGasPriceUpdate.timestamp.After(gasPriceUpdate.timestamp) { - gasPriceUpdate = *latestInflightGasPriceUpdate - } + latestInflightGasPriceUpdate, latestUpdateFound := r.inflightReports.getLatestInflightGasPriceUpdate() + if latestUpdateFound { + gasUpdate = latestInflightGasPriceUpdate + r.lggr.Infow("Latest gas price from inflight", "gasPriceUpdateVal", gasUpdate.value, "gasPriceUpdateTs", gasUpdate.timestamp) - if gasPriceUpdate.value != nil { - r.lggr.Infow("Latest gas price from inflight", "gasPriceUpdateVal", gasPriceUpdate.value, "gasPriceUpdateTs", gasPriceUpdate.timestamp) // Gas price can fluctuate frequently, many updates may be in flight. // If there is gas price update inflight, use it as source of truth, no need to check onchain. - return gasPriceUpdate, nil + return gasUpdate, nil } } @@ -423,7 +435,7 @@ func (r *CommitReportingPlugin) getLatestGasPriceUpdate(ctx context.Context, now ctx, r.destPriceRegistry.Address(), r.config.sourceChainSelector, - now.Add(-r.offchainConfig.FeeUpdateHeartBeat.Duration()), + now.Add(-r.offchainConfig.GasPriceHeartBeat.Duration()), 0, ) if err != nil { @@ -433,27 +445,37 @@ func (r *CommitReportingPlugin) getLatestGasPriceUpdate(ctx context.Context, now for _, priceUpdate := range gasPriceUpdates { // Ordered by ascending timestamps timestamp := time.Unix(priceUpdate.Data.Timestamp.Int64(), 0) - if !timestamp.Before(gasPriceUpdate.timestamp) { - gasPriceUpdate = update{ + if !timestamp.Before(gasUpdate.timestamp) { + gasUpdate = update{ timestamp: timestamp, value: priceUpdate.Data.Value, } } } - if gasPriceUpdate.value != nil { - r.lggr.Infow("Latest gas price from log poller", "gasPriceUpdateVal", gasPriceUpdate.value, "gasPriceUpdateTs", gasPriceUpdate.timestamp) - } - - return gasPriceUpdate, nil + r.lggr.Infow("Latest gas price from log poller", "gasPriceUpdateVal", gasUpdate.value, "gasPriceUpdateTs", gasUpdate.timestamp) + return gasUpdate, nil } func (r *CommitReportingPlugin) Report(ctx context.Context, epochAndRound types.ReportTimestamp, _ types.Query, observations []types.AttributedObservation) (bool, types.Report, error) { now := time.Now() lggr := r.lggr.Named("CommitReport") parsableObservations := getParsableObservations[CommitObservation](lggr, observations) + + // tokens in the tokenDecimalsCache represent supported tokens on the dest chain + supportedTokensMap, err := r.tokenDecimalsCache.Get(ctx) + if err != nil { + return false, nil, err + } + + // Filters out parsable but faulty observations + validObservations, err := validateObservations(ctx, lggr, supportedTokensMap, r.F, parsableObservations) + if err != nil { + return false, nil, err + } + var intervals []commit_store.CommitStoreInterval - for _, obs := range parsableObservations { + for _, obs := range validObservations { intervals = append(intervals, obs.Interval) } @@ -472,7 +494,10 @@ func (r *CommitReportingPlugin) Report(ctx context.Context, epochAndRound types. return false, nil, err } - priceUpdates := r.calculatePriceUpdates(parsableObservations, latestGasPrice, latestTokenPrices) + priceUpdates, err := r.calculatePriceUpdates(validObservations, latestGasPrice, latestTokenPrices) + if err != nil { + return false, nil, err + } // If there are no fee updates and the interval is zero there is no report to produce. if len(priceUpdates.TokenPriceUpdates) == 0 && priceUpdates.DestChainSelector == 0 && agreedInterval.Min == 0 { lggr.Infow("Empty report, skipping") @@ -499,6 +524,50 @@ func (r *CommitReportingPlugin) Report(ctx context.Context, epochAndRound types. return true, encodedReport, nil } +// validateObservations validates the given observations. +// An observation is rejected if any of its gas price or token price is nil. With current CommitObservation implementation, prices +// are checked to ensure no nil values before adding to Observation, hence an observation that contains nil values comes from a faulty node. +func validateObservations(ctx context.Context, lggr logger.Logger, supportedTokensMap map[common.Address]uint8, f int, observations []CommitObservation) (validObs []CommitObservation, err error) { + for _, obs := range observations { + // If gas price is reported as nil, the observation is faulty, skip the observation. + if obs.SourceGasPriceUSD == nil { + lggr.Warnw("Skipping observation due to nil SourceGasPriceUSD") + continue + } + // If observed number of token prices does not match number of supported tokens on dest chain, skip the observation. + if len(supportedTokensMap) != len(obs.TokenPricesUSD) { + lggr.Warnw("Skipping observation due to token count mismatch", "expecting", len(supportedTokensMap), "got", len(obs.TokenPricesUSD)) + continue + } + // If any of the observed token prices is reported as nil, or not supported on dest chain, the observation is faulty, skip the observation. + // Printing all faulty prices instead of short-circuiting to make log more informative. + skipObservation := false + for token, price := range obs.TokenPricesUSD { + if price == nil { + lggr.Warnw("Nil value in observed TokenPricesUSD", "token", token.Hex()) + skipObservation = true + } + if _, exists := supportedTokensMap[token]; !exists { + lggr.Warnw("Unsupported token in observed TokenPricesUSD", "token", token.Hex()) + skipObservation = true + } + } + if skipObservation { + lggr.Warnw("Skipping observation due to invalid TokenPricesUSD") + continue + } + + validObs = append(validObs, obs) + } + + // We require at least f+1 valid observations. This corresponds to the scenario where f of the 2f+1 are faulty. + if len(validObs) <= f { + return nil, errors.Errorf("Not enough valid observations to form consensus: #obs=%d, f=%d", len(validObs), f) + } + + return validObs, nil +} + // calculateIntervalConsensus compresses a set of intervals into one interval // taking into account f which is the maximum number of faults across the whole DON. // OCR itself won't call Report unless there are 2*f+1 observations @@ -508,12 +577,6 @@ func (r *CommitReportingPlugin) Report(ctx context.Context, epochAndRound types. // in between. // rangeLimit is the maximum range of the interval. If the interval is larger than this, it will be truncated. Zero means no limit. func calculateIntervalConsensus(intervals []commit_store.CommitStoreInterval, f int, rangeLimit uint64) (commit_store.CommitStoreInterval, error) { - // We require at least f+1 parsed values. This corresponds to the scenario where f of the 2f+1 are faulty - // in the sense that they are unparseable. - if len(intervals) <= f { - return commit_store.CommitStoreInterval{}, errors.Errorf("Not enough intervals to form consensus: #obs=%d, f=%d", len(intervals), f) - } - // To understand min/max selection here, we need to consider an adversary that controls f values // and is intentionally trying to stall the protocol or influence the value returned. For simplicity // consider f=1 and n=4 nodes. In that case adversary may try to bias the min or max high/low. @@ -568,36 +631,26 @@ func calculateIntervalConsensus(intervals []commit_store.CommitStoreInterval, f // Note priceUpdates must be deterministic. // The provided latestTokenPrices should not contain nil values. -func (r *CommitReportingPlugin) calculatePriceUpdates(observations []CommitObservation, latestGasPrice update, latestTokenPrices map[common.Address]update) commit_store.InternalPriceUpdates { +func (r *CommitReportingPlugin) calculatePriceUpdates(observations []CommitObservation, latestGasPrice update, latestTokenPrices map[common.Address]update) (commit_store.InternalPriceUpdates, error) { priceObservations := make(map[common.Address][]*big.Int) - var sourceGasObservations []*big.Int + var sourceGasObservations []prices.GasPrice for _, obs := range observations { - if obs.SourceGasPriceUSD != nil { - // Add only non-nil source gas price - sourceGasObservations = append(sourceGasObservations, obs.SourceGasPriceUSD) - } + sourceGasObservations = append(sourceGasObservations, obs.SourceGasPriceUSD) // iterate over any token which price is included in observations for token, price := range obs.TokenPricesUSD { - if price == nil { - continue - } priceObservations[token] = append(priceObservations[token], price) } } var tokenPriceUpdates []commit_store.InternalTokenPriceUpdate for token, tokenPriceObservations := range priceObservations { - // If majority report a token price, include it in the update - if len(tokenPriceObservations) <= r.F { - continue - } medianPrice := ccipcalc.BigIntMedian(tokenPriceObservations) latestTokenPrice, exists := latestTokenPrices[token] if exists { - tokenPriceUpdatedRecently := time.Since(latestTokenPrice.timestamp) < r.offchainConfig.FeeUpdateHeartBeat.Duration() - tokenPriceNotChanged := !ccipcalc.Deviates(medianPrice, latestTokenPrice.value, int64(r.offchainConfig.FeeUpdateDeviationPPB)) + tokenPriceUpdatedRecently := time.Since(latestTokenPrice.timestamp) < r.offchainConfig.TokenPriceHeartBeat.Duration() + tokenPriceNotChanged := !ccipcalc.Deviates(medianPrice, latestTokenPrice.value, int64(r.offchainConfig.TokenPriceDeviationPPB)) if tokenPriceUpdatedRecently && tokenPriceNotChanged { r.lggr.Debugw("price was updated recently, skipping the update", "token", token, "newPrice", medianPrice, "existingPrice", latestTokenPrice.value) @@ -616,28 +669,29 @@ func (r *CommitReportingPlugin) calculatePriceUpdates(observations []CommitObser return bytes.Compare(tokenPriceUpdates[i].SourceToken[:], tokenPriceUpdates[j].SourceToken[:]) == -1 }) - usdPerUnitGas := big.NewInt(0) - destChainSelector := uint64(0) - - if len(sourceGasObservations) > r.F { - usdPerUnitGas = ccipcalc.BigIntMedian(sourceGasObservations) // Compute the median price - destChainSelector = r.config.sourceChainSelector // Assuming plugin lane is A->B, we write to B the gas price of A + newGasPrice, err := r.gasPriceEstimator.Median(sourceGasObservations) // Compute the median price + if err != nil { + return commit_store.InternalPriceUpdates{}, err + } + destChainSelector := r.config.sourceChainSelector // Assuming plugin lane is A->B, we write to B the gas price of A - if latestGasPrice.value != nil { - gasPriceUpdatedRecently := time.Since(latestGasPrice.timestamp) < r.offchainConfig.FeeUpdateHeartBeat.Duration() - gasPriceNotChanged := !ccipcalc.Deviates(usdPerUnitGas, latestGasPrice.value, int64(r.offchainConfig.FeeUpdateDeviationPPB)) - if gasPriceUpdatedRecently && gasPriceNotChanged { - usdPerUnitGas = big.NewInt(0) - destChainSelector = uint64(0) - } + if latestGasPrice.value != nil { + gasPriceUpdatedRecently := time.Since(latestGasPrice.timestamp) < r.offchainConfig.GasPriceHeartBeat.Duration() + gasPriceDeviated, err := r.gasPriceEstimator.Deviates(newGasPrice, latestGasPrice.value) + if err != nil { + return commit_store.InternalPriceUpdates{}, err + } + if gasPriceUpdatedRecently && !gasPriceDeviated { + newGasPrice = big.NewInt(0) + destChainSelector = uint64(0) } } return commit_store.InternalPriceUpdates{ TokenPriceUpdates: tokenPriceUpdates, DestChainSelector: destChainSelector, - UsdPerUnitGas: usdPerUnitGas, // we MUST pass zero to skip the update (never nil) - } + UsdPerUnitGas: newGasPrice, // we MUST pass zero to skip the update (never nil) + }, nil } // buildReport assumes there is at least one message in reqs. @@ -816,17 +870,26 @@ func (r *CommitReportingPlugin) isStaleMerkleRoot(ctx context.Context, lggr logg } func (r *CommitReportingPlugin) isStaleGasPrice(ctx context.Context, lggr logger.Logger, priceUpdates commit_store.InternalPriceUpdates, checkInflight bool) bool { - gasPriceUpdate, err := r.getLatestGasPriceUpdate(ctx, time.Now(), checkInflight) + latestGasPrice, err := r.getLatestGasPriceUpdate(ctx, time.Now(), checkInflight) if err != nil { + lggr.Errorw("Report is stale because getLatestGasPriceUpdate failed", "err", err) return true } - if gasPriceUpdate.value != nil && !ccipcalc.Deviates(priceUpdates.UsdPerUnitGas, gasPriceUpdate.value, int64(r.offchainConfig.FeeUpdateDeviationPPB)) { - lggr.Infow("Report is stale because of gas price", - "latestGasPriceUpdate", gasPriceUpdate.value, - "usdPerUnitGas", priceUpdates.UsdPerUnitGas, - "destChainSelector", priceUpdates.DestChainSelector) - return true + if latestGasPrice.value != nil { + gasPriceDeviated, err := r.gasPriceEstimator.Deviates(priceUpdates.UsdPerUnitGas, latestGasPrice.value) + if err != nil { + lggr.Errorw("Report is stale because deviation check failed", "err", err) + return true + } + + if !gasPriceDeviated { + lggr.Infow("Report is stale because of gas price", + "latestGasPriceUpdate", latestGasPrice.value, + "usdPerUnitGas", priceUpdates.UsdPerUnitGas, + "destChainSelector", priceUpdates.DestChainSelector) + return true + } } return false @@ -842,7 +905,7 @@ func (r *CommitReportingPlugin) isStaleTokenPrices(ctx context.Context, lggr log for _, tokenUpdate := range priceUpdates { latestUpdate, ok := latestTokenPriceUpdates[tokenUpdate.SourceToken] - priceEqual := ok && !ccipcalc.Deviates(tokenUpdate.UsdPerToken, latestUpdate.value, int64(r.offchainConfig.FeeUpdateDeviationPPB)) + priceEqual := ok && !ccipcalc.Deviates(tokenUpdate.UsdPerToken, latestUpdate.value, int64(r.offchainConfig.TokenPriceDeviationPPB)) if !priceEqual { lggr.Infow("Found non-stale token price", "token", tokenUpdate.SourceToken, "usdPerToken", tokenUpdate.UsdPerToken, "latestUpdate", latestUpdate.value) diff --git a/core/services/ocr2/plugins/ccip/commit_reporting_plugin_test.go b/core/services/ocr2/plugins/ccip/commit_reporting_plugin_test.go index fe384f93e3..baa2cc2bad 100644 --- a/core/services/ocr2/plugins/ccip/commit_reporting_plugin_test.go +++ b/core/services/ocr2/plugins/ccip/commit_reporting_plugin_test.go @@ -2,6 +2,7 @@ package ccip import ( "context" + "encoding/json" "fmt" "math/big" "math/rand" @@ -10,6 +11,7 @@ import ( "testing" "time" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/leanovate/gopter" @@ -21,8 +23,6 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - "github.com/smartcontractkit/chainlink/v2/core/assets" - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/mocks" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/commit_store" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/price_registry" @@ -31,10 +31,12 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/abihelpers" ccipconfig "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/config" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/cache" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/hashlib" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/merklemulti" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/pricegetter" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/prices" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/testhelpers" "github.com/smartcontractkit/chainlink/v2/core/store/models" @@ -119,10 +121,12 @@ func TestCommitReportingPlugin_Observation(t *testing.T) { priceGet.On("TokenPricesUSD", mock.Anything, addrs).Return(tc.tokenPrices, nil) } - sourceFeeEst := mocks.NewEvmFeeEstimator(t) + gasPriceEstimator := prices.NewMockGasPriceEstimatorCommit(t) if tc.fee != nil { - sourceFeeEst.On("GetFee", ctx, []byte(nil), uint32(0), assets.NewWei(big.NewInt(0))). - Return(gas.EvmFee{Legacy: assets.NewWei(tc.fee)}, uint32(0), nil) + var p prices.GasPrice = tc.fee + var pUSD prices.GasPrice = ccipcalc.CalculateUsdPerUnitGas(p, tc.tokenPrices[sourceNativeTokenAddr]) + gasPriceEstimator.On("GetGasPrice", ctx).Return(p, nil) + gasPriceEstimator.On("DenoteInUSD", p, tc.tokenPrices[sourceNativeTokenAddr]).Return(pUSD, nil) } p := &CommitReportingPlugin{} @@ -133,8 +137,8 @@ func TestCommitReportingPlugin_Observation(t *testing.T) { p.config.onRampReader = onRampReader p.tokenDecimalsCache = tokenDecimalsCache p.config.priceGetter = priceGet - p.config.sourceFeeEstimator = sourceFeeEst p.config.sourceNative = sourceNativeTokenAddr + p.gasPriceEstimator = gasPriceEstimator obs, err := p.Observation(ctx, tc.epochAndRound, types.Query{}) @@ -152,12 +156,38 @@ func TestCommitReportingPlugin_Observation(t *testing.T) { } func TestCommitReportingPlugin_Report(t *testing.T) { + ctx := testutils.Context(t) + sourceChainSelector := rand.Int() + var gasPrice prices.GasPrice = big.NewInt(1) + gasPriceHeartBeat := models.MustMakeDuration(time.Hour) + + t.Run("not enough observations", func(t *testing.T) { + tokenDecimalsCache := cache.NewMockAutoSync[map[common.Address]uint8](t) + tokenDecimalsCache.On("Get", ctx).Return(make(map[common.Address]uint8), nil) + + p := &CommitReportingPlugin{} + p.lggr = logger.TestLogger(t) + p.tokenDecimalsCache = tokenDecimalsCache + p.F = 1 + + o := CommitObservation{Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}, SourceGasPriceUSD: big.NewInt(0)} + obs, err := o.Marshal() + assert.NoError(t, err) + + aos := []types.AttributedObservation{{Observation: obs}} + + gotSomeReport, gotReport, err := p.Report(ctx, types.ReportTimestamp{}, types.Query{}, aos) + assert.False(t, gotSomeReport) + assert.Nil(t, gotReport) + assert.Error(t, err) + }) testCases := []struct { name string observations []CommitObservation f int gasPriceUpdates []ccipdata.Event[price_registry.PriceRegistryUsdPerUnitGasUpdated] + tokenDecimals map[common.Address]uint8 tokenPriceUpdates []ccipdata.Event[price_registry.PriceRegistryUsdPerTokenUpdated] sendRequests []ccipdata.Event[ccipdata.EVM2EVMMessage] @@ -168,8 +198,8 @@ func TestCommitReportingPlugin_Report(t *testing.T) { { name: "base", observations: []CommitObservation{ - {Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}}, - {Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}}, + {Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}, SourceGasPriceUSD: gasPrice}, + {Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}, SourceGasPriceUSD: gasPrice}, }, f: 1, sendRequests: []ccipdata.Event[ccipdata.EVM2EVMMessage]{ @@ -179,33 +209,39 @@ func TestCommitReportingPlugin_Report(t *testing.T) { }, }, }, + gasPriceUpdates: []ccipdata.Event[price_registry.PriceRegistryUsdPerUnitGasUpdated]{ + { + Data: price_registry.PriceRegistryUsdPerUnitGasUpdated{ + Value: big.NewInt(1), + Timestamp: big.NewInt(time.Now().Add(-2 * gasPriceHeartBeat.Duration()).Unix()), + }, + }, + }, expSeqNumRange: commit_store.CommitStoreInterval{Min: 1, Max: 1}, expCommitReport: &commit_store.CommitStoreCommitReport{ MerkleRoot: [32]byte{}, Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}, PriceUpdates: commit_store.InternalPriceUpdates{ TokenPriceUpdates: nil, - DestChainSelector: 0, - UsdPerUnitGas: big.NewInt(0), + DestChainSelector: uint64(sourceChainSelector), + UsdPerUnitGas: gasPrice, }, }, expErr: false, }, - { - name: "not enough observations", - observations: []CommitObservation{ - {Interval: commit_store.CommitStoreInterval{Min: 1, Max: 1}}, - }, - f: 1, - sendRequests: []ccipdata.Event[ccipdata.EVM2EVMMessage]{{}}, - expSeqNumRange: commit_store.CommitStoreInterval{Min: 1, Max: 1}, - expErr: true, - }, { name: "empty", observations: []CommitObservation{ - {Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}}, - {Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}}, + {Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, SourceGasPriceUSD: big.NewInt(0)}, + {Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, SourceGasPriceUSD: big.NewInt(0)}, + }, + gasPriceUpdates: []ccipdata.Event[price_registry.PriceRegistryUsdPerUnitGasUpdated]{ + { + Data: price_registry.PriceRegistryUsdPerUnitGasUpdated{ + Value: big.NewInt(1), + Timestamp: big.NewInt(time.Now().Add(-gasPriceHeartBeat.Duration() / 2).Unix()), + }, + }, }, f: 1, expErr: false, @@ -213,8 +249,8 @@ func TestCommitReportingPlugin_Report(t *testing.T) { { name: "no leaves", observations: []CommitObservation{ - {Interval: commit_store.CommitStoreInterval{Min: 2, Max: 2}}, - {Interval: commit_store.CommitStoreInterval{Min: 2, Max: 2}}, + {Interval: commit_store.CommitStoreInterval{Min: 2, Max: 2}, SourceGasPriceUSD: big.NewInt(0)}, + {Interval: commit_store.CommitStoreInterval{Min: 2, Max: 2}, SourceGasPriceUSD: big.NewInt(0)}, }, f: 1, sendRequests: []ccipdata.Event[ccipdata.EVM2EVMMessage]{{}}, @@ -223,9 +259,6 @@ func TestCommitReportingPlugin_Report(t *testing.T) { }, } - ctx := testutils.Context(t) - sourceChainSelector := rand.Int() - for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { destPriceRegistry, destPriceRegistryAddress := testhelpers.NewFakePriceRegistry(t) @@ -239,6 +272,15 @@ func TestCommitReportingPlugin_Report(t *testing.T) { onRampReader.On("GetSendRequestsBetweenSeqNums", ctx, tc.expSeqNumRange.Min, tc.expSeqNumRange.Max, 0).Return(tc.sendRequests, nil) } + gasPriceEstimator := prices.NewMockGasPriceEstimatorCommit(t) + gasPriceEstimator.On("Median", mock.Anything).Return(gasPrice, nil) + if tc.gasPriceUpdates != nil { + gasPriceEstimator.On("Deviates", mock.Anything, mock.Anything, mock.Anything).Return(false, nil) + } + + tokenDecimalsCache := cache.NewMockAutoSync[map[common.Address]uint8](t) + tokenDecimalsCache.On("Get", ctx).Return(tc.tokenDecimals, nil) + p := &CommitReportingPlugin{} p.lggr = logger.TestLogger(t) p.inflightReports = newInflightCommitReportsContainer(time.Minute) @@ -246,6 +288,10 @@ func TestCommitReportingPlugin_Report(t *testing.T) { p.config.destReader = destReader p.config.onRampReader = onRampReader p.config.sourceChainSelector = uint64(sourceChainSelector) + p.tokenDecimalsCache = tokenDecimalsCache + p.gasPriceEstimator = gasPriceEstimator + p.offchainConfig.GasPriceHeartBeat = gasPriceHeartBeat + p.F = tc.f aos := make([]types.AttributedObservation, 0, len(tc.observations)) for _, o := range tc.observations { @@ -259,6 +305,7 @@ func TestCommitReportingPlugin_Report(t *testing.T) { assert.Error(t, err) return } + assert.NoError(t, err) if tc.expCommitReport != nil { @@ -411,6 +458,163 @@ func TestCommitReportingPlugin_ShouldTransmitAcceptedReport(t *testing.T) { }) } +func TestCommitReportingPlugin_validateObservations(t *testing.T) { + ctx := context.Background() + + token1 := common.HexToAddress("0xa") + token2 := common.HexToAddress("0xb") + token1Price := big.NewInt(1) + token2Price := big.NewInt(2) + unsupportedToken := common.HexToAddress("0xc") + gasPrice := big.NewInt(100) + + tokenDecimals := make(map[common.Address]uint8) + tokenDecimals[token1] = 18 + tokenDecimals[token2] = 18 + + ob1 := CommitObservation{ + Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, + TokenPricesUSD: map[common.Address]*big.Int{ + token1: token1Price, + token2: token2Price, + }, + SourceGasPriceUSD: gasPrice, + } + ob1Bytes, err := ob1.Marshal() + assert.NoError(t, err) + var ob2, ob3 CommitObservation + _ = json.Unmarshal(ob1Bytes, &ob2) + _ = json.Unmarshal(ob1Bytes, &ob3) + + obWithNilGasPrice := CommitObservation{ + Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, + TokenPricesUSD: map[common.Address]*big.Int{ + token1: token1Price, + token2: token2Price, + }, + SourceGasPriceUSD: nil, + } + obWithNilTokenPrice := CommitObservation{ + Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, + TokenPricesUSD: map[common.Address]*big.Int{ + token1: token1Price, + token2: nil, + }, + SourceGasPriceUSD: gasPrice, + } + obMissingTokenPrices := CommitObservation{ + Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, + TokenPricesUSD: map[common.Address]*big.Int{}, + SourceGasPriceUSD: gasPrice, + } + obWithUnsupportedToken := CommitObservation{ + Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, + TokenPricesUSD: map[common.Address]*big.Int{ + token1: token1Price, + token2: token2Price, + unsupportedToken: token2Price, + }, + SourceGasPriceUSD: gasPrice, + } + obEmpty := CommitObservation{ + Interval: commit_store.CommitStoreInterval{Min: 0, Max: 0}, + TokenPricesUSD: nil, + SourceGasPriceUSD: nil, + } + + testCases := []struct { + name string + commitObservations []CommitObservation + f int + expValidObs []CommitObservation + expError bool + }{ + { + name: "base", + commitObservations: []CommitObservation{ob1, ob2}, + f: 1, + expValidObs: []CommitObservation{ob1, ob2}, + expError: false, + }, + { + name: "pass with f=2", + commitObservations: []CommitObservation{ob1, ob2, ob3}, + f: 2, + expValidObs: []CommitObservation{ob1, ob2, ob3}, + expError: false, + }, + { + name: "tolerate 1 nil gas price with f=2", + commitObservations: []CommitObservation{ob1, ob2, ob3, obWithNilGasPrice}, + f: 2, + expValidObs: []CommitObservation{ob1, ob2, ob3}, + expError: false, + }, + { + name: "tolerate 1 nil token price with f=1", + commitObservations: []CommitObservation{ob1, ob2, obWithNilTokenPrice}, + f: 1, + expValidObs: []CommitObservation{ob1, ob2}, + expError: false, + }, + { + name: "tolerate 1 missing token prices with f=1", + commitObservations: []CommitObservation{ob1, ob2, obMissingTokenPrices}, + f: 1, + expValidObs: []CommitObservation{ob1, ob2}, + expError: false, + }, + { + name: "tolerate 1 unsupported token with f=1", + commitObservations: []CommitObservation{ob1, ob2, obWithUnsupportedToken}, + f: 1, + expValidObs: []CommitObservation{ob1, ob2}, + expError: false, + }, + { + name: "not enough valid observations", + commitObservations: []CommitObservation{ob1, ob2}, + f: 2, + expValidObs: nil, + expError: true, + }, + { + name: "too many faulty observations with f=2", + commitObservations: []CommitObservation{ob1, ob2, obMissingTokenPrices, obWithUnsupportedToken}, + f: 2, + expValidObs: nil, + expError: true, + }, + { + name: "too many faulty observations with f=1", + commitObservations: []CommitObservation{ob1, obEmpty}, + f: 1, + expValidObs: nil, + expError: true, + }, + { + name: "all faulty observations", + commitObservations: []CommitObservation{obWithNilGasPrice, obWithNilTokenPrice, obMissingTokenPrices, obWithUnsupportedToken, obEmpty}, + f: 1, + expValidObs: nil, + expError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + obs, err := validateObservations(ctx, logger.TestLogger(t), tokenDecimals, tc.f, tc.commitObservations) + + if tc.expError { + assert.Error(t, err) + return + } + assert.Equal(t, tc.expValidObs, obs) + assert.NoError(t, err) + }) + } +} + func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { const defaultSourceChainSelector = 10 // we reuse this value across all test cases feeToken1 := common.HexToAddress("0xa") @@ -420,16 +624,19 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { val1e18 := func(val int64) *big.Int { return new(big.Int).Mul(big.NewInt(1e18), big.NewInt(val)) } testCases := []struct { - name string - commitObservations []CommitObservation - f int - latestGasPrice update - latestTokenPrices map[common.Address]update - feeUpdateHeartBeat models.Duration - feeUpdateDeviationPPB uint32 - expGas *big.Int - expTokenUpdates []commit_store.InternalTokenPriceUpdate - expDestChainSel uint64 + name string + commitObservations []CommitObservation + f int + latestGasPrice update + latestTokenPrices map[common.Address]update + gasPriceHeartBeat models.Duration + daGasPriceDeviationPPB int64 + execGasPriceDeviationPPB int64 + tokenPriceHeartBeat models.Duration + tokenPriceDeviationPPB uint32 + expGas *big.Int + expTokenUpdates []commit_store.InternalTokenPriceUpdate + expDestChainSel uint64 }{ { name: "median", @@ -443,35 +650,17 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { expGas: big.NewInt(3), expDestChainSel: defaultSourceChainSelector, }, - { - name: "insufficient", - commitObservations: []CommitObservation{ - {SourceGasPriceUSD: nil}, - {SourceGasPriceUSD: nil}, - {SourceGasPriceUSD: big.NewInt(3)}, - }, - f: 1, - expGas: big.NewInt(0), - }, - { - name: "median including empties", - commitObservations: []CommitObservation{ - {SourceGasPriceUSD: nil}, - {SourceGasPriceUSD: big.NewInt(1)}, - {SourceGasPriceUSD: big.NewInt(2)}, - }, - f: 1, - expGas: big.NewInt(2), - expDestChainSel: defaultSourceChainSelector, - }, { name: "gas price update skipped because the latest is similar and was updated recently", commitObservations: []CommitObservation{ {SourceGasPriceUSD: val1e18(10)}, {SourceGasPriceUSD: val1e18(11)}, }, - feeUpdateHeartBeat: models.MustMakeDuration(time.Hour), - feeUpdateDeviationPPB: 20e7, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 20e7, + execGasPriceDeviationPPB: 20e7, + tokenPriceHeartBeat: models.MustMakeDuration(time.Hour), + tokenPriceDeviationPPB: 20e7, latestGasPrice: update{ timestamp: time.Now().Add(-30 * time.Minute), // recent value: val1e18(9), // latest value close to the update @@ -486,8 +675,11 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { {SourceGasPriceUSD: val1e18(10)}, {SourceGasPriceUSD: val1e18(11)}, }, - feeUpdateHeartBeat: models.MustMakeDuration(time.Hour), - feeUpdateDeviationPPB: 20e7, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 20e7, + execGasPriceDeviationPPB: 20e7, + tokenPriceHeartBeat: models.MustMakeDuration(time.Hour), + tokenPriceDeviationPPB: 20e7, latestGasPrice: update{ timestamp: time.Now().Add(-90 * time.Minute), // recent value: val1e18(9), // latest value close to the update @@ -503,8 +695,11 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { {SourceGasPriceUSD: val1e18(20)}, {SourceGasPriceUSD: val1e18(20)}, }, - feeUpdateHeartBeat: models.MustMakeDuration(time.Hour), - feeUpdateDeviationPPB: 20e7, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 20e7, + execGasPriceDeviationPPB: 20e7, + tokenPriceHeartBeat: models.MustMakeDuration(time.Hour), + tokenPriceDeviationPPB: 20e7, latestGasPrice: update{ timestamp: time.Now().Add(-30 * time.Minute), // recent value: val1e18(11), // latest value close to the update @@ -516,22 +711,21 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { { name: "median one token", commitObservations: []CommitObservation{ - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(10)}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(12)}}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(10)}, SourceGasPriceUSD: val1e18(0)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(12)}, SourceGasPriceUSD: val1e18(0)}, }, f: 1, expTokenUpdates: []commit_store.InternalTokenPriceUpdate{ {SourceToken: feeToken1, UsdPerToken: big.NewInt(12)}, }, expGas: zero, - expDestChainSel: 0, + expDestChainSel: defaultSourceChainSelector, }, { - name: "median two tokens, including nil", + name: "median two tokens", commitObservations: []CommitObservation{ - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: nil, feeToken2: nil}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(10), feeToken2: big.NewInt(13)}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(12), feeToken2: big.NewInt(7)}}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(10), feeToken2: big.NewInt(13)}, SourceGasPriceUSD: val1e18(0)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(12), feeToken2: big.NewInt(7)}, SourceGasPriceUSD: val1e18(0)}, }, f: 1, expTokenUpdates: []commit_store.InternalTokenPriceUpdate{ @@ -539,75 +733,132 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { {SourceToken: feeToken2, UsdPerToken: big.NewInt(13)}, }, expGas: zero, - expDestChainSel: 0, + expDestChainSel: defaultSourceChainSelector, }, { - name: "only one token with enough votes", + name: "token price update skipped because it is close to the latest", commitObservations: []CommitObservation{ - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(10)}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: big.NewInt(12), feeToken2: big.NewInt(7)}}, - }, - f: 1, - expTokenUpdates: []commit_store.InternalTokenPriceUpdate{ - {SourceToken: feeToken1, UsdPerToken: big.NewInt(12)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(10)}, SourceGasPriceUSD: val1e18(0)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(11)}, SourceGasPriceUSD: val1e18(0)}, + }, + f: 1, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 20e7, + execGasPriceDeviationPPB: 20e7, + tokenPriceHeartBeat: models.MustMakeDuration(time.Hour), + tokenPriceDeviationPPB: 20e7, + latestTokenPrices: map[common.Address]update{ + feeToken1: { + timestamp: time.Now().Add(-30 * time.Minute), + value: val1e18(9), + }, }, expGas: zero, - expDestChainSel: 0, + expDestChainSel: defaultSourceChainSelector, }, { - name: "token price update skipped because it is close to the latest", + name: "gas price and token price both included because they are not close to the latest", commitObservations: []CommitObservation{ - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(10)}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(11), feeToken2: val1e18(7)}}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(20)}, SourceGasPriceUSD: val1e18(10)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(21)}, SourceGasPriceUSD: val1e18(11)}, + }, + f: 1, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 10e7, + execGasPriceDeviationPPB: 10e7, + tokenPriceHeartBeat: models.MustMakeDuration(time.Hour), + tokenPriceDeviationPPB: 20e7, + latestGasPrice: update{ + timestamp: time.Now().Add(-30 * time.Minute), + value: val1e18(9), }, - f: 1, - feeUpdateHeartBeat: models.MustMakeDuration(time.Hour), - feeUpdateDeviationPPB: 20e7, latestTokenPrices: map[common.Address]update{ feeToken1: { timestamp: time.Now().Add(-30 * time.Minute), value: val1e18(9), }, }, - expGas: zero, - expDestChainSel: 0, + expTokenUpdates: []commit_store.InternalTokenPriceUpdate{ + {SourceToken: feeToken1, UsdPerToken: val1e18(21)}, + }, + expGas: val1e18(11), + expDestChainSel: defaultSourceChainSelector, }, { - name: "token price update is close to the latest but included because it has not been updated recently", + name: "gas price and token price both included because they not been updated recently", commitObservations: []CommitObservation{ - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(10)}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(11), feeToken2: val1e18(7)}}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(20)}, SourceGasPriceUSD: val1e18(10)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(21)}, SourceGasPriceUSD: val1e18(11)}, + }, + f: 1, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 10e7, + execGasPriceDeviationPPB: 10e7, + tokenPriceHeartBeat: models.MustMakeDuration(2 * time.Hour), + tokenPriceDeviationPPB: 20e7, + latestGasPrice: update{ + timestamp: time.Now().Add(-90 * time.Minute), + value: val1e18(11), }, - f: 1, - feeUpdateHeartBeat: models.MustMakeDuration(50 * time.Minute), - feeUpdateDeviationPPB: 20e7, latestTokenPrices: map[common.Address]update{ feeToken1: { - timestamp: time.Now().Add(-1 * time.Hour), - value: val1e18(9), + timestamp: time.Now().Add(-4 * time.Hour), + value: val1e18(21), }, }, expTokenUpdates: []commit_store.InternalTokenPriceUpdate{ - {SourceToken: feeToken1, UsdPerToken: val1e18(11)}, + {SourceToken: feeToken1, UsdPerToken: val1e18(21)}, }, - expGas: zero, - expDestChainSel: 0, + expGas: val1e18(11), + expDestChainSel: defaultSourceChainSelector, }, { - name: "token price update included because it is not close to the latest", + name: "gas price included because it deviates from latest and token price skipped because it does not deviate", commitObservations: []CommitObservation{ - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(20)}}, - {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(21), feeToken2: val1e18(7)}}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(20)}, SourceGasPriceUSD: val1e18(10)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(21)}, SourceGasPriceUSD: val1e18(11)}, + }, + f: 1, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 10e7, + execGasPriceDeviationPPB: 10e7, + tokenPriceHeartBeat: models.MustMakeDuration(2 * time.Hour), + tokenPriceDeviationPPB: 200e7, + latestGasPrice: update{ + timestamp: time.Now().Add(-30 * time.Minute), + value: val1e18(9), }, - f: 1, - feeUpdateHeartBeat: models.MustMakeDuration(time.Hour), - feeUpdateDeviationPPB: 20e7, latestTokenPrices: map[common.Address]update{ feeToken1: { timestamp: time.Now().Add(-30 * time.Minute), value: val1e18(9), }, }, + expGas: val1e18(11), + expDestChainSel: defaultSourceChainSelector, + }, + { + name: "gas price skipped because it does not deviate and token price included because it has not been updated recently", + commitObservations: []CommitObservation{ + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(20)}, SourceGasPriceUSD: val1e18(10)}, + {TokenPricesUSD: map[common.Address]*big.Int{feeToken1: val1e18(21)}, SourceGasPriceUSD: val1e18(11)}, + }, + f: 1, + gasPriceHeartBeat: models.MustMakeDuration(time.Hour), + daGasPriceDeviationPPB: 10e7, + execGasPriceDeviationPPB: 10e7, + tokenPriceHeartBeat: models.MustMakeDuration(2 * time.Hour), + tokenPriceDeviationPPB: 20e7, + latestGasPrice: update{ + timestamp: time.Now().Add(-30 * time.Minute), + value: val1e18(11), + }, + latestTokenPrices: map[common.Address]update{ + feeToken1: { + timestamp: time.Now().Add(-4 * time.Hour), + value: val1e18(21), + }, + }, expTokenUpdates: []commit_store.InternalTokenPriceUpdate{ {SourceToken: feeToken1, UsdPerToken: val1e18(21)}, }, @@ -616,22 +867,37 @@ func TestCommitReportingPlugin_calculatePriceUpdates(t *testing.T) { }, } + evmEstimator := mocks.NewEvmFeeEstimator(t) + evmEstimator.On("L1Oracle").Return(nil) + estimatorCSVer, _ := semver.NewVersion("1.2.0") + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + estimator, _ := prices.NewGasPriceEstimatorForCommitPlugin( + *estimatorCSVer, + evmEstimator, + nil, + tc.daGasPriceDeviationPPB, + tc.execGasPriceDeviationPPB, + ) + r := &CommitReportingPlugin{ lggr: logger.TestLogger(t), config: CommitPluginConfig{sourceChainSelector: defaultSourceChainSelector}, offchainConfig: ccipconfig.CommitOffchainConfig{ - FeeUpdateHeartBeat: tc.feeUpdateHeartBeat, - FeeUpdateDeviationPPB: tc.feeUpdateDeviationPPB, + GasPriceHeartBeat: tc.gasPriceHeartBeat, + TokenPriceHeartBeat: tc.tokenPriceHeartBeat, + TokenPriceDeviationPPB: tc.tokenPriceDeviationPPB, }, - F: tc.f, + gasPriceEstimator: estimator, + F: tc.f, } - got := r.calculatePriceUpdates(tc.commitObservations, tc.latestGasPrice, tc.latestTokenPrices) + got, err := r.calculatePriceUpdates(tc.commitObservations, tc.latestGasPrice, tc.latestTokenPrices) assert.Equal(t, tc.expGas, got.UsdPerUnitGas) assert.Equal(t, tc.expTokenUpdates, got.TokenPriceUpdates) assert.Equal(t, tc.expDestChainSel, got.DestChainSelector) + assert.NoError(t, err) }) } } @@ -647,17 +913,17 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { sort.Slice(tokens, func(i, j int) bool { return tokens[i].String() < tokens[j].String() }) testCases := []struct { - name string - tokenDecimals map[common.Address]uint8 - sourceNativeToken common.Address - priceGetterRespData map[common.Address]*big.Int - priceGetterRespErr error - sourceFeeEstimatorRespFee gas.EvmFee - sourceFeeEstimatorRespErr error - maxGasPrice uint64 - expSourceGasPriceUSD *big.Int - expTokenPricesUSD map[common.Address]*big.Int - expErr bool + name string + tokenDecimals map[common.Address]uint8 + sourceNativeToken common.Address + priceGetterRespData map[common.Address]*big.Int + priceGetterRespErr error + feeEstimatorRespFee prices.GasPrice + feeEstimatorRespErr error + maxGasPrice uint64 + expSourceGasPriceUSD *big.Int + expTokenPricesUSD map[common.Address]*big.Int + expErr bool }{ { name: "base", @@ -671,15 +937,11 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { tokens[1]: val1e18(200), tokens[2]: val1e18(300), // price getter returned a price for this token even though we didn't request it (should be skipped) }, - priceGetterRespErr: nil, - sourceFeeEstimatorRespFee: gas.EvmFee{ - Legacy: assets.NewWei(big.NewInt(10)), - DynamicFeeCap: nil, - DynamicTipCap: nil, - }, - sourceFeeEstimatorRespErr: nil, - maxGasPrice: 1e18, - expSourceGasPriceUSD: big.NewInt(1000), + priceGetterRespErr: nil, + feeEstimatorRespFee: big.NewInt(10), + feeEstimatorRespErr: nil, + maxGasPrice: 1e18, + expSourceGasPriceUSD: big.NewInt(1000), expTokenPricesUSD: map[common.Address]*big.Int{ tokens[0]: val1e18(100), tokens[1]: val1e18(200), @@ -736,15 +998,11 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { tokens[1]: val1e18(200), tokens[2]: val1e18(300), // price getter returned a price for this token even though we didn't request it }, - priceGetterRespErr: nil, - sourceFeeEstimatorRespFee: gas.EvmFee{ - Legacy: assets.NewWei(big.NewInt(10)), - DynamicFeeCap: nil, - DynamicTipCap: nil, - }, - sourceFeeEstimatorRespErr: nil, - maxGasPrice: 1e18, - expSourceGasPriceUSD: big.NewInt(1000), + priceGetterRespErr: nil, + feeEstimatorRespFee: big.NewInt(10), + feeEstimatorRespErr: nil, + maxGasPrice: 1e18, + expSourceGasPriceUSD: big.NewInt(1000), expTokenPricesUSD: map[common.Address]*big.Int{ tokens[0]: val1e18(100), tokens[1]: val1e18(200), @@ -763,15 +1021,11 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { tokens[1]: val1e18(200), tokens[2]: val1e18(300), // price getter returned a price for this token even though we didn't request it (should be skipped) }, - priceGetterRespErr: nil, - sourceFeeEstimatorRespFee: gas.EvmFee{ - Legacy: assets.NewWei(big.NewInt(10)), - DynamicFeeCap: assets.NewWei(big.NewInt(20)), - DynamicTipCap: nil, - }, - sourceFeeEstimatorRespErr: nil, - maxGasPrice: 1e18, - expSourceGasPriceUSD: big.NewInt(2000), + priceGetterRespErr: nil, + feeEstimatorRespFee: big.NewInt(20), + feeEstimatorRespErr: nil, + maxGasPrice: 1e18, + expSourceGasPriceUSD: big.NewInt(2000), expTokenPricesUSD: map[common.Address]*big.Int{ tokens[0]: val1e18(100), tokens[1]: val1e18(200), @@ -790,13 +1044,9 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { tokens[1]: val1e18(200), tokens[2]: val1e18(300), // price getter returned a price for this token even though we didn't request it (should be skipped) }, - sourceFeeEstimatorRespFee: gas.EvmFee{ - Legacy: nil, - DynamicFeeCap: nil, - DynamicTipCap: nil, - }, - maxGasPrice: 1e18, - expErr: true, + feeEstimatorRespFee: nil, + maxGasPrice: 1e18, + expErr: true, }, } @@ -805,8 +1055,8 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { priceGetter := pricegetter.NewMockPriceGetter(t) defer priceGetter.AssertExpectations(t) - sourceFeeEstimator := mocks.NewEvmFeeEstimator(t) - defer sourceFeeEstimator.AssertExpectations(t) + gasPriceEstimator := prices.NewMockGasPriceEstimatorCommit(t) + defer gasPriceEstimator.AssertExpectations(t) tokens := make([]common.Address, 0, len(tc.tokenDecimals)) for tk := range tc.tokenDecimals { @@ -820,17 +1070,20 @@ func TestCommitReportingPlugin_generatePriceUpdates(t *testing.T) { } if tc.maxGasPrice > 0 { - sourceFeeEstimator.On("GetFee", mock.Anything, []byte(nil), uint32(0), assets.NewWei(big.NewInt(int64(tc.maxGasPrice)))).Return( - tc.sourceFeeEstimatorRespFee, uint32(0), tc.sourceFeeEstimatorRespErr) + gasPriceEstimator.On("GetGasPrice", mock.Anything).Return(tc.feeEstimatorRespFee, tc.feeEstimatorRespErr) + if tc.feeEstimatorRespFee != nil { + var pUSD prices.GasPrice = ccipcalc.CalculateUsdPerUnitGas(tc.feeEstimatorRespFee, tc.expTokenPricesUSD[tc.sourceNativeToken]) + gasPriceEstimator.On("DenoteInUSD", mock.Anything, mock.Anything).Return(pUSD, nil) + } } p := &CommitReportingPlugin{ config: CommitPluginConfig{ - sourceNative: tc.sourceNativeToken, - priceGetter: priceGetter, - sourceFeeEstimator: sourceFeeEstimator, + sourceNative: tc.sourceNativeToken, + priceGetter: priceGetter, }, - offchainConfig: ccipconfig.CommitOffchainConfig{MaxGasPrice: tc.maxGasPrice}, + offchainConfig: ccipconfig.CommitOffchainConfig{MaxGasPrice: tc.maxGasPrice}, + gasPriceEstimator: gasPriceEstimator, } sourceGasPriceUSD, tokenPricesUSD, err := p.generatePriceUpdates(context.Background(), logger.TestLogger(t), tc.tokenDecimals) @@ -1069,7 +1322,7 @@ func TestCommitReportingPlugin_getLatestGasPriceUpdate(t *testing.T) { { name: "inflight price is nil", checkInflight: true, - inflightGasPriceUpdate: &update{timestamp: now, value: nil}, + inflightGasPriceUpdate: nil, destGasPriceUpdates: []update{ {timestamp: now.Add(time.Minute), value: big.NewInt(2000)}, {timestamp: now.Add(2 * time.Minute), value: big.NewInt(3000)}, @@ -1292,7 +1545,6 @@ func Test_calculateIntervalConsensus(t *testing.T) { {Min: 10, Max: 12}, {Min: 10, Max: 14}, }, 0, 1, 10, 14, false}, - {"not enough intervals", []commit_store.CommitStoreInterval{}, 0, 1, 0, 0, true}, {"min > max", []commit_store.CommitStoreInterval{ {Min: 9, Max: 4}, {Min: 10, Max: 4}, diff --git a/core/services/ocr2/plugins/ccip/config/offchain_config.go b/core/services/ocr2/plugins/ccip/config/offchain_config.go index bdcb0e77cd..fcbb10edcb 100644 --- a/core/services/ocr2/plugins/ccip/config/offchain_config.go +++ b/core/services/ocr2/plugins/ccip/config/offchain_config.go @@ -15,6 +15,51 @@ type OffchainConfig interface { // Do not change the JSON format of this struct without consulting with // the RDD people first. type CommitOffchainConfig struct { + SourceFinalityDepth uint32 + DestFinalityDepth uint32 + GasPriceHeartBeat models.Duration + DAGasPriceDeviationPPB uint32 + ExecGasPriceDeviationPPB uint32 + TokenPriceHeartBeat models.Duration + TokenPriceDeviationPPB uint32 + MaxGasPrice uint64 + InflightCacheExpiry models.Duration +} + +func (c CommitOffchainConfig) Validate() error { + if c.SourceFinalityDepth == 0 { + return errors.New("must set SourceFinalityDepth") + } + if c.DestFinalityDepth == 0 { + return errors.New("must set DestFinalityDepth") + } + if c.GasPriceHeartBeat.Duration() == 0 { + return errors.New("must set GasPriceHeartBeat") + } + if c.DAGasPriceDeviationPPB == 0 { + return errors.New("must set DAGasPriceDeviationPPB") + } + if c.ExecGasPriceDeviationPPB == 0 { + return errors.New("must set ExecGasPriceDeviationPPB") + } + if c.TokenPriceHeartBeat.Duration() == 0 { + return errors.New("must set TokenPriceHeartBeat") + } + if c.TokenPriceDeviationPPB == 0 { + return errors.New("must set TokenPriceDeviationPPB") + } + if c.MaxGasPrice == 0 { + return errors.New("must set MaxGasPrice") + } + if c.InflightCacheExpiry.Duration() == 0 { + return errors.New("must set InflightCacheExpiry") + } + + return nil +} + +// CommitOffchainConfigV1 is a legacy version of CommitOffchainConfig, used for CommitStore version 1.0.0 and 1.1.0 +type CommitOffchainConfigV1 struct { SourceFinalityDepth uint32 DestFinalityDepth uint32 FeeUpdateHeartBeat models.Duration @@ -23,7 +68,7 @@ type CommitOffchainConfig struct { InflightCacheExpiry models.Duration } -func (c CommitOffchainConfig) Validate() error { +func (c CommitOffchainConfigV1) Validate() error { if c.SourceFinalityDepth == 0 { return errors.New("must set SourceFinalityDepth") } diff --git a/core/services/ocr2/plugins/ccip/config/offchain_config_test.go b/core/services/ocr2/plugins/ccip/config/offchain_config_test.go index 5edf21c176..0b36e869ee 100644 --- a/core/services/ocr2/plugins/ccip/config/offchain_config_test.go +++ b/core/services/ocr2/plugins/ccip/config/offchain_config_test.go @@ -16,22 +16,28 @@ func TestCommitOffchainConfig_Encoding(t *testing.T) { }{ "encodes and decodes config with all fields set": { want: CommitOffchainConfig{ - SourceFinalityDepth: 3, - DestFinalityDepth: 3, - FeeUpdateHeartBeat: models.MustMakeDuration(1 * time.Hour), - FeeUpdateDeviationPPB: 5e7, - MaxGasPrice: 200e9, - InflightCacheExpiry: models.MustMakeDuration(23456 * time.Second), + SourceFinalityDepth: 3, + DestFinalityDepth: 3, + GasPriceHeartBeat: models.MustMakeDuration(1 * time.Hour), + DAGasPriceDeviationPPB: 5e7, + ExecGasPriceDeviationPPB: 5e7, + TokenPriceHeartBeat: models.MustMakeDuration(1 * time.Hour), + TokenPriceDeviationPPB: 5e7, + MaxGasPrice: 200e9, + InflightCacheExpiry: models.MustMakeDuration(23456 * time.Second), }, }, "fails decoding when all fields present but with 0 values": { want: CommitOffchainConfig{ - SourceFinalityDepth: 0, - DestFinalityDepth: 0, - FeeUpdateHeartBeat: models.MustMakeDuration(0), - FeeUpdateDeviationPPB: 0, - MaxGasPrice: 0, - InflightCacheExpiry: models.MustMakeDuration(0), + SourceFinalityDepth: 0, + DestFinalityDepth: 0, + GasPriceHeartBeat: models.MustMakeDuration(0), + DAGasPriceDeviationPPB: 0, + ExecGasPriceDeviationPPB: 0, + TokenPriceHeartBeat: models.MustMakeDuration(0), + TokenPriceDeviationPPB: 0, + MaxGasPrice: 0, + InflightCacheExpiry: models.MustMakeDuration(0), }, expectErr: true, }, @@ -41,10 +47,13 @@ func TestCommitOffchainConfig_Encoding(t *testing.T) { }, "fails decoding when some fields are missing": { want: CommitOffchainConfig{ - SourceFinalityDepth: 3, - FeeUpdateHeartBeat: models.MustMakeDuration(1 * time.Hour), - FeeUpdateDeviationPPB: 5e7, - MaxGasPrice: 200e9, + SourceFinalityDepth: 3, + GasPriceHeartBeat: models.MustMakeDuration(1 * time.Hour), + DAGasPriceDeviationPPB: 5e7, + ExecGasPriceDeviationPPB: 5e7, + TokenPriceHeartBeat: models.MustMakeDuration(1 * time.Hour), + TokenPriceDeviationPPB: 5e7, + MaxGasPrice: 200e9, }, expectErr: true, }, diff --git a/core/services/ocr2/plugins/ccip/config/type_and_version.go b/core/services/ocr2/plugins/ccip/config/type_and_version.go index a06c00dd8d..0774f67509 100644 --- a/core/services/ocr2/plugins/ccip/config/type_and_version.go +++ b/core/services/ocr2/plugins/ccip/config/type_and_version.go @@ -24,15 +24,15 @@ var ( } ) -func VerifyTypeAndVersion(addr common.Address, client bind.ContractBackend, expectedType ContractType) error { - contractType, _, err := TypeAndVersion(addr, client) +func VerifyTypeAndVersion(addr common.Address, client bind.ContractBackend, expectedType ContractType) (semver.Version, error) { + contractType, version, err := TypeAndVersion(addr, client) if err != nil { - return errors.Errorf("failed getting type and version %v", err) + return semver.Version{}, errors.Errorf("failed getting type and version %v", err) } if contractType != expectedType { - return errors.Errorf("Wrong contract type %s", contractType) + return semver.Version{}, errors.Errorf("Wrong contract type %s", contractType) } - return nil + return version, nil } func TypeAndVersion(addr common.Address, client bind.ContractBackend) (ContractType, semver.Version, error) { diff --git a/core/services/ocr2/plugins/ccip/execution_gas_helpers.go b/core/services/ocr2/plugins/ccip/execution_gas_helpers.go index 108684d4d6..de880e01bd 100644 --- a/core/services/ocr2/plugins/ccip/execution_gas_helpers.go +++ b/core/services/ocr2/plugins/ccip/execution_gas_helpers.go @@ -4,8 +4,6 @@ import ( "math" "math/big" "time" - - "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" ) const ( @@ -28,6 +26,9 @@ const ( EXECUTION_STATE_PROCESSING_OVERHEAD_GAS = 2_100 + // COLD_SLOAD_COST for first reading the state 20_000 + // SSTORE_SET_GAS for writing from 0 (untouched) to non-zero (in-progress) 100 //# SLOAD_GAS = WARM_STORAGE_READ_COST for rewriting from non-zero (in-progress) to non-zero (success/failure) + EVM_MESSAGE_FIXED_BYTES = 448 // Byte size of fixed-size fields in EVM2EVMMessage + EVM_MESSAGE_BYTES_PER_TOKEN = 128 // Byte size of each token transfer, consisting of 1 EVMTokenAmount and 1 bytes, excl length of bytes + DA_MULTIPLIER_BASE = int64(10000) ) // return the size of bytes for msg tokens @@ -65,14 +66,6 @@ func maxGasOverHeadGas(numMsgs, dataLength, numTokens int) uint64 { return overheadGas(dataLength, numTokens) + merkleGasShare } -// computeExecCost calculates the costs for next execution, and converts to USD value scaled by 1e18 (e.g. 5$ = 5e18). -func computeExecCost(gasLimit *big.Int, execGasPriceEstimate, tokenPriceUSD *big.Int) *big.Int { - execGasEstimate := new(big.Int).Add(big.NewInt(FEE_BOOSTING_OVERHEAD_GAS), gasLimit) - execGasEstimate.Mul(execGasEstimate, execGasPriceEstimate) - - return ccipcalc.CalculateUsdPerUnitGas(execGasEstimate, tokenPriceUSD) -} - // waitBoostedFee boosts the given fee according to the time passed since the msg was sent. // RelativeBoostPerWaitHour is used to normalize the time diff, // it makes our loss taking "smooth" and gives us time to react without a hard deadline. diff --git a/core/services/ocr2/plugins/ccip/execution_gas_helpers_test.go b/core/services/ocr2/plugins/ccip/execution_gas_helpers_test.go index 5992db5962..0289a706d8 100644 --- a/core/services/ocr2/plugins/ccip/execution_gas_helpers_test.go +++ b/core/services/ocr2/plugins/ccip/execution_gas_helpers_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestOverheadGas(t *testing.T) { @@ -69,45 +68,6 @@ func TestMaxGasOverHeadGas(t *testing.T) { } } -func TestComputeExecCost(t *testing.T) { - tests := []struct { - name string - gasLimit *big.Int - execGasEstimate *big.Int - tokenPriceUSD *big.Int - execCostUsd *big.Int - }{ - { - "happy flow", - big.NewInt(3_000_000), - big.NewInt(2e10), - big.NewInt(6e18), - big.NewInt(384e15), - }, - { - "low usd price", - big.NewInt(3_000_000), - big.NewInt(2e10), - big.NewInt(6e15), - big.NewInt(384e12), - }, - { - "zero token price", - big.NewInt(3_000_000), - big.NewInt(2e10), - big.NewInt(0), - big.NewInt(0), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - execCostUsd := computeExecCost(tc.gasLimit, tc.execGasEstimate, tc.tokenPriceUSD) - require.Equal(t, tc.execCostUsd, execCostUsd) - }) - } -} - func TestWaitBoostedFee(t *testing.T) { tests := []struct { name string diff --git a/core/services/ocr2/plugins/ccip/execution_plugin.go b/core/services/ocr2/plugins/ccip/execution_plugin.go index 03f454209c..ee6ed1a9a9 100644 --- a/core/services/ocr2/plugins/ccip/execution_plugin.go +++ b/core/services/ocr2/plugins/ccip/execution_plugin.go @@ -6,6 +6,7 @@ import ( "net/url" "strconv" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -68,7 +69,7 @@ func jobSpecToExecPluginConfig(lggr logger.Logger, jb job.Job, chainSet evm.Lega if err != nil { return nil, nil, errors.Wrap(err, "get chainset") } - offRamp, err := contractutil.LoadOffRamp(common.HexToAddress(spec.ContractID), ExecPluginLabel, destChain.Client()) + offRamp, _, err := contractutil.LoadOffRamp(common.HexToAddress(spec.ContractID), ExecPluginLabel, destChain.Client()) if err != nil { return nil, nil, errors.Wrap(err, "failed loading offRamp") } @@ -84,15 +85,15 @@ func jobSpecToExecPluginConfig(lggr logger.Logger, jb job.Job, chainSet evm.Lega if err != nil { return nil, nil, errors.Wrap(err, "unable to open source chain") } - commitStore, err := contractutil.LoadCommitStore(offRampConfig.CommitStore, ExecPluginLabel, destChain.Client()) + commitStore, commitStoreVersion, err := contractutil.LoadCommitStore(offRampConfig.CommitStore, ExecPluginLabel, destChain.Client()) if err != nil { return nil, nil, errors.Wrap(err, "failed loading commitStore") } - onRamp, err := contractutil.LoadOnRamp(offRampConfig.OnRamp, ExecPluginLabel, sourceChain.Client()) + onRamp, onRampVersion, err := contractutil.LoadOnRamp(offRampConfig.OnRamp, ExecPluginLabel, sourceChain.Client()) if err != nil { return nil, nil, errors.Wrap(err, "failed loading onRamp") } - dynamicOnRampConfig, err := contractutil.LoadOnRampDynamicConfig(onRamp, sourceChain.Client()) + dynamicOnRampConfig, err := contractutil.LoadOnRampDynamicConfig(onRamp, onRampVersion, sourceChain.Client()) if err != nil { return nil, nil, errors.Wrap(err, "failed loading onRamp config") } @@ -135,8 +136,10 @@ func jobSpecToExecPluginConfig(lggr logger.Logger, jb job.Job, chainSet evm.Lega onRampReader: onRampReader, destReader: ccipdata.NewLogPollerReader(destChain.LogPoller(), execLggr, destChain.Client()), onRamp: onRamp, + onRampVersion: onRampVersion, offRamp: offRamp, commitStore: commitStore, + commitStoreVersion: commitStoreVersion, sourcePriceRegistry: sourcePriceRegistry, sourceWrappedNativeToken: sourceWrappedNative, destClient: destChain.Client(), @@ -298,7 +301,21 @@ func unregisterExecutionPluginLpFilters( return err } - onRampDynCfg, err := contractutil.LoadOnRampDynamicConfig(sourceOnRamp, sourceChainClient) + // TODO stopgap solution before compatibility phase-2 + tvStr, err := sourceOnRamp.TypeAndVersion(&bind.CallOpts{Context: ctx}) + if err != nil { + return err + } + _, versionStr, err := ccipconfig.ParseTypeAndVersion(tvStr) + if err != nil { + return err + } + version, err := semver.NewVersion(versionStr) + if err != nil { + return err + } + + onRampDynCfg, err := contractutil.LoadOnRampDynamicConfig(sourceOnRamp, *version, sourceChainClient) if err != nil { return err } diff --git a/core/services/ocr2/plugins/ccip/execution_reporting_plugin.go b/core/services/ocr2/plugins/ccip/execution_reporting_plugin.go index 5571311660..4c7e31303d 100644 --- a/core/services/ocr2/plugins/ccip/execution_reporting_plugin.go +++ b/core/services/ocr2/plugins/ccip/execution_reporting_plugin.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -18,7 +19,6 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - "github.com/smartcontractkit/chainlink/v2/core/assets" evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" @@ -38,6 +38,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/hashlib" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/logpollerutil" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/observability" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/prices" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/tokendata" "github.com/smartcontractkit/chainlink/v2/core/services/pg" ) @@ -61,8 +62,10 @@ type ExecutionPluginConfig struct { onRampReader ccipdata.OnRampReader destReader ccipdata.Reader onRamp evm_2_evm_onramp.EVM2EVMOnRampInterface + onRampVersion semver.Version offRamp evm_2_evm_offramp.EVM2EVMOffRampInterface commitStore commit_store.CommitStoreInterface + commitStoreVersion semver.Version sourcePriceRegistry price_registry.PriceRegistryInterface sourceWrappedNativeToken common.Address destClient evmclient.Client @@ -86,6 +89,7 @@ type ExecutionReportingPlugin struct { cachedDestTokens cache.AutoSync[cache.CachedTokens] cachedTokenPools cache.AutoSync[map[common.Address]common.Address] customTokenPoolFactory func(ctx context.Context, poolAddress common.Address, bind bind.ContractBackend) (custom_token_pool.CustomTokenPoolInterface, error) + gasPriceEstimator prices.GasPriceEstimatorExec } type ExecutionReportingPluginFactory struct { @@ -138,6 +142,23 @@ func (rf *ExecutionReportingPluginFactory) NewReportingPlugin(config types.Repor "offchainConfig", offchainConfig, "onchainConfig", onchainConfig) + dynamicOnRampConfig, err := contractutil.LoadOnRampDynamicConfig(rf.config.onRamp, rf.config.onRampVersion, rf.config.sourceClient) + if err != nil { + return nil, types.ReportingPluginInfo{}, err + } + + gasPriceEstimator, err := prices.NewGasPriceEstimatorForExecPlugin( + rf.config.commitStoreVersion, + rf.config.destGasEstimator, + big.NewInt(int64(offchainConfig.MaxGasPrice)), + int64(dynamicOnRampConfig.DestDataAvailabilityOverheadGas), + int64(dynamicOnRampConfig.DestGasPerDataAvailabilityByte), + int64(dynamicOnRampConfig.DestDataAvailabilityMultiplier), + ) + if err != nil { + return nil, types.ReportingPluginInfo{}, err + } + return &ExecutionReportingPlugin{ config: rf.config, F: config.F, @@ -154,6 +175,7 @@ func (rf *ExecutionReportingPluginFactory) NewReportingPlugin(config types.Repor customTokenPoolFactory: func(ctx context.Context, poolAddress common.Address, contractBackend bind.ContractBackend) (custom_token_pool.CustomTokenPoolInterface, error) { return custom_token_pool.NewCustomTokenPool(poolAddress, contractBackend) }, + gasPriceEstimator: gasPriceEstimator, }, types.ReportingPluginInfo{ Name: "CCIPExecution", // Setting this to false saves on calldata since OffRamp doesn't require agreement between NOPs @@ -280,8 +302,8 @@ func (r *ExecutionReportingPlugin) getExecutableObservations(ctx context.Context } return getTokensPrices(ctx, dstTokens.FeeTokens, r.destPriceRegistry, append(supportedDestTokens, r.destWrappedNative)) }) - getDestGasPrice := cache.LazyFetch(func() (*big.Int, error) { - return r.estimateDestinationGasPrice(ctx) + getDestGasPrice := cache.LazyFetch(func() (prices.GasPrice, error) { + return r.gasPriceEstimator.GetGasPrice(ctx) }) lggr.Infow("Processing unexpired reports", "n", len(unexpiredReports)) @@ -429,18 +451,6 @@ func (r *ExecutionReportingPlugin) destPoolRateLimits(ctx context.Context, commi return res, nil } -func (r *ExecutionReportingPlugin) estimateDestinationGasPrice(ctx context.Context) (*big.Int, error) { - destGasPriceWei, _, err := r.config.destGasEstimator.GetFee(ctx, nil, 0, assets.NewWei(big.NewInt(int64(r.offchainConfig.MaxGasPrice)))) - if err != nil { - return nil, errors.Wrap(err, "could not estimate destination gas price") - } - destGasPrice := destGasPriceWei.Legacy.ToInt() - if destGasPriceWei.DynamicFeeCap != nil { - destGasPrice = destGasPriceWei.DynamicFeeCap.ToInt() - } - return destGasPrice, nil -} - func (r *ExecutionReportingPlugin) sourceDestinationTokens(ctx context.Context) (map[common.Address]common.Address, []common.Address, error) { destTokens, err := r.cachedDestTokens.Get(ctx) if err != nil { @@ -488,7 +498,7 @@ func (r *ExecutionReportingPlugin) buildBatch( aggregateTokenLimit *big.Int, sourceTokenPricesUSD map[common.Address]*big.Int, destTokenPricesUSD map[common.Address]*big.Int, - execGasPriceEstimate cache.LazyFunction[*big.Int], + gasPriceEstimate cache.LazyFunction[prices.GasPrice], sourceToDestToken map[common.Address]common.Address, destTokenPoolRateLimits map[common.Address]*big.Int, ) (executableMessages []ObservedMessage) { @@ -561,7 +571,7 @@ func (r *ExecutionReportingPlugin) buildBatch( } // Fee boosting - execGasPriceEstimateValue, err := execGasPriceEstimate() + gasPriceValue, err := gasPriceEstimate() if err != nil { msgLggr.Errorw("Unexpected error fetching gas price estimate", "err", err) return []ObservedMessage{} @@ -573,7 +583,12 @@ func (r *ExecutionReportingPlugin) buildBatch( continue } - execCostUsd := computeExecCost(msg.GasLimit, execGasPriceEstimateValue, dstWrappedNativePrice) + execCostUsd, err := r.gasPriceEstimator.EstimateMsgCostUSD(gasPriceValue, dstWrappedNativePrice, msg) + if err != nil { + msgLggr.Errorw("failed to estimate message cost USD", "err", err) + return []ObservedMessage{} + } + // calculating the source chain fee, dividing by 1e18 for denomination. // For example: // FeeToken=link; FeeTokenAmount=1e17 i.e. 0.1 link, price is 6e18 USD/link (1 USD = 1e18), diff --git a/core/services/ocr2/plugins/ccip/execution_reporting_plugin_test.go b/core/services/ocr2/plugins/ccip/execution_reporting_plugin_test.go index fb02daa03c..5329254882 100644 --- a/core/services/ocr2/plugins/ccip/execution_reporting_plugin_test.go +++ b/core/services/ocr2/plugins/ccip/execution_reporting_plugin_test.go @@ -24,9 +24,6 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/chainlink/v2/core/assets" - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/mocks" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" lpMocks "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller/mocks" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/custom_token_pool" @@ -39,6 +36,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/cache" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/prices" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/testhelpers" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/tokendata" "github.com/smartcontractkit/chainlink/v2/core/utils" @@ -425,22 +423,6 @@ func TestExecutionReportingPlugin_buildBatch(t *testing.T) { sender1 := common.HexToAddress("0xa") destNative := common.HexToAddress("0xb") srcNative := common.HexToAddress("0xc") - plugin := ExecutionReportingPlugin{ - config: ExecutionPluginConfig{ - offRamp: offRamp, - onRamp: onRamp, - }, - destWrappedNative: destNative, - offchainConfig: ccipconfig.ExecOffchainConfig{ - SourceFinalityDepth: 5, - DestOptimisticConfirmations: 1, - DestFinalityDepth: 5, - BatchGasLimit: 300_000, - RelativeBoostPerWaitHour: 1, - MaxGasPrice: 1, - }, - lggr: logger.TestLogger(t), - } msg1 := internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ @@ -647,6 +629,30 @@ func TestExecutionReportingPlugin_buildBatch(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { offRamp.SetSenderNonces(tc.offRampNoncesBySender) + + gasPriceEstimator := prices.NewMockGasPriceEstimatorExec(t) + if tc.expectedSeqNrs != nil { + gasPriceEstimator.On("EstimateMsgCostUSD", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(big.NewInt(0), nil) + } + + plugin := ExecutionReportingPlugin{ + config: ExecutionPluginConfig{ + offRamp: offRamp, + onRamp: onRamp, + }, + destWrappedNative: destNative, + offchainConfig: ccipconfig.ExecOffchainConfig{ + SourceFinalityDepth: 5, + DestOptimisticConfirmations: 1, + DestFinalityDepth: 5, + BatchGasLimit: 300_000, + RelativeBoostPerWaitHour: 1, + MaxGasPrice: 1, + }, + lggr: logger.TestLogger(t), + gasPriceEstimator: gasPriceEstimator, + } + seqNrs := plugin.buildBatch( context.Background(), lggr, @@ -655,7 +661,7 @@ func TestExecutionReportingPlugin_buildBatch(t *testing.T) { tc.tokenLimit, tc.srcPrices, tc.dstPrices, - func() (*big.Int, error) { return tc.destGasPrice, nil }, + func() (prices.GasPrice, error) { return tc.destGasPrice, nil }, tc.srcToDestTokens, tc.destRateLimits, ) @@ -903,55 +909,6 @@ func TestExecutionReportingPlugin_destPoolRateLimits(t *testing.T) { } } -func TestExecutionReportingPlugin_estimateDestinationGasPrice(t *testing.T) { - testCases := []struct { - name string - evmFee gas.EvmFee - evmFeeErr error - - expRes *big.Int - expErr bool - }{ - { - name: "dynamic fee cap has precedence over legacy", - evmFee: gas.EvmFee{ - Legacy: assets.NewWei(big.NewInt(1000)), - DynamicFeeCap: assets.NewWei(big.NewInt(2000)), - }, - expRes: big.NewInt(2000), - }, - { - name: "legacy is used if dynamic fee cap is not provided", - evmFee: gas.EvmFee{ - Legacy: assets.NewWei(big.NewInt(1000)), - }, - expRes: big.NewInt(1000), - }, - { - name: "stop on error", - evmFeeErr: errors.New("some error"), - expErr: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - p := &ExecutionReportingPlugin{} - mockEstimator := mocks.NewEvmFeeEstimator(t) - mockEstimator.On("GetFee", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.evmFee, uint32(0), tc.evmFeeErr) - p.config.destGasEstimator = mockEstimator - - res, err := p.estimateDestinationGasPrice(testutils.Context(t)) - if tc.expErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - assert.Equal(t, tc.expRes, res) - }) - } -} - func TestExecutionReportingPlugin_getReportsWithSendRequests(t *testing.T) { testCases := []struct { name string diff --git a/core/services/ocr2/plugins/ccip/internal/contractutil/loaders.go b/core/services/ocr2/plugins/ccip/internal/contractutil/loaders.go index 218bd2a00f..e058e9702a 100644 --- a/core/services/ocr2/plugins/ccip/internal/contractutil/loaders.go +++ b/core/services/ocr2/plugins/ccip/internal/contractutil/loaders.go @@ -1,6 +1,7 @@ package contractutil import ( + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/pkg/errors" @@ -15,28 +16,20 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/observability" ) -func LoadOnRamp(onRampAddress common.Address, pluginName string, client client.Client) (evm_2_evm_onramp.EVM2EVMOnRampInterface, error) { - err := ccipconfig.VerifyTypeAndVersion(onRampAddress, client, ccipconfig.EVM2EVMOnRamp) +func LoadOnRamp(onRampAddress common.Address, pluginName string, client client.Client) (evm_2_evm_onramp.EVM2EVMOnRampInterface, semver.Version, error) { + version, err := ccipconfig.VerifyTypeAndVersion(onRampAddress, client, ccipconfig.EVM2EVMOnRamp) if err != nil { - return nil, errors.Wrap(err, "Invalid onRamp contract") + return nil, semver.Version{}, errors.Wrap(err, "Invalid onRamp contract") } - return observability.NewObservedEvm2EvmOnRamp(onRampAddress, pluginName, client) -} -func LoadOnRampDynamicConfig(onRamp evm_2_evm_onramp.EVM2EVMOnRampInterface, client client.Client) (evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, error) { - versionString, err := onRamp.TypeAndVersion(&bind.CallOpts{}) - if err != nil { - return evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig{}, err - } - - _, version, err := ccipconfig.ParseTypeAndVersion(versionString) - if err != nil { - return evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig{}, err - } + onRamp, err := observability.NewObservedEvm2EvmOnRamp(onRampAddress, pluginName, client) + return onRamp, version, err +} +func LoadOnRampDynamicConfig(onRamp evm_2_evm_onramp.EVM2EVMOnRampInterface, version semver.Version, client client.Client) (evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, error) { opts := &bind.CallOpts{} - switch version { + switch version.String() { case "1.0.0": legacyOnramp, err := evm_2_evm_onramp_1_0_0.NewEVM2EVMOnRamp(onRamp.Address(), client) if err != nil { @@ -47,11 +40,16 @@ func LoadOnRampDynamicConfig(onRamp evm_2_evm_onramp.EVM2EVMOnRampInterface, cli return evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig{}, err } return evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig{ - Router: legacyDynamicConfig.Router, - MaxTokensLength: legacyDynamicConfig.MaxTokensLength, - PriceRegistry: legacyDynamicConfig.PriceRegistry, - MaxDataSize: legacyDynamicConfig.MaxDataSize, - MaxGasLimit: uint32(legacyDynamicConfig.MaxGasLimit), + Router: legacyDynamicConfig.Router, + MaxTokensLength: legacyDynamicConfig.MaxTokensLength, + DestGasOverhead: 0, + DestGasPerPayloadByte: 0, + DestDataAvailabilityOverheadGas: 0, + DestGasPerDataAvailabilityByte: 0, + DestDataAvailabilityMultiplier: 0, + PriceRegistry: legacyDynamicConfig.PriceRegistry, + MaxDataSize: legacyDynamicConfig.MaxDataSize, + MaxGasLimit: uint32(legacyDynamicConfig.MaxGasLimit), }, nil case "1.1.0": legacyOnramp, err := evm_2_evm_onramp_1_1_0.NewEVM2EVMOnRamp(onRamp.Address(), client) @@ -63,13 +61,16 @@ func LoadOnRampDynamicConfig(onRamp evm_2_evm_onramp.EVM2EVMOnRampInterface, cli return evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig{}, err } return evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig{ - Router: legacyDynamicConfig.Router, - MaxTokensLength: legacyDynamicConfig.MaxTokensLength, - DestGasOverhead: legacyDynamicConfig.DestGasOverhead, - DestGasPerPayloadByte: legacyDynamicConfig.DestGasPerPayloadByte, - PriceRegistry: legacyDynamicConfig.PriceRegistry, - MaxDataSize: legacyDynamicConfig.MaxDataSize, - MaxGasLimit: uint32(legacyDynamicConfig.MaxGasLimit), + Router: legacyDynamicConfig.Router, + MaxTokensLength: legacyDynamicConfig.MaxTokensLength, + DestGasOverhead: legacyDynamicConfig.DestGasOverhead, + DestGasPerPayloadByte: legacyDynamicConfig.DestGasPerPayloadByte, + DestDataAvailabilityOverheadGas: 0, + DestGasPerDataAvailabilityByte: 0, + DestDataAvailabilityMultiplier: 0, + PriceRegistry: legacyDynamicConfig.PriceRegistry, + MaxDataSize: legacyDynamicConfig.MaxDataSize, + MaxGasLimit: uint32(legacyDynamicConfig.MaxGasLimit), }, nil case "1.2.0": return onRamp.GetDynamicConfig(opts) @@ -78,18 +79,49 @@ func LoadOnRampDynamicConfig(onRamp evm_2_evm_onramp.EVM2EVMOnRampInterface, cli } } -func LoadOffRamp(offRampAddress common.Address, pluginName string, client client.Client) (evm_2_evm_offramp.EVM2EVMOffRampInterface, error) { - err := ccipconfig.VerifyTypeAndVersion(offRampAddress, client, ccipconfig.EVM2EVMOffRamp) +func LoadOffRamp(offRampAddress common.Address, pluginName string, client client.Client) (evm_2_evm_offramp.EVM2EVMOffRampInterface, semver.Version, error) { + version, err := ccipconfig.VerifyTypeAndVersion(offRampAddress, client, ccipconfig.EVM2EVMOffRamp) if err != nil { - return nil, errors.Wrap(err, "Invalid offRamp contract") + return nil, semver.Version{}, errors.Wrap(err, "Invalid offRamp contract") } - return observability.NewObservedEvm2EvmOffRamp(offRampAddress, pluginName, client) + + offRamp, err := observability.NewObservedEvm2EvmOffRamp(offRampAddress, pluginName, client) + return offRamp, version, err } -func LoadCommitStore(commitStoreAddress common.Address, pluginName string, client client.Client) (commit_store.CommitStoreInterface, error) { - err := ccipconfig.VerifyTypeAndVersion(commitStoreAddress, client, ccipconfig.CommitStore) +func LoadCommitStore(commitStoreAddress common.Address, pluginName string, client client.Client) (commit_store.CommitStoreInterface, semver.Version, error) { + version, err := ccipconfig.VerifyTypeAndVersion(commitStoreAddress, client, ccipconfig.CommitStore) if err != nil { - return nil, errors.Wrap(err, "Invalid commitStore contract") + return nil, semver.Version{}, errors.Wrap(err, "Invalid commitStore contract") + } + + commitStore, err := observability.NewObservedCommitStore(commitStoreAddress, pluginName, client) + return commitStore, version, err +} + +func DecodeCommitStoreOffchainConfig(version semver.Version, offchainConfig []byte) (ccipconfig.CommitOffchainConfig, error) { + switch version.String() { + case "1.0.0", "1.1.0": + offchainConfigV1, err := ccipconfig.DecodeOffchainConfig[ccipconfig.CommitOffchainConfigV1](offchainConfig) + if err != nil { + return ccipconfig.CommitOffchainConfig{}, err + } + + return ccipconfig.CommitOffchainConfig{ + SourceFinalityDepth: offchainConfigV1.SourceFinalityDepth, + DestFinalityDepth: offchainConfigV1.DestFinalityDepth, + GasPriceHeartBeat: offchainConfigV1.FeeUpdateHeartBeat, + DAGasPriceDeviationPPB: offchainConfigV1.FeeUpdateDeviationPPB, + ExecGasPriceDeviationPPB: offchainConfigV1.FeeUpdateDeviationPPB, + TokenPriceHeartBeat: offchainConfigV1.FeeUpdateHeartBeat, + TokenPriceDeviationPPB: offchainConfigV1.FeeUpdateDeviationPPB, + MaxGasPrice: offchainConfigV1.MaxGasPrice, + InflightCacheExpiry: offchainConfigV1.InflightCacheExpiry, + }, nil + case "1.2.0": + offchainConfig, err := ccipconfig.DecodeOffchainConfig[ccipconfig.CommitOffchainConfig](offchainConfig) + return offchainConfig, err + default: + return ccipconfig.CommitOffchainConfig{}, errors.Errorf("Invalid commitStore version: %s", version) } - return observability.NewObservedCommitStore(commitStoreAddress, pluginName, client) } diff --git a/core/services/ocr2/plugins/ccip/prices/da_price_estimator.go b/core/services/ocr2/plugins/ccip/prices/da_price_estimator.go new file mode 100644 index 0000000000..3f4a40c86b --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/da_price_estimator.go @@ -0,0 +1,169 @@ +package prices + +import ( + "context" + "fmt" + "math/big" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" +) + +type DAGasPriceEstimator struct { + execEstimator GasPriceEstimator + l1Oracle rollups.L1Oracle + priceEncodingLength uint + daDeviationPPB int64 + daOverheadGas int64 + gasPerDAByte int64 + daMultiplier int64 +} + +func (g DAGasPriceEstimator) GetGasPrice(ctx context.Context) (GasPrice, error) { + execGasPrice, err := g.execEstimator.GetGasPrice(ctx) + if err != nil { + return nil, err + } + var gasPrice *big.Int = execGasPrice + if gasPrice.BitLen() > int(g.priceEncodingLength) { + return nil, fmt.Errorf("native gas price exceeded max range %+v", gasPrice) + } + + if g.l1Oracle == nil { + return gasPrice, nil + } + + daGasPriceWei, err := g.l1Oracle.GasPrice(ctx) + if err != nil { + return nil, err + } + + if daGasPrice := daGasPriceWei.ToInt(); daGasPrice.Cmp(big.NewInt(0)) > 0 { + if daGasPrice.BitLen() > int(g.priceEncodingLength) { + return nil, fmt.Errorf("data availability gas price exceeded max range %+v", daGasPrice) + } + + daGasPrice = new(big.Int).Lsh(daGasPrice, g.priceEncodingLength) + gasPrice = new(big.Int).Add(gasPrice, daGasPrice) + } + + return gasPrice, nil +} + +func (g DAGasPriceEstimator) DenoteInUSD(p GasPrice, wrappedNativePrice *big.Int) (GasPrice, error) { + daGasPrice, execGasPrice, err := g.parseEncodedGasPrice(p) + if err != nil { + return nil, err + } + + // This assumes l1GasPrice is priced using the same native token as l2 native + daUSD := ccipcalc.CalculateUsdPerUnitGas(daGasPrice, wrappedNativePrice) + if daUSD.BitLen() > int(g.priceEncodingLength) { + return nil, fmt.Errorf("data availability gas price USD exceeded max range %+v", daUSD) + } + execUSD := ccipcalc.CalculateUsdPerUnitGas(execGasPrice, wrappedNativePrice) + if execUSD.BitLen() > int(g.priceEncodingLength) { + return nil, fmt.Errorf("exec gas price USD exceeded max range %+v", execUSD) + } + + daUSD = new(big.Int).Lsh(daUSD, g.priceEncodingLength) + return new(big.Int).Add(daUSD, execUSD), nil +} + +func (g DAGasPriceEstimator) Median(gasPrices []GasPrice) (GasPrice, error) { + daPrices := make([]*big.Int, len(gasPrices)) + execPrices := make([]*big.Int, len(gasPrices)) + + for i := range gasPrices { + daGasPrice, execGasPrice, err := g.parseEncodedGasPrice(gasPrices[i]) + if err != nil { + return nil, err + } + + daPrices[i] = daGasPrice + execPrices[i] = execGasPrice + } + + daMedian := ccipcalc.BigIntMedian(daPrices) + execMedian := ccipcalc.BigIntMedian(execPrices) + + daMedian = new(big.Int).Lsh(daMedian, g.priceEncodingLength) + return new(big.Int).Add(daMedian, execMedian), nil +} + +func (g DAGasPriceEstimator) Deviates(p1 GasPrice, p2 GasPrice) (bool, error) { + p1DAGasPrice, p1ExecGasPrice, err := g.parseEncodedGasPrice(p1) + if err != nil { + return false, err + } + p2DAGasPrice, p2ExecGasPrice, err := g.parseEncodedGasPrice(p2) + if err != nil { + return false, err + } + + execDeviates, err := g.execEstimator.Deviates(p1ExecGasPrice, p2ExecGasPrice) + if err != nil { + return false, err + } + if execDeviates { + return execDeviates, nil + } + + return ccipcalc.Deviates(p1DAGasPrice, p2DAGasPrice, g.daDeviationPPB), nil +} + +func (g DAGasPriceEstimator) EstimateMsgCostUSD(p GasPrice, wrappedNativePrice *big.Int, msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error) { + daGasPrice, execGasPrice, err := g.parseEncodedGasPrice(p) + if err != nil { + return nil, err + } + + execCostUSD, err := g.execEstimator.EstimateMsgCostUSD(execGasPrice, wrappedNativePrice, msg) + if err != nil { + return nil, err + } + + // If there is data availability price component, then include data availability cost in fee estimation + if daGasPrice.Cmp(big.NewInt(0)) > 0 { + daGasCostUSD := g.estimateDACostUSD(daGasPrice, wrappedNativePrice, msg) + execCostUSD = new(big.Int).Add(daGasCostUSD, execCostUSD) + } + return execCostUSD, nil +} + +func (g DAGasPriceEstimator) String(p GasPrice) string { + daGasPrice, execGasPrice, err := g.parseEncodedGasPrice(p) + if err != nil { + return err.Error() + } + return fmt.Sprintf("DA Price: %s, Exec Price: %s", daGasPrice, execGasPrice) +} + +func (g DAGasPriceEstimator) parseEncodedGasPrice(p *big.Int) (*big.Int, *big.Int, error) { + if p.BitLen() > int(g.priceEncodingLength*2) { + return nil, nil, fmt.Errorf("encoded gas price exceeded max range %+v", p) + } + + daGasPrice := new(big.Int).Rsh(p, g.priceEncodingLength) + + daStart := new(big.Int).Lsh(big.NewInt(1), g.priceEncodingLength) + execGasPrice := new(big.Int).Mod(p, daStart) + + return daGasPrice, execGasPrice, nil +} + +func (g DAGasPriceEstimator) estimateDACostUSD(daGasPrice GasPrice, wrappedNativePrice *big.Int, msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) *big.Int { + var sourceTokenDataLen int + for _, tokenData := range msg.SourceTokenData { + sourceTokenDataLen += len(tokenData) + } + + dataLen := evmMessageFixedBytes + len(msg.Data) + len(msg.TokenAmounts)*evmMessageBytesPerToken + sourceTokenDataLen + dataGas := big.NewInt(int64(dataLen)*g.gasPerDAByte + g.daOverheadGas) + + dataGasEstimate := new(big.Int).Mul(dataGas, daGasPrice) + dataGasEstimate = new(big.Int).Div(new(big.Int).Mul(dataGasEstimate, big.NewInt(g.daMultiplier)), big.NewInt(daMultiplierBase)) + + return ccipcalc.CalculateUsdPerUnitGas(dataGasEstimate, wrappedNativePrice) +} diff --git a/core/services/ocr2/plugins/ccip/prices/da_price_estimator_test.go b/core/services/ocr2/plugins/ccip/prices/da_price_estimator_test.go new file mode 100644 index 0000000000..de64f6811f --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/da_price_estimator_test.go @@ -0,0 +1,452 @@ +package prices + +import ( + "context" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_offramp" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" +) + +func encodeGasPrice(daPrice, execPrice *big.Int) *big.Int { + return new(big.Int).Add(new(big.Int).Lsh(daPrice, daGasPriceEncodingLength), execPrice) +} + +func TestDAPriceEstimator_GetGasPrice(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + daGasPrice GasPrice + execGasPrice GasPrice + expPrice GasPrice + expErr bool + }{ + { + name: "base", + daGasPrice: big.NewInt(1), + execGasPrice: big.NewInt(0), + expPrice: encodeGasPrice(big.NewInt(1), big.NewInt(0)), + expErr: false, + }, + { + name: "large values", + daGasPrice: big.NewInt(1e9), // 1 gwei + execGasPrice: big.NewInt(200e9), // 200 gwei + expPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(200e9)), + expErr: false, + }, + { + name: "zero DA price", + daGasPrice: big.NewInt(0), + execGasPrice: big.NewInt(200e9), + expPrice: encodeGasPrice(big.NewInt(0), big.NewInt(200e9)), + expErr: false, + }, + { + name: "zero exec price", + daGasPrice: big.NewInt(1e9), + execGasPrice: big.NewInt(0), + expPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(0)), + expErr: false, + }, + { + name: "price out of bounds", + daGasPrice: new(big.Int).Lsh(big.NewInt(1), daGasPriceEncodingLength), + execGasPrice: big.NewInt(1), + expPrice: nil, + expErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + execEstimator := NewMockGasPriceEstimator(t) + execEstimator.On("GetGasPrice", ctx).Return(tc.execGasPrice, nil) + + l1Oracle := mocks.NewL1Oracle(t) + l1Oracle.On("GasPrice", ctx).Return(assets.NewWei(tc.daGasPrice), nil) + + g := DAGasPriceEstimator{ + execEstimator: execEstimator, + l1Oracle: l1Oracle, + priceEncodingLength: daGasPriceEncodingLength, + } + + gasPrice, err := g.GetGasPrice(ctx) + if tc.expErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expPrice, gasPrice) + }) + } + + t.Run("nil L1 oracle", func(t *testing.T) { + var expPrice GasPrice = big.NewInt(1) + + execEstimator := NewMockGasPriceEstimator(t) + execEstimator.On("GetGasPrice", ctx).Return(expPrice, nil) + + g := DAGasPriceEstimator{ + execEstimator: execEstimator, + l1Oracle: nil, + priceEncodingLength: daGasPriceEncodingLength, + } + + gasPrice, err := g.GetGasPrice(ctx) + assert.NoError(t, err) + assert.Equal(t, expPrice, gasPrice) + }) +} + +func TestDAPriceEstimator_DenoteInUSD(t *testing.T) { + val1e18 := func(val int64) *big.Int { return new(big.Int).Mul(big.NewInt(1e18), big.NewInt(val)) } + + testCases := []struct { + name string + gasPrice GasPrice + nativePrice *big.Int + expPrice GasPrice + }{ + { + name: "base", + gasPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(10e9)), + nativePrice: val1e18(2_000), + expPrice: encodeGasPrice(big.NewInt(2000e9), big.NewInt(20000e9)), + }, + { + name: "low price truncates to 0", + gasPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(10e9)), + nativePrice: big.NewInt(1), + expPrice: big.NewInt(0), + }, + { + name: "high price", + gasPrice: encodeGasPrice(val1e18(1), val1e18(10)), + nativePrice: val1e18(2000), + expPrice: encodeGasPrice(val1e18(2_000), val1e18(20_000)), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := DAGasPriceEstimator{ + priceEncodingLength: daGasPriceEncodingLength, + } + + gasPrice, err := g.DenoteInUSD(tc.gasPrice, tc.nativePrice) + assert.NoError(t, err) + assert.True(t, ((*big.Int)(tc.expPrice)).Cmp(gasPrice) == 0) + }) + } +} + +func TestDAPriceEstimator_Median(t *testing.T) { + val1e18 := func(val int64) *big.Int { return new(big.Int).Mul(big.NewInt(1e18), big.NewInt(val)) } + + testCases := []struct { + name string + gasPrices []GasPrice + expMedian GasPrice + }{ + { + name: "base", + gasPrices: []GasPrice{ + encodeGasPrice(big.NewInt(1), big.NewInt(1)), + encodeGasPrice(big.NewInt(2), big.NewInt(2)), + encodeGasPrice(big.NewInt(3), big.NewInt(3)), + }, + expMedian: encodeGasPrice(big.NewInt(2), big.NewInt(2)), + }, + { + name: "median 2", + gasPrices: []GasPrice{ + encodeGasPrice(big.NewInt(1), big.NewInt(1)), + encodeGasPrice(big.NewInt(2), big.NewInt(2)), + }, + expMedian: encodeGasPrice(big.NewInt(2), big.NewInt(2)), + }, + { + name: "large values", + gasPrices: []GasPrice{ + encodeGasPrice(val1e18(5), val1e18(5)), + encodeGasPrice(val1e18(4), val1e18(4)), + encodeGasPrice(val1e18(3), val1e18(3)), + encodeGasPrice(val1e18(2), val1e18(2)), + encodeGasPrice(val1e18(1), val1e18(1)), + }, + expMedian: encodeGasPrice(val1e18(3), val1e18(3)), + }, + { + name: "zeros", + gasPrices: []GasPrice{big.NewInt(0), big.NewInt(0), big.NewInt(0)}, + expMedian: big.NewInt(0), + }, + { + name: "picks median of each price component individually", + gasPrices: []GasPrice{ + encodeGasPrice(val1e18(1), val1e18(3)), + encodeGasPrice(val1e18(2), val1e18(2)), + encodeGasPrice(val1e18(3), val1e18(1)), + }, + expMedian: encodeGasPrice(val1e18(2), val1e18(2)), + }, + { + name: "unsorted even number of price components", + gasPrices: []GasPrice{ + encodeGasPrice(val1e18(1), val1e18(22)), + encodeGasPrice(val1e18(4), val1e18(33)), + encodeGasPrice(val1e18(2), val1e18(44)), + encodeGasPrice(val1e18(3), val1e18(11)), + }, + expMedian: encodeGasPrice(val1e18(3), val1e18(33)), + }, + { + name: "equal DA price components", + gasPrices: []GasPrice{ + encodeGasPrice(val1e18(2), val1e18(22)), + encodeGasPrice(val1e18(2), val1e18(33)), + encodeGasPrice(val1e18(2), val1e18(44)), + encodeGasPrice(val1e18(2), val1e18(11)), + }, + expMedian: encodeGasPrice(val1e18(2), val1e18(33)), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := DAGasPriceEstimator{ + priceEncodingLength: daGasPriceEncodingLength, + } + + gasPrice, err := g.Median(tc.gasPrices) + assert.NoError(t, err) + assert.True(t, ((*big.Int)(tc.expMedian)).Cmp(gasPrice) == 0) + }) + } +} + +func TestDAPriceEstimator_Deviates(t *testing.T) { + testCases := []struct { + name string + gasPrice1 GasPrice + gasPrice2 GasPrice + daDeviationPPB int64 + execDeviationPPB int64 + expDeviates bool + }{ + { + name: "base", + gasPrice1: encodeGasPrice(big.NewInt(100e8), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(79e8), big.NewInt(79e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: true, + }, + { + name: "negative difference also deviates", + gasPrice1: encodeGasPrice(big.NewInt(100e8), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(121e8), big.NewInt(121e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: true, + }, + { + name: "only DA component deviates", + gasPrice1: encodeGasPrice(big.NewInt(100e8), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(150e8), big.NewInt(110e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: true, + }, + { + name: "only exec component deviates", + gasPrice1: encodeGasPrice(big.NewInt(100e8), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(110e8), big.NewInt(150e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: true, + }, + { + name: "both do not deviate", + gasPrice1: encodeGasPrice(big.NewInt(100e8), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(110e8), big.NewInt(110e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: false, + }, + { + name: "zero DA price and exec deviates", + gasPrice1: encodeGasPrice(big.NewInt(0), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(0), big.NewInt(121e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: true, + }, + { + name: "zero DA price and exec does not deviate", + gasPrice1: encodeGasPrice(big.NewInt(0), big.NewInt(100e8)), + gasPrice2: encodeGasPrice(big.NewInt(0), big.NewInt(110e8)), + daDeviationPPB: 2e8, + execDeviationPPB: 2e8, + expDeviates: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := DAGasPriceEstimator{ + execEstimator: ExecGasPriceEstimator{ + deviationPPB: tc.execDeviationPPB, + }, + daDeviationPPB: tc.daDeviationPPB, + priceEncodingLength: daGasPriceEncodingLength, + } + + deviated, err := g.Deviates(tc.gasPrice1, tc.gasPrice2) + assert.NoError(t, err) + if tc.expDeviates { + assert.True(t, deviated) + } else { + assert.False(t, deviated) + } + }) + } +} + +func TestDAPriceEstimator_EstimateMsgCostUSD(t *testing.T) { + execCostUSD := big.NewInt(100_000) + + testCases := []struct { + name string + gasPrice GasPrice + wrappedNativePrice *big.Int + msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta + daOverheadGas int64 + gasPerDAByte int64 + daMultiplier int64 + expUSD *big.Int + }{ + { + name: "only DA overhead", + gasPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(0)), // 1 gwei DA price, 0 exec price + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + SourceTokenData: [][]byte{}, + }, + }, + daOverheadGas: 100_000, + gasPerDAByte: 0, + daMultiplier: 10_000, // 1x multiplier + expUSD: new(big.Int).Add(execCostUSD, big.NewInt(100_000e9)), + }, + { + name: "include message data gas", + gasPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(0)), // 1 gwei DA price, 0 exec price + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + Data: make([]byte, 1_000), + TokenAmounts: make([]evm_2_evm_offramp.ClientEVMTokenAmount, 5), + SourceTokenData: [][]byte{ + make([]byte, 10), make([]byte, 10), make([]byte, 10), make([]byte, 10), make([]byte, 10), + }, + }, + }, + daOverheadGas: 100_000, + gasPerDAByte: 16, + daMultiplier: 10_000, // 1x multiplier + expUSD: new(big.Int).Add(execCostUSD, big.NewInt(134_208e9)), + }, + { + name: "zero DA price", + gasPrice: big.NewInt(0), // 1 gwei DA price, 0 exec price + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + SourceTokenData: [][]byte{}, + }, + }, + daOverheadGas: 100_000, + gasPerDAByte: 16, + daMultiplier: 10_000, // 1x multiplier + expUSD: execCostUSD, + }, + { + name: "double native price", + gasPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(0)), // 1 gwei DA price, 0 exec price + wrappedNativePrice: big.NewInt(2e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + SourceTokenData: [][]byte{}, + }, + }, + daOverheadGas: 100_000, + gasPerDAByte: 0, + daMultiplier: 10_000, // 1x multiplier + expUSD: new(big.Int).Add(execCostUSD, big.NewInt(200_000e9)), + }, + { + name: "half multiplier", + gasPrice: encodeGasPrice(big.NewInt(1e9), big.NewInt(0)), // 1 gwei DA price, 0 exec price + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + SourceTokenData: [][]byte{}, + }, + }, + daOverheadGas: 100_000, + gasPerDAByte: 0, + daMultiplier: 5_000, // 0.5x multiplier + expUSD: new(big.Int).Add(execCostUSD, big.NewInt(50_000e9)), + }, + } + + for _, tc := range testCases { + execEstimator := NewMockGasPriceEstimator(t) + execEstimator.On("EstimateMsgCostUSD", mock.Anything, tc.wrappedNativePrice, tc.msg).Return(execCostUSD, nil) + + t.Run(tc.name, func(t *testing.T) { + g := DAGasPriceEstimator{ + execEstimator: execEstimator, + l1Oracle: nil, + priceEncodingLength: daGasPriceEncodingLength, + daOverheadGas: tc.daOverheadGas, + gasPerDAByte: tc.gasPerDAByte, + daMultiplier: tc.daMultiplier, + } + + costUSD, err := g.EstimateMsgCostUSD(tc.gasPrice, tc.wrappedNativePrice, tc.msg) + assert.NoError(t, err) + assert.Equal(t, tc.expUSD, costUSD) + }) + } +} + +func TestDAPriceEstimator_String(t *testing.T) { + g := DAGasPriceEstimator{ + execEstimator: nil, + l1Oracle: nil, + priceEncodingLength: daGasPriceEncodingLength, + } + + str := g.String(encodeGasPrice(big.NewInt(1), big.NewInt(2))) + assert.Equal(t, "DA Price: 1, Exec Price: 2", str) +} diff --git a/core/services/ocr2/plugins/ccip/prices/exec_price_estimator.go b/core/services/ocr2/plugins/ccip/prices/exec_price_estimator.go new file mode 100644 index 0000000000..511d8b17eb --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/exec_price_estimator.go @@ -0,0 +1,67 @@ +package prices + +import ( + "context" + "fmt" + "math/big" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" +) + +type ExecGasPriceEstimator struct { + estimator gas.EvmFeeEstimator + maxGasPrice *big.Int + deviationPPB int64 +} + +func (g ExecGasPriceEstimator) GetGasPrice(ctx context.Context) (GasPrice, error) { + gasPriceWei, _, err := g.estimator.GetFee(ctx, nil, 0, assets.NewWei(g.maxGasPrice)) + if err != nil { + return nil, err + } + // Use legacy if no dynamic is available. + gasPrice := gasPriceWei.Legacy.ToInt() + if gasPriceWei.DynamicFeeCap != nil { + gasPrice = gasPriceWei.DynamicFeeCap.ToInt() + } + if gasPrice == nil { + return nil, fmt.Errorf("missing gas price %+v", gasPriceWei) + } + + return gasPrice, nil +} + +func (g ExecGasPriceEstimator) DenoteInUSD(p GasPrice, wrappedNativePrice *big.Int) (GasPrice, error) { + return ccipcalc.CalculateUsdPerUnitGas(p, wrappedNativePrice), nil +} + +func (g ExecGasPriceEstimator) Median(gasPrices []GasPrice) (GasPrice, error) { + var prices []*big.Int + for _, p := range gasPrices { + prices = append(prices, p) + } + + return ccipcalc.BigIntMedian(prices), nil +} + +func (g ExecGasPriceEstimator) Deviates(p1 GasPrice, p2 GasPrice) (bool, error) { + return ccipcalc.Deviates(p1, p2, g.deviationPPB), nil +} + +func (g ExecGasPriceEstimator) EstimateMsgCostUSD(p GasPrice, wrappedNativePrice *big.Int, msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error) { + execGasAmount := new(big.Int).Add(big.NewInt(feeBoostingOverheadGas), msg.GasLimit) + execGasAmount = new(big.Int).Add(execGasAmount, new(big.Int).Mul(big.NewInt(int64(len(msg.Data))), big.NewInt(execGasPerPayloadByte))) + execGasAmount = new(big.Int).Add(execGasAmount, new(big.Int).Mul(big.NewInt(int64(len(msg.TokenAmounts))), big.NewInt(execGasPerToken))) + + execGasCost := new(big.Int).Mul(execGasAmount, p) + + return ccipcalc.CalculateUsdPerUnitGas(execGasCost, wrappedNativePrice), nil +} + +func (g ExecGasPriceEstimator) String(p GasPrice) string { + var pi *big.Int = p + return pi.String() +} diff --git a/core/services/ocr2/plugins/ccip/prices/exec_price_estimator_test.go b/core/services/ocr2/plugins/ccip/prices/exec_price_estimator_test.go new file mode 100644 index 0000000000..f55bf8673f --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/exec_price_estimator_test.go @@ -0,0 +1,359 @@ +package prices + +import ( + "context" + "math/big" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/mocks" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_offramp" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" +) + +func TestExecPriceEstimator_GetGasPrice(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + sourceFeeEstimatorRespFee gas.EvmFee + sourceFeeEstimatorRespErr error + maxGasPrice *big.Int + expPrice GasPrice + expErr bool + }{ + { + name: "gets legacy gas price", + sourceFeeEstimatorRespFee: gas.EvmFee{ + Legacy: assets.NewWei(big.NewInt(10)), + DynamicFeeCap: nil, + }, + sourceFeeEstimatorRespErr: nil, + maxGasPrice: big.NewInt(1), + expPrice: big.NewInt(10), + expErr: false, + }, + { + name: "gets dynamic gas price", + sourceFeeEstimatorRespFee: gas.EvmFee{ + Legacy: nil, + DynamicFeeCap: assets.NewWei(big.NewInt(20)), + }, + sourceFeeEstimatorRespErr: nil, + maxGasPrice: big.NewInt(1), + expPrice: big.NewInt(20), + expErr: false, + }, + { + name: "gets dynamic gas price over legacy gas price", + sourceFeeEstimatorRespFee: gas.EvmFee{ + Legacy: assets.NewWei(big.NewInt(10)), + DynamicFeeCap: assets.NewWei(big.NewInt(20)), + }, + sourceFeeEstimatorRespErr: nil, + maxGasPrice: big.NewInt(1), + expPrice: big.NewInt(20), + expErr: false, + }, + { + name: "fee estimator error", + sourceFeeEstimatorRespFee: gas.EvmFee{ + Legacy: assets.NewWei(big.NewInt(10)), + DynamicFeeCap: nil, + }, + sourceFeeEstimatorRespErr: errors.New("fee estimator error"), + maxGasPrice: big.NewInt(1), + expPrice: nil, + expErr: true, + }, + { + name: "nil gas price error", + sourceFeeEstimatorRespFee: gas.EvmFee{ + Legacy: nil, + DynamicFeeCap: nil, + }, + sourceFeeEstimatorRespErr: nil, + maxGasPrice: big.NewInt(1), + expPrice: nil, + expErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sourceFeeEstimator := mocks.NewEvmFeeEstimator(t) + sourceFeeEstimator.On("GetFee", ctx, []byte(nil), uint32(0), assets.NewWei(tc.maxGasPrice)).Return( + tc.sourceFeeEstimatorRespFee, uint32(0), tc.sourceFeeEstimatorRespErr) + + g := ExecGasPriceEstimator{ + estimator: sourceFeeEstimator, + maxGasPrice: tc.maxGasPrice, + } + + gasPrice, err := g.GetGasPrice(ctx) + if tc.expErr { + assert.Nil(t, gasPrice) + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expPrice, gasPrice) + }) + } +} + +func TestExecPriceEstimator_DenoteInUSD(t *testing.T) { + val1e18 := func(val int64) *big.Int { return new(big.Int).Mul(big.NewInt(1e18), big.NewInt(val)) } + + testCases := []struct { + name string + gasPrice GasPrice + nativePrice *big.Int + expPrice GasPrice + }{ + { + name: "base", + gasPrice: big.NewInt(1e9), + nativePrice: val1e18(2_000), + expPrice: big.NewInt(2_000e9), + }, + { + name: "low price truncates to 0", + gasPrice: big.NewInt(1e9), + nativePrice: big.NewInt(1), + expPrice: big.NewInt(0), + }, + { + name: "high price", + gasPrice: val1e18(1), + nativePrice: val1e18(2_000), + expPrice: val1e18(2_000), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := ExecGasPriceEstimator{} + + gasPrice, err := g.DenoteInUSD(tc.gasPrice, tc.nativePrice) + assert.NoError(t, err) + assert.True(t, ((*big.Int)(tc.expPrice)).Cmp(gasPrice) == 0) + }) + } +} + +func TestExecPriceEstimator_Median(t *testing.T) { + val1e18 := func(val int64) *big.Int { return new(big.Int).Mul(big.NewInt(1e18), big.NewInt(val)) } + + testCases := []struct { + name string + gasPrices []GasPrice + expMedian GasPrice + }{ + { + name: "base", + gasPrices: []GasPrice{big.NewInt(1), big.NewInt(2), big.NewInt(3)}, + expMedian: big.NewInt(2), + }, + { + name: "median 1", + gasPrices: []GasPrice{big.NewInt(1)}, + expMedian: big.NewInt(1), + }, + { + name: "median 2", + gasPrices: []GasPrice{big.NewInt(1), big.NewInt(2)}, + expMedian: big.NewInt(2), + }, + { + name: "large values", + gasPrices: []GasPrice{val1e18(5), val1e18(4), val1e18(3), val1e18(2), val1e18(1)}, + expMedian: val1e18(3), + }, + { + name: "zeros", + gasPrices: []GasPrice{big.NewInt(0), big.NewInt(0), big.NewInt(0)}, + expMedian: big.NewInt(0), + }, + { + name: "unsorted even number of prices", + gasPrices: []GasPrice{big.NewInt(4), big.NewInt(2), big.NewInt(3), big.NewInt(1)}, + expMedian: big.NewInt(3), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := ExecGasPriceEstimator{} + + gasPrice, err := g.Median(tc.gasPrices) + assert.NoError(t, err) + assert.True(t, ((*big.Int)(tc.expMedian)).Cmp(gasPrice) == 0) + }) + } +} + +func TestExecPriceEstimator_Deviates(t *testing.T) { + testCases := []struct { + name string + gasPrice1 GasPrice + gasPrice2 GasPrice + deviationPPB int64 + expDeviates bool + }{ + { + name: "base", + gasPrice1: big.NewInt(100e8), + gasPrice2: big.NewInt(79e8), + deviationPPB: 2e8, + expDeviates: true, + }, + { + name: "negative difference also deviates", + gasPrice1: big.NewInt(100e8), + gasPrice2: big.NewInt(121e8), + deviationPPB: 2e8, + expDeviates: true, + }, + { + name: "larger difference deviates", + gasPrice1: big.NewInt(100e8), + gasPrice2: big.NewInt(70e8), + deviationPPB: 2e8, + expDeviates: true, + }, + { + name: "smaller difference does not deviate", + gasPrice1: big.NewInt(100e8), + gasPrice2: big.NewInt(90e8), + deviationPPB: 2e8, + expDeviates: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := ExecGasPriceEstimator{ + deviationPPB: tc.deviationPPB, + } + + deviated, err := g.Deviates(tc.gasPrice1, tc.gasPrice2) + assert.NoError(t, err) + if tc.expDeviates { + assert.True(t, deviated) + } else { + assert.False(t, deviated) + } + }) + } +} + +func TestExecPriceEstimator_EstimateMsgCostUSD(t *testing.T) { + testCases := []struct { + name string + gasPrice GasPrice + wrappedNativePrice *big.Int + msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta + expUSD *big.Int + }{ + { + name: "base", + gasPrice: big.NewInt(1e9), // 1 gwei + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + GasLimit: big.NewInt(100_000), + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + }, + }, + expUSD: big.NewInt(300_000e9), + }, + { + name: "base with data", + gasPrice: big.NewInt(1e9), // 1 gwei + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + GasLimit: big.NewInt(100_000), + Data: make([]byte, 1_000), + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + }, + }, + expUSD: big.NewInt(316_000e9), + }, + { + name: "base with data and tokens", + gasPrice: big.NewInt(1e9), // 1 gwei + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + GasLimit: big.NewInt(100_000), + Data: make([]byte, 1_000), + TokenAmounts: make([]evm_2_evm_offramp.ClientEVMTokenAmount, 5), + }, + }, + expUSD: big.NewInt(366_000e9), + }, + { + name: "empty msg", + gasPrice: big.NewInt(1e9), // 1 gwei + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + GasLimit: big.NewInt(0), + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + }, + }, + expUSD: big.NewInt(200_000e9), + }, + { + name: "double native price", + gasPrice: big.NewInt(1e9), // 1 gwei + wrappedNativePrice: big.NewInt(2e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + GasLimit: big.NewInt(0), + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + }, + }, + expUSD: big.NewInt(400_000e9), + }, + { + name: "zero gas price", + gasPrice: big.NewInt(0), // 1 gwei + wrappedNativePrice: big.NewInt(1e18), // $1 + msg: internal.EVM2EVMOnRampCCIPSendRequestedWithMeta{ + InternalEVM2EVMMessage: evm_2_evm_offramp.InternalEVM2EVMMessage{ + GasLimit: big.NewInt(0), + Data: []byte{}, + TokenAmounts: []evm_2_evm_offramp.ClientEVMTokenAmount{}, + }, + }, + expUSD: big.NewInt(0), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := ExecGasPriceEstimator{} + + costUSD, err := g.EstimateMsgCostUSD(tc.gasPrice, tc.wrappedNativePrice, tc.msg) + assert.NoError(t, err) + assert.Equal(t, tc.expUSD, costUSD) + }) + } +} + +func TestExecPriceEstimator_String(t *testing.T) { + g := ExecGasPriceEstimator{} + + str := g.String(big.NewInt(1)) + assert.Equal(t, "1", str) +} diff --git a/core/services/ocr2/plugins/ccip/prices/gas_price_estimator.go b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator.go new file mode 100644 index 0000000000..af51898e60 --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator.go @@ -0,0 +1,133 @@ +package prices + +import ( + "context" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" +) + +const ( + feeBoostingOverheadGas = 200_000 + // execGasPerToken is lower-bound estimation of ERC20 releaseOrMint gas cost (Mint with static minter). + // Use this in per-token gas cost calc as heuristic to simplify estimation logic. + execGasPerToken = 10_000 + // execGasPerPayloadByte is gas charged for passing each byte of `data` payload to CCIP receiver, ignores 4 gas per 0-byte rule. + // This can be a constant as it is part of EVM spec. Changes should be rare. + execGasPerPayloadByte = 16 + // evmMessageFixedBytes is byte size of fixed-size fields in EVM2EVMMessage + // Updating EVM2EVMMessage involves an offchain upgrade, safe to keep this as constant in code. + evmMessageFixedBytes = 448 + evmMessageBytesPerToken = 128 // Byte size of each token transfer, consisting of 1 EVMTokenAmount and 1 bytes, excl length of bytes + daMultiplierBase = int64(10000) // DA multiplier is in multiples of 0.0001, i.e. 1/daMultiplierBase + daGasPriceEncodingLength = 112 // Each gas price takes up at most GasPriceEncodingLength number of bits +) + +// GasPrice represents gas price as a single big.Int, same as gas price representation onchain. +// (multi-component gas prices are encoded into the int) +type GasPrice *big.Int + +// gasPriceEstimatorCommon is abstraction over multi-component gas prices. +type gasPriceEstimatorCommon interface { + // GetGasPrice fetches the current gas price. + GetGasPrice(ctx context.Context) (GasPrice, error) + // DenoteInUSD converts the gas price to be in units of USD. Input prices should not be nil. + DenoteInUSD(p GasPrice, wrappedNativePrice *big.Int) (GasPrice, error) + // Median finds the median gas price in slice. If gas price has multiple components, median of each individual component should be taken. Input prices should not contain nil. + Median(gasPrices []GasPrice) (GasPrice, error) + // String converts the gas price to string. + String(p GasPrice) string +} + +// GasPriceEstimatorCommit provides gasPriceEstimatorCommon + features needed in commit plugin, e.g. price deviation check. +// +//go:generate mockery --quiet --name GasPriceEstimatorCommit --output . --filename gas_price_estimator_commit_mock.go --inpackage --case=underscore +type GasPriceEstimatorCommit interface { + gasPriceEstimatorCommon + // Deviates checks if p1 gas price diffs from p2 by deviation options. Input prices should not be nil. + Deviates(p1 GasPrice, p2 GasPrice) (bool, error) +} + +// GasPriceEstimatorExec provides gasPriceEstimatorCommon + features needed in exec plugin, e.g. message cost estimation. +// +//go:generate mockery --quiet --name GasPriceEstimatorExec --output . --filename gas_price_estimator_exec_mock.go --inpackage --case=underscore +type GasPriceEstimatorExec interface { + gasPriceEstimatorCommon + // EstimateMsgCostUSD estimates the costs for msg execution, and converts to USD value scaled by 1e18 (e.g. 5$ = 5e18). + EstimateMsgCostUSD(p GasPrice, wrappedNativePrice *big.Int, msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error) +} + +// GasPriceEstimator provides complete gas price estimator functions. +// +//go:generate mockery --quiet --name GasPriceEstimator --output . --filename gas_price_estimator_mock.go --inpackage --case=underscore +type GasPriceEstimator interface { + GasPriceEstimatorCommit + GasPriceEstimatorExec +} + +func NewGasPriceEstimatorForCommitPlugin( + commitStoreVersion semver.Version, + estimator gas.EvmFeeEstimator, + maxExecGasPrice *big.Int, + daDeviationPPB int64, + execDeviationPPB int64, +) (GasPriceEstimatorCommit, error) { + execEstimator := ExecGasPriceEstimator{ + estimator: estimator, + maxGasPrice: maxExecGasPrice, + deviationPPB: execDeviationPPB, + } + + switch commitStoreVersion.String() { + case "1.0.0", "1.1.0": + return execEstimator, nil + case "1.2.0": + return DAGasPriceEstimator{ + execEstimator: execEstimator, + l1Oracle: estimator.L1Oracle(), + priceEncodingLength: daGasPriceEncodingLength, + daDeviationPPB: daDeviationPPB, + daOverheadGas: 0, + gasPerDAByte: 0, + daMultiplier: 0, + }, nil + default: + return nil, errors.Errorf("Invalid commitStore version: %s", commitStoreVersion) + } +} + +func NewGasPriceEstimatorForExecPlugin( + commitStoreVersion semver.Version, + estimator gas.EvmFeeEstimator, + maxExecGasPrice *big.Int, + daOverheadGas int64, + gasPerDAByte int64, + daMultiplier int64, +) (GasPriceEstimatorExec, error) { + execEstimator := ExecGasPriceEstimator{ + estimator: estimator, + maxGasPrice: maxExecGasPrice, + deviationPPB: 0, + } + + switch commitStoreVersion.String() { + case "1.0.0", "1.1.0": + return execEstimator, nil + case "1.2.0": + return DAGasPriceEstimator{ + execEstimator: execEstimator, + l1Oracle: estimator.L1Oracle(), + priceEncodingLength: daGasPriceEncodingLength, + daDeviationPPB: 0, + daOverheadGas: daOverheadGas, + gasPerDAByte: gasPerDAByte, + daMultiplier: daMultiplier, + }, nil + default: + return nil, errors.Errorf("Invalid commitStore version: %s", commitStoreVersion) + } +} diff --git a/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_commit_mock.go b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_commit_mock.go new file mode 100644 index 0000000000..86d3d3c27c --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_commit_mock.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package prices + +import ( + context "context" + big "math/big" + + mock "github.com/stretchr/testify/mock" +) + +// MockGasPriceEstimatorCommit is an autogenerated mock type for the GasPriceEstimatorCommit type +type MockGasPriceEstimatorCommit struct { + mock.Mock +} + +// DenoteInUSD provides a mock function with given fields: p, wrappedNativePrice +func (_m *MockGasPriceEstimatorCommit) DenoteInUSD(p GasPrice, wrappedNativePrice *big.Int) (GasPrice, error) { + ret := _m.Called(p, wrappedNativePrice) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int) (GasPrice, error)); ok { + return rf(p, wrappedNativePrice) + } + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int) GasPrice); ok { + r0 = rf(p, wrappedNativePrice) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func(GasPrice, *big.Int) error); ok { + r1 = rf(p, wrappedNativePrice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Deviates provides a mock function with given fields: p1, p2 +func (_m *MockGasPriceEstimatorCommit) Deviates(p1 GasPrice, p2 GasPrice) (bool, error) { + ret := _m.Called(p1, p2) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, GasPrice) (bool, error)); ok { + return rf(p1, p2) + } + if rf, ok := ret.Get(0).(func(GasPrice, GasPrice) bool); ok { + r0 = rf(p1, p2) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(GasPrice, GasPrice) error); ok { + r1 = rf(p1, p2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetGasPrice provides a mock function with given fields: ctx +func (_m *MockGasPriceEstimatorCommit) GetGasPrice(ctx context.Context) (GasPrice, error) { + ret := _m.Called(ctx) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (GasPrice, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) GasPrice); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Median provides a mock function with given fields: gasPrices +func (_m *MockGasPriceEstimatorCommit) Median(gasPrices []GasPrice) (GasPrice, error) { + ret := _m.Called(gasPrices) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func([]GasPrice) (GasPrice, error)); ok { + return rf(gasPrices) + } + if rf, ok := ret.Get(0).(func([]GasPrice) GasPrice); ok { + r0 = rf(gasPrices) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func([]GasPrice) error); ok { + r1 = rf(gasPrices) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// String provides a mock function with given fields: p +func (_m *MockGasPriceEstimatorCommit) String(p GasPrice) string { + ret := _m.Called(p) + + var r0 string + if rf, ok := ret.Get(0).(func(GasPrice) string); ok { + r0 = rf(p) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +type mockConstructorTestingTNewMockGasPriceEstimatorCommit interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockGasPriceEstimatorCommit creates a new instance of MockGasPriceEstimatorCommit. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockGasPriceEstimatorCommit(t mockConstructorTestingTNewMockGasPriceEstimatorCommit) *MockGasPriceEstimatorCommit { + mock := &MockGasPriceEstimatorCommit{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_exec_mock.go b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_exec_mock.go new file mode 100644 index 0000000000..f1fd155da0 --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_exec_mock.go @@ -0,0 +1,149 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package prices + +import ( + context "context" + big "math/big" + + internal "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" + mock "github.com/stretchr/testify/mock" +) + +// MockGasPriceEstimatorExec is an autogenerated mock type for the GasPriceEstimatorExec type +type MockGasPriceEstimatorExec struct { + mock.Mock +} + +// DenoteInUSD provides a mock function with given fields: p, wrappedNativePrice +func (_m *MockGasPriceEstimatorExec) DenoteInUSD(p GasPrice, wrappedNativePrice *big.Int) (GasPrice, error) { + ret := _m.Called(p, wrappedNativePrice) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int) (GasPrice, error)); ok { + return rf(p, wrappedNativePrice) + } + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int) GasPrice); ok { + r0 = rf(p, wrappedNativePrice) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func(GasPrice, *big.Int) error); ok { + r1 = rf(p, wrappedNativePrice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// EstimateMsgCostUSD provides a mock function with given fields: p, wrappedNativePrice, msg +func (_m *MockGasPriceEstimatorExec) EstimateMsgCostUSD(p GasPrice, wrappedNativePrice *big.Int, msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error) { + ret := _m.Called(p, wrappedNativePrice, msg) + + var r0 *big.Int + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int, internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error)); ok { + return rf(p, wrappedNativePrice, msg) + } + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int, internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) *big.Int); ok { + r0 = rf(p, wrappedNativePrice, msg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*big.Int) + } + } + + if rf, ok := ret.Get(1).(func(GasPrice, *big.Int, internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) error); ok { + r1 = rf(p, wrappedNativePrice, msg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetGasPrice provides a mock function with given fields: ctx +func (_m *MockGasPriceEstimatorExec) GetGasPrice(ctx context.Context) (GasPrice, error) { + ret := _m.Called(ctx) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (GasPrice, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) GasPrice); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Median provides a mock function with given fields: gasPrices +func (_m *MockGasPriceEstimatorExec) Median(gasPrices []GasPrice) (GasPrice, error) { + ret := _m.Called(gasPrices) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func([]GasPrice) (GasPrice, error)); ok { + return rf(gasPrices) + } + if rf, ok := ret.Get(0).(func([]GasPrice) GasPrice); ok { + r0 = rf(gasPrices) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func([]GasPrice) error); ok { + r1 = rf(gasPrices) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// String provides a mock function with given fields: p +func (_m *MockGasPriceEstimatorExec) String(p GasPrice) string { + ret := _m.Called(p) + + var r0 string + if rf, ok := ret.Get(0).(func(GasPrice) string); ok { + r0 = rf(p) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +type mockConstructorTestingTNewMockGasPriceEstimatorExec interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockGasPriceEstimatorExec creates a new instance of MockGasPriceEstimatorExec. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockGasPriceEstimatorExec(t mockConstructorTestingTNewMockGasPriceEstimatorExec) *MockGasPriceEstimatorExec { + mock := &MockGasPriceEstimatorExec{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_mock.go b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_mock.go new file mode 100644 index 0000000000..8ff5e29cd3 --- /dev/null +++ b/core/services/ocr2/plugins/ccip/prices/gas_price_estimator_mock.go @@ -0,0 +1,173 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package prices + +import ( + context "context" + big "math/big" + + internal "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal" + mock "github.com/stretchr/testify/mock" +) + +// MockGasPriceEstimator is an autogenerated mock type for the GasPriceEstimator type +type MockGasPriceEstimator struct { + mock.Mock +} + +// DenoteInUSD provides a mock function with given fields: p, wrappedNativePrice +func (_m *MockGasPriceEstimator) DenoteInUSD(p GasPrice, wrappedNativePrice *big.Int) (GasPrice, error) { + ret := _m.Called(p, wrappedNativePrice) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int) (GasPrice, error)); ok { + return rf(p, wrappedNativePrice) + } + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int) GasPrice); ok { + r0 = rf(p, wrappedNativePrice) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func(GasPrice, *big.Int) error); ok { + r1 = rf(p, wrappedNativePrice) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Deviates provides a mock function with given fields: p1, p2 +func (_m *MockGasPriceEstimator) Deviates(p1 GasPrice, p2 GasPrice) (bool, error) { + ret := _m.Called(p1, p2) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, GasPrice) (bool, error)); ok { + return rf(p1, p2) + } + if rf, ok := ret.Get(0).(func(GasPrice, GasPrice) bool); ok { + r0 = rf(p1, p2) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(GasPrice, GasPrice) error); ok { + r1 = rf(p1, p2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// EstimateMsgCostUSD provides a mock function with given fields: p, wrappedNativePrice, msg +func (_m *MockGasPriceEstimator) EstimateMsgCostUSD(p GasPrice, wrappedNativePrice *big.Int, msg internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error) { + ret := _m.Called(p, wrappedNativePrice, msg) + + var r0 *big.Int + var r1 error + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int, internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) (*big.Int, error)); ok { + return rf(p, wrappedNativePrice, msg) + } + if rf, ok := ret.Get(0).(func(GasPrice, *big.Int, internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) *big.Int); ok { + r0 = rf(p, wrappedNativePrice, msg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*big.Int) + } + } + + if rf, ok := ret.Get(1).(func(GasPrice, *big.Int, internal.EVM2EVMOnRampCCIPSendRequestedWithMeta) error); ok { + r1 = rf(p, wrappedNativePrice, msg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetGasPrice provides a mock function with given fields: ctx +func (_m *MockGasPriceEstimator) GetGasPrice(ctx context.Context) (GasPrice, error) { + ret := _m.Called(ctx) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (GasPrice, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) GasPrice); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Median provides a mock function with given fields: gasPrices +func (_m *MockGasPriceEstimator) Median(gasPrices []GasPrice) (GasPrice, error) { + ret := _m.Called(gasPrices) + + var r0 GasPrice + var r1 error + if rf, ok := ret.Get(0).(func([]GasPrice) (GasPrice, error)); ok { + return rf(gasPrices) + } + if rf, ok := ret.Get(0).(func([]GasPrice) GasPrice); ok { + r0 = rf(gasPrices) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(GasPrice) + } + } + + if rf, ok := ret.Get(1).(func([]GasPrice) error); ok { + r1 = rf(gasPrices) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// String provides a mock function with given fields: p +func (_m *MockGasPriceEstimator) String(p GasPrice) string { + ret := _m.Called(p) + + var r0 string + if rf, ok := ret.Get(0).(func(GasPrice) string); ok { + r0 = rf(p) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +type mockConstructorTestingTNewMockGasPriceEstimator interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockGasPriceEstimator creates a new instance of MockGasPriceEstimator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockGasPriceEstimator(t mockConstructorTestingTNewMockGasPriceEstimator) *MockGasPriceEstimator { + mock := &MockGasPriceEstimator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/ocr2/plugins/ccip/testhelpers/config.go b/core/services/ocr2/plugins/ccip/testhelpers/config.go index b8a29fc960..8e10d17bad 100644 --- a/core/services/ocr2/plugins/ccip/testhelpers/config.go +++ b/core/services/ocr2/plugins/ccip/testhelpers/config.go @@ -29,12 +29,15 @@ func (c *CCIPContracts) CreateDefaultCommitOffchainConfig(t *testing.T) []byte { func (c *CCIPContracts) createCommitOffchainConfig(t *testing.T, feeUpdateHearBeat time.Duration, inflightCacheExpiry time.Duration) []byte { config, err := ccipconfig.EncodeOffchainConfig(ccipconfig.CommitOffchainConfig{ - SourceFinalityDepth: 1, - DestFinalityDepth: 1, - FeeUpdateHeartBeat: models.MustMakeDuration(feeUpdateHearBeat), - FeeUpdateDeviationPPB: 1, - MaxGasPrice: 200e9, - InflightCacheExpiry: models.MustMakeDuration(inflightCacheExpiry), + SourceFinalityDepth: 1, + DestFinalityDepth: 1, + GasPriceHeartBeat: models.MustMakeDuration(feeUpdateHearBeat), + DAGasPriceDeviationPPB: 1, + ExecGasPriceDeviationPPB: 1, + TokenPriceHeartBeat: models.MustMakeDuration(feeUpdateHearBeat), + TokenPriceDeviationPPB: 1, + MaxGasPrice: 200e9, + InflightCacheExpiry: models.MustMakeDuration(inflightCacheExpiry), }) require.NoError(t, err) return config diff --git a/core/services/ocr2/plugins/ccip/testhelpers/onramp.go b/core/services/ocr2/plugins/ccip/testhelpers/onramp.go index bd51b2ccd2..e7e9cabe5e 100644 --- a/core/services/ocr2/plugins/ccip/testhelpers/onramp.go +++ b/core/services/ocr2/plugins/ccip/testhelpers/onramp.go @@ -14,6 +14,10 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/utils" ) +const ( + FakeOnRampVersion = "1.2.0" +) + type FakeOnRamp struct { *mock_contracts.EVM2EVMOnRampInterface @@ -32,7 +36,7 @@ func NewFakeOnRamp(t *testing.T) (*FakeOnRamp, common.Address) { } func (o *FakeOnRamp) TypeAndVersion(opts *bind.CallOpts) (string, error) { - return fmt.Sprintf("%s %s", ccipconfig.EVM2EVMOnRamp, "1.2.0"), nil + return fmt.Sprintf("%s %s", ccipconfig.EVM2EVMOnRamp, FakeOnRampVersion), nil } func (o *FakeOnRamp) SetDynamicCfg(cfg evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig) { diff --git a/docs/CHANGELOG_CCIP.md b/docs/CHANGELOG_CCIP.md index 6d8b37b140..3f9713ac2d 100644 --- a/docs/CHANGELOG_CCIP.md +++ b/docs/CHANGELOG_CCIP.md @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `destDataAvailabilityOverheadGas` is the extra data availability gas charged on top of message data. - `destGasPerDataAvailabilityByte` is the amount of gas to charge per byte of data that needs data availability. - `destDataAvailabilityMultiplier` is the multiplier for data availability gas. It is in multiples of 1e-4, or 0.0001. It can represent calldata compression factor on Optimistic Rollups. +- MessageId hashing logic updated. + - the new `sourceTokenData` field is added to the hash. + - fixed-size fields are hashed in nested hash function. +- CommitStore OffchainConfig fields updated. + - New fields `GasPriceHeartBeat`, `DAGasPriceDeviationPPB`, `ExecGasPriceDeviationPPB`, `TokenPriceHeartBeat`, `TokenPriceDeviationPPB` added + - Old Fields `FeeUpdateHeartBeat`, `FeeUpdateDeviationPPB` removed. ### Removed diff --git a/integration-tests/ccip-tests/actions/ccip_helpers.go b/integration-tests/ccip-tests/actions/ccip_helpers.go index 5a5fb74017..5518bec28d 100644 --- a/integration-tests/ccip-tests/actions/ccip_helpers.go +++ b/integration-tests/ccip-tests/actions/ccip_helpers.go @@ -1950,12 +1950,15 @@ func SetOCR2Configs(commitNodes, execNodes []*client.CLNodesWithKeys, destCCIP D inflightExpiry = models.MustMakeDuration(InflightExpirySimulated) } signers, transmitters, f, onchainConfig, offchainConfigVersion, offchainConfig, err := contracts.NewOffChainAggregatorV2Config(commitNodes, ccipConfig.CommitOffchainConfig{ - SourceFinalityDepth: 1, - DestFinalityDepth: 1, - FeeUpdateHeartBeat: models.MustMakeDuration(10 * time.Second), // reduce the heartbeat to 10 sec for faster fee updates - FeeUpdateDeviationPPB: 1e6, - MaxGasPrice: 200e9, - InflightCacheExpiry: inflightExpiry, + SourceFinalityDepth: 1, + DestFinalityDepth: 1, + GasPriceHeartBeat: models.MustMakeDuration(10 * time.Second), // reduce the heartbeat to 10 sec for faster fee updates + DAGasPriceDeviationPPB: 1e6, + ExecGasPriceDeviationPPB: 1e6, + TokenPriceHeartBeat: models.MustMakeDuration(10 * time.Second), + TokenPriceDeviationPPB: 1e6, + MaxGasPrice: 200e9, + InflightCacheExpiry: inflightExpiry, }, ccipConfig.CommitOnchainConfig{ PriceRegistry: destCCIP.Common.PriceRegistry.EthAddress, })