From ad24a2ba0ca720d4e2e413bd882bf74acfab66f7 Mon Sep 17 00:00:00 2001 From: Ryan Stout Date: Tue, 29 Oct 2024 15:24:11 -0700 Subject: [PATCH] Add DA cost estimation to CCIPMessageExecCostUSD18Calculator --- execute/exectypes/costly_messages.go | 70 ++++++++++++++++++-- execute/exectypes/costly_messages_test.go | 78 ++++++++++++++++++----- internal/mocks/inmem/ccipreader_inmem.go | 6 ++ mocks/pkg/reader/ccip_reader.go | 56 ++++++++++++++++ pkg/reader/ccip.go | 67 +++++++++++++++++++ pkg/reader/ccip_interface.go | 3 + pkg/types/ccipocr3/fee_quoter.go | 40 ++++++++++++ 7 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 pkg/types/ccipocr3/fee_quoter.go diff --git a/execute/exectypes/costly_messages.go b/execute/exectypes/costly_messages.go index 3bce58a5..60afdf86 100644 --- a/execute/exectypes/costly_messages.go +++ b/execute/exectypes/costly_messages.go @@ -6,15 +6,20 @@ import ( "math/big" "time" - "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink-ccip/execute/internal/gas" + "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-ccip/internal/plugintypes" readerpkg "github.com/smartcontractkit/chainlink-ccip/pkg/reader" cciptypes "github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3" ) +const ( + EvmWordBytes = 32 + ConstantMessagePartBytes = 10 * 32 // A message consists of 10 abi encoded fields 32B each (after encoding) + daMultiplierBase = 10_000 // DA multiplier is in multiples of 0.0001, i.e. 1/daMultiplierBase +) + // CostlyMessageObserver observes messages that are too costly to execute. type CostlyMessageObserver interface { // Observe takes a set of messages and returns a slice of message IDs that are too costly to execute. @@ -340,11 +345,17 @@ func (c *CCIPMessageExecCostUSD18Calculator) MessageExecCostUSD18( if feeComponents.ExecutionFee == nil { return nil, fmt.Errorf("missing execution fee") } + if feeComponents.DataAvailabilityFee == nil { + return nil, fmt.Errorf("missing data availability fee") + } + daConfig, err := c.ccipReader.GetMedianDataAvailabilityGasConfig(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get data availability gas config: %w", err) + } for _, msg := range messages { executionCostUSD18 := c.computeExecutionCostUSD18(feeComponents.ExecutionFee, msg) - // TODO: implement data availability cost - dataAvailabilityCostUSD18 := new(big.Int) + dataAvailabilityCostUSD18 := computeDataAvailabilityCostUSD18(feeComponents.DataAvailabilityFee, daConfig, msg) totalCostUSD18 := new(big.Int).Add(executionCostUSD18, dataAvailabilityCostUSD18) messageExecCosts[msg.Header.MessageID] = totalCostUSD18 } @@ -364,4 +375,55 @@ func (c *CCIPMessageExecCostUSD18Calculator) computeExecutionCostUSD18( return cost } +// computeDataAvailabilityCostUSD18 computes the data availability cost of a message in USD18s. +func computeDataAvailabilityCostUSD18( + dataAvailabilityFee *big.Int, + daConfig cciptypes.DataAvailabilityGasConfig, + message cciptypes.Message, +) plugintypes.USD18 { + if dataAvailabilityFee == nil || dataAvailabilityFee.Cmp(big.NewInt(0)) == 0 { + return big.NewInt(0) + } + + messageGas := CalculateMessageMaxDAGas(message, daConfig) + return big.NewInt(0).Mul(messageGas, dataAvailabilityFee) +} + +// CalculateMessageMaxDAGas calculates the total DA gas needed for a CCIP message +func CalculateMessageMaxDAGas( + msg cciptypes.Message, + daConfig cciptypes.DataAvailabilityGasConfig, +) *big.Int { + // Calculate token data length + var totalTokenDataLen int + for _, tokenAmount := range msg.TokenAmounts { + totalTokenDataLen += len(tokenAmount.SourcePoolAddress) + + len(tokenAmount.DestTokenAddress) + + len(tokenAmount.ExtraData) + + EvmWordBytes + + len(tokenAmount.DestExecData) + } + + // Calculate total message data length + dataLen := ConstantMessagePartBytes + + len(msg.Data) + + len(msg.Sender) + + len(msg.Receiver) + + len(msg.ExtraArgs) + + len(msg.FeeToken) + + EvmWordBytes*2 + // FeeTokenAmount and FeeValueJuels + totalTokenDataLen + + // Calculate base gas cost + dataGas := big.NewInt(int64(dataLen)) + dataGas = new(big.Int).Mul(dataGas, big.NewInt(int64(daConfig.DestGasPerDataAvailabilityByte))) + dataGas = new(big.Int).Add(dataGas, big.NewInt(int64(daConfig.DestDataAvailabilityOverheadGas))) + + // Then apply the multiplier as: (dataGas * daMultiplier) / multiplierBase + dataGas = new(big.Int).Mul(dataGas, big.NewInt(int64(daConfig.DestDataAvailabilityMultiplierBps))) + dataGas = new(big.Int).Div(dataGas, big.NewInt(daMultiplierBase)) + + return dataGas +} + var _ MessageExecCostUSD18Calculator = &CCIPMessageExecCostUSD18Calculator{} diff --git a/execute/exectypes/costly_messages_test.go b/execute/exectypes/costly_messages_test.go index ce801c25..0621aed5 100644 --- a/execute/exectypes/costly_messages_test.go +++ b/execute/exectypes/costly_messages_test.go @@ -292,16 +292,18 @@ func TestCCIPMessageFeeE18USDCalculator_MessageFeeE18USD(t *testing.T) { func TestCCIPMessageExecCostUSD18Calculator_MessageExecCostUSD18(t *testing.T) { tests := []struct { - name string - messages []ccipocr3.Message - messageGases []uint64 - executionFee *big.Int - feeComponentsError error - want map[ccipocr3.Bytes32]plugintypes.USD18 - wantErr bool + name string + messages []ccipocr3.Message + messageGases []uint64 + executionFee *big.Int + dataAvailabilityFee *big.Int + feeComponentsError error + daGasConfig ccipocr3.DataAvailabilityGasConfig + want map[ccipocr3.Bytes32]plugintypes.USD18 + wantErr bool }{ { - name: "happy path", + name: "happy path, no DA cost", messages: []ccipocr3.Message{ { Header: ccipocr3.RampMessageHeader{MessageID: b1}, @@ -313,9 +315,15 @@ func TestCCIPMessageExecCostUSD18Calculator_MessageExecCostUSD18(t *testing.T) { Header: ccipocr3.RampMessageHeader{MessageID: b3}, }, }, - messageGases: []uint64{100, 200, 300}, - executionFee: big.NewInt(100), - feeComponentsError: nil, + messageGases: []uint64{100, 200, 300}, + executionFee: big.NewInt(100), + dataAvailabilityFee: big.NewInt(0), + feeComponentsError: nil, + daGasConfig: ccipocr3.DataAvailabilityGasConfig{ + DestDataAvailabilityOverheadGas: 1, + DestGasPerDataAvailabilityByte: 1, + DestDataAvailabilityMultiplierBps: 1, + }, want: map[ccipocr3.Bytes32]plugintypes.USD18{ b1: plugintypes.NewUSD18(10000), b2: plugintypes.NewUSD18(20000), @@ -323,6 +331,35 @@ func TestCCIPMessageExecCostUSD18Calculator_MessageExecCostUSD18(t *testing.T) { }, wantErr: false, }, + { + name: "happy path, with DA cost", + messages: []ccipocr3.Message{ + { + Header: ccipocr3.RampMessageHeader{MessageID: b1}, + }, + { + Header: ccipocr3.RampMessageHeader{MessageID: b2}, + }, + { + Header: ccipocr3.RampMessageHeader{MessageID: b3}, + }, + }, + messageGases: []uint64{100, 200, 300}, + executionFee: big.NewInt(100), + dataAvailabilityFee: big.NewInt(400), + feeComponentsError: nil, + daGasConfig: ccipocr3.DataAvailabilityGasConfig{ + DestDataAvailabilityOverheadGas: 1200, + DestGasPerDataAvailabilityByte: 10, + DestDataAvailabilityMultiplierBps: 200, + }, + want: map[ccipocr3.Bytes32]plugintypes.USD18{ + b1: plugintypes.NewUSD18(50000), + b2: plugintypes.NewUSD18(60000), + b3: plugintypes.NewUSD18(70000), + }, + wantErr: false, + }, { name: "fee components error", messages: []ccipocr3.Message{ @@ -336,11 +373,17 @@ func TestCCIPMessageExecCostUSD18Calculator_MessageExecCostUSD18(t *testing.T) { Header: ccipocr3.RampMessageHeader{MessageID: b3}, }, }, - messageGases: []uint64{100, 200, 300}, - executionFee: big.NewInt(100), - feeComponentsError: fmt.Errorf("error"), - want: map[ccipocr3.Bytes32]plugintypes.USD18{}, - wantErr: true, + messageGases: []uint64{100, 200, 300}, + executionFee: big.NewInt(100), + dataAvailabilityFee: big.NewInt(0), + feeComponentsError: fmt.Errorf("error"), + daGasConfig: ccipocr3.DataAvailabilityGasConfig{ + DestDataAvailabilityOverheadGas: 1, + DestGasPerDataAvailabilityByte: 1, + DestDataAvailabilityMultiplierBps: 1, + }, + want: map[ccipocr3.Bytes32]plugintypes.USD18{}, + wantErr: true, }, } @@ -352,9 +395,10 @@ func TestCCIPMessageExecCostUSD18Calculator_MessageExecCostUSD18(t *testing.T) { mockReader := readerpkg_mock.NewMockCCIPReader(t) feeComponents := types.ChainFeeComponents{ ExecutionFee: tt.executionFee, - DataAvailabilityFee: big.NewInt(0), + DataAvailabilityFee: tt.dataAvailabilityFee, } mockReader.EXPECT().GetDestChainFeeComponents(ctx).Return(feeComponents, tt.feeComponentsError) + mockReader.EXPECT().GetMedianDataAvailabilityGasConfig(ctx).Return(tt.daGasConfig, nil).Maybe() ep := gasmock.NewMockEstimateProvider(t) if !tt.wantErr { diff --git a/internal/mocks/inmem/ccipreader_inmem.go b/internal/mocks/inmem/ccipreader_inmem.go index 5361ce09..c164e644 100644 --- a/internal/mocks/inmem/ccipreader_inmem.go +++ b/internal/mocks/inmem/ccipreader_inmem.go @@ -170,5 +170,11 @@ func (r InMemoryCCIPReader) Sync(_ context.Context, _ reader.ContractAddresses) return nil } +func (r InMemoryCCIPReader) GetMedianDataAvailabilityGasConfig( + ctx context.Context, +) (cciptypes.DataAvailabilityGasConfig, error) { + return cciptypes.DataAvailabilityGasConfig{}, nil +} + // Interface compatibility check. var _ reader.CCIPReader = InMemoryCCIPReader{} diff --git a/mocks/pkg/reader/ccip_reader.go b/mocks/pkg/reader/ccip_reader.go index 322a127f..13dfdfbf 100644 --- a/mocks/pkg/reader/ccip_reader.go +++ b/mocks/pkg/reader/ccip_reader.go @@ -486,6 +486,62 @@ func (_c *MockCCIPReader_GetExpectedNextSequenceNumber_Call) RunAndReturn(run fu return _c } +// GetMedianDataAvailabilityGasConfig provides a mock function with given fields: ctx +func (_m *MockCCIPReader) GetMedianDataAvailabilityGasConfig(ctx context.Context) (ccipocr3.DataAvailabilityGasConfig, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetMedianDataAvailabilityGasConfig") + } + + var r0 ccipocr3.DataAvailabilityGasConfig + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (ccipocr3.DataAvailabilityGasConfig, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) ccipocr3.DataAvailabilityGasConfig); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(ccipocr3.DataAvailabilityGasConfig) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMedianDataAvailabilityGasConfig' +type MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call struct { + *mock.Call +} + +// GetMedianDataAvailabilityGasConfig is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockCCIPReader_Expecter) GetMedianDataAvailabilityGasConfig(ctx interface{}) *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call { + return &MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call{Call: _e.mock.On("GetMedianDataAvailabilityGasConfig", ctx)} +} + +func (_c *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call) Run(run func(ctx context.Context)) *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call) Return(_a0 ccipocr3.DataAvailabilityGasConfig, _a1 error) *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call) RunAndReturn(run func(context.Context) (ccipocr3.DataAvailabilityGasConfig, error)) *MockCCIPReader_GetMedianDataAvailabilityGasConfig_Call { + _c.Call.Return(run) + return _c +} + // GetRMNRemoteConfig provides a mock function with given fields: ctx, destChainSelector func (_m *MockCCIPReader) GetRMNRemoteConfig(ctx context.Context, destChainSelector ccipocr3.ChainSelector) (rmntypes.RemoteConfig, error) { ret := _m.Called(ctx, destChainSelector) diff --git a/pkg/reader/ccip.go b/pkg/reader/ccip.go index 3ede7f43..4bce16e5 100644 --- a/pkg/reader/ccip.go +++ b/pkg/reader/ccip.go @@ -13,6 +13,7 @@ import ( "golang.org/x/exp/maps" "golang.org/x/sync/errgroup" + "github.com/smartcontractkit/chainlink-ccip/internal/plugincommon/consensus" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/types/query" @@ -1292,5 +1293,71 @@ func (r *ccipChainReader) getRMNRemoteAddress( return rmnRemoteAddress, nil } +// Get the DestChainConfig from the FeeQuoter contract on the given chain. +func (r *ccipChainReader) getFeeQuoterDestChainConfig( + ctx context.Context, + chainSelector cciptypes.ChainSelector, +) (cciptypes.FeeQuoterDestChainConfig, error) { + if err := validateExtendedReaderExistence(r.contractReaders, chainSelector); err != nil { + return cciptypes.FeeQuoterDestChainConfig{}, err + } + + var destChainConfig cciptypes.FeeQuoterDestChainConfig + srcReader := r.contractReaders[chainSelector] + err := srcReader.ExtendedGetLatestValue( + ctx, + consts.ContractNameFeeQuoter, + consts.MethodNameGetDestChainConfig, + primitives.Unconfirmed, + map[string]any{ + "destChainSelector": r.destChain, + }, + &destChainConfig, + ) + + if err != nil { + return cciptypes.FeeQuoterDestChainConfig{}, fmt.Errorf("failed to get dest chain config: %w", err) + } + + return destChainConfig, nil +} + +// GetMedianDataAvailabilityGasConfig returns the median of the DataAvailabilityGasConfig values from all FeeQuoters +func (r *ccipChainReader) GetMedianDataAvailabilityGasConfig( + ctx context.Context, +) (cciptypes.DataAvailabilityGasConfig, error) { + overheadGasValues := make([]uint32, 0) + gasPerByteValues := make([]uint16, 0) + multiplierBpsValues := make([]uint16, 0) + for chain := range r.contractReaders { + config, err := r.getFeeQuoterDestChainConfig(ctx, chain) + if err != nil { + continue + } + if config.IsEnabled && config.HasNonEmptyDAGasParams() { + overheadGasValues = append(overheadGasValues, config.DestDataAvailabilityOverheadGas) + gasPerByteValues = append(gasPerByteValues, config.DestGasPerDataAvailabilityByte) + multiplierBpsValues = append(multiplierBpsValues, config.DestDataAvailabilityMultiplierBps) + } + } + + if len(overheadGasValues) == 0 { + return cciptypes.DataAvailabilityGasConfig{}, fmt.Errorf("no valid fee quoter destChainConfigs found") + } + + // Calculate medians + medianOverheadGas := consensus.Median(overheadGasValues, func(a, b uint32) bool { return a < b }) + medianGasPerByte := consensus.Median(gasPerByteValues, func(a, b uint16) bool { return a < b }) + medianMultiplierBps := consensus.Median(multiplierBpsValues, func(a, b uint16) bool { return a < b }) + + daConfig := cciptypes.DataAvailabilityGasConfig{ + DestDataAvailabilityOverheadGas: medianOverheadGas, + DestGasPerDataAvailabilityByte: medianGasPerByte, + DestDataAvailabilityMultiplierBps: medianMultiplierBps, + } + + return daConfig, nil +} + // Interface compliance check var _ CCIPReader = (*ccipChainReader)(nil) diff --git a/pkg/reader/ccip_interface.go b/pkg/reader/ccip_interface.go index a5e967b7..7b22128b 100644 --- a/pkg/reader/ccip_interface.go +++ b/pkg/reader/ccip_interface.go @@ -158,4 +158,7 @@ type CCIPReader interface { // Sync can be used to perform frequent syncing operations inside the reader implementation. // Returns a bool indicating whether something was updated. Sync(ctx context.Context, contracts ContractAddresses) error + + // GetMedianDataAvailabilityGasConfig returns the median of the DataAvailabilityGasConfig values from all FeeQuoters + GetMedianDataAvailabilityGasConfig(ctx context.Context) (cciptypes.DataAvailabilityGasConfig, error) } diff --git a/pkg/types/ccipocr3/fee_quoter.go b/pkg/types/ccipocr3/fee_quoter.go new file mode 100644 index 00000000..ac84320c --- /dev/null +++ b/pkg/types/ccipocr3/fee_quoter.go @@ -0,0 +1,40 @@ +package ccipocr3 + +// FeeQuoterDestChainConfig represents the configuration of a destination chain in the FeeQuoter contract +type FeeQuoterDestChainConfig struct { + IsEnabled bool // Whether this destination chain is enabled + MaxNumberOfTokensPerMsg uint16 // Maximum number of distinct ERC20 token transferred per message + MaxDataBytes uint32 // Maximum payload data size in bytes + MaxPerMsgGasLimit uint32 // Maximum gas limit for messages targeting EVMs + DestGasOverhead uint32 // Gas charged on top of the gasLimit to cover destination chain costs + DestGasPerPayloadByte uint16 // Destination chain gas charged per byte of `data` payload to receiver + DestDataAvailabilityOverheadGas uint32 // Extra data availability gas charged, e.g., for OCR + DestGasPerDataAvailabilityByte uint16 // Gas charged per byte of message data needing availability + DestDataAvailabilityMultiplierBps uint16 // Multiplier for data availability gas in bps + DefaultTokenFeeUSDCents uint16 // Default token fee charged per token transfer + DefaultTokenDestGasOverhead uint32 // Default gas charged to execute token transfer on destination + DefaultTxGasLimit uint32 // Default gas limit for a transaction + GasMultiplierWeiPerEth uint64 // Multiplier for gas costs, 1e18 based (11e17 = 10% extra cost) + NetworkFeeUSDCents uint32 // Flat network fee for messages, in multiples of 0.01 USD + GasPriceStalenessThreshold uint32 // Maximum time for gas price to be valid (0 means disabled) + EnforceOutOfOrder bool // Enforce the allowOutOfOrderExecution extraArg to be true + ChainFamilySelector [4]byte // Selector identifying the destination chain's family +} + +// HasNonEmptyDAGasParams returns true if the destination chain has non-empty data availability gas parameters +func (c FeeQuoterDestChainConfig) HasNonEmptyDAGasParams() bool { + return c.DestDataAvailabilityOverheadGas != 0 && c.DestGasPerDataAvailabilityByte != 0 && + c.DestDataAvailabilityMultiplierBps != 0 +} + +type DataAvailabilityGasConfig struct { + DestDataAvailabilityOverheadGas uint32 // Extra data availability gas charged, e.g., for OCR + DestGasPerDataAvailabilityByte uint16 // Gas charged per byte of message data needing availability + DestDataAvailabilityMultiplierBps uint16 // Multiplier for data availability gas in bps +} + +func (c DataAvailabilityGasConfig) isEmpty() bool { + return c.DestDataAvailabilityOverheadGas == 0 && + c.DestGasPerDataAvailabilityByte == 0 && + c.DestDataAvailabilityMultiplierBps == 0 +}