From b563d77dd30ad96253ae6586c06fd34a66d61936 Mon Sep 17 00:00:00 2001 From: Mateusz Sekara <mateusz.sekara@smartcontract.com> Date: Thu, 22 Aug 2024 08:59:21 +0200 Subject: [PATCH] Report all prices from Jobspec (#14185) * Report all prices from Jobspec (#1275) Fetch all the token prices in the jobspec for dest tokens * Missing changeset --------- Co-authored-by: nogo <110664798+0xnogo@users.noreply.github.com> --- .changeset/lucky-boats-run.md | 5 + .mockery.yaml | 5 + .../plugins/ccip/ccipcommit/initializers.go | 2 +- .../plugins/ccip/clo_ccip_integration_test.go | 26 +- .../plugins/ccip/integration_legacy_test.go | 11 - .../ocr2/plugins/ccip/integration_test.go | 11 - .../ccip/internal/ccipcommon/shortcuts.go | 19 +- .../internal/ccipcommon/shortcuts_test.go | 96 ++-- .../ccip/internal/ccipdb/price_service.go | 75 ++- .../internal/ccipdb/price_service_test.go | 170 +++++-- .../pricegetter/all_price_getter_mock.go | 269 +++++++++++ .../plugins/ccip/internal/pricegetter/evm.go | 17 + .../ccip/internal/pricegetter/evm_test.go | 440 +++++++++++++----- .../ccip/internal/pricegetter/pipeline.go | 68 ++- .../internal/pricegetter/pipeline_test.go | 18 +- .../ccip/internal/pricegetter/pricegetter.go | 13 +- .../ccip/testhelpers/integration/chainlink.go | 43 ++ 17 files changed, 973 insertions(+), 315 deletions(-) create mode 100644 .changeset/lucky-boats-run.md create mode 100644 core/services/ocr2/plugins/ccip/internal/pricegetter/all_price_getter_mock.go diff --git a/.changeset/lucky-boats-run.md b/.changeset/lucky-boats-run.md new file mode 100644 index 00000000000..c5864a6e370 --- /dev/null +++ b/.changeset/lucky-boats-run.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +Reporting all the token prices from the job spec for CCIP #updated diff --git a/.mockery.yaml b/.mockery.yaml index 87c27cd46a9..5cba66f3dad 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -529,6 +529,11 @@ packages: PriceGetter: config: mockname: "Mock{{ .InterfaceName }}" + filename: mock.go + AllTokensPriceGetter: + config: + mockname: "Mock{{ .InterfaceName }}" + filename: all_price_getter_mock.go github.com/smartcontractkit/chainlink/v2/core/services/relay/evm/statuschecker: interfaces: CCIPTransactionStatusChecker: diff --git a/core/services/ocr2/plugins/ccip/ccipcommit/initializers.go b/core/services/ocr2/plugins/ccip/ccipcommit/initializers.go index e964896ab93..5fb9733cc64 100644 --- a/core/services/ocr2/plugins/ccip/ccipcommit/initializers.go +++ b/core/services/ocr2/plugins/ccip/ccipcommit/initializers.go @@ -69,7 +69,7 @@ func NewCommitServices(ctx context.Context, ds sqlutil.DataSource, srcProvider c commitStoreReader = ccip.NewProviderProxyCommitStoreReader(srcCommitStore, dstCommitStore) commitLggr := lggr.Named("CCIPCommit").With("sourceChain", sourceChainID, "destChain", destChainID) - var priceGetter pricegetter.PriceGetter + var priceGetter pricegetter.AllTokensPriceGetter withPipeline := strings.Trim(pluginConfig.TokenPricesUSDPipeline, "\n\t ") != "" if withPipeline { priceGetter, err = pricegetter.NewPipelineGetter(pluginConfig.TokenPricesUSDPipeline, pr, jb.ID, jb.ExternalJobID, jb.Name.ValueOrZero(), lggr) diff --git a/core/services/ocr2/plugins/ccip/clo_ccip_integration_test.go b/core/services/ocr2/plugins/ccip/clo_ccip_integration_test.go index 142ba006be6..2fddd58ac8f 100644 --- a/core/services/ocr2/plugins/ccip/clo_ccip_integration_test.go +++ b/core/services/ocr2/plugins/ccip/clo_ccip_integration_test.go @@ -1,6 +1,7 @@ package ccip_test import ( + "context" "encoding/json" "math/big" "testing" @@ -32,7 +33,6 @@ func Test_CLOSpecApprovalFlow_dynamicPriceGetter(t *testing.T) { ccipTH := integrationtesthelpers.SetupCCIPIntegrationTH(t, testhelpers.SourceChainID, testhelpers.SourceChainSelector, testhelpers.DestChainID, testhelpers.DestChainSelector) //Set up the aggregators here to avoid modifying ccipTH. - srcLinkAddr := ccipTH.Source.LinkToken.Address() dstLinkAddr := ccipTH.Dest.LinkToken.Address() srcNativeAddr, err := ccipTH.Source.Router.GetWrappedNative(nil) require.NoError(t, err) @@ -44,13 +44,6 @@ func Test_CLOSpecApprovalFlow_dynamicPriceGetter(t *testing.T) { require.NoError(t, err) ccipTH.Source.Chain.Commit() - aggSrcLnkAddr, _, aggSrcLnk, err := mock_v3_aggregator_contract.DeployMockV3AggregatorContract(ccipTH.Source.User, ccipTH.Source.Chain, 18, big.NewInt(3e18)) - require.NoError(t, err) - ccipTH.Dest.Chain.Commit() - _, err = aggSrcLnk.UpdateRoundData(ccipTH.Source.User, big.NewInt(50), big.NewInt(8000000), big.NewInt(1000), big.NewInt(1000)) - require.NoError(t, err) - ccipTH.Source.Chain.Commit() - aggDstLnkAddr, _, aggDstLnk, err := mock_v3_aggregator_contract.DeployMockV3AggregatorContract(ccipTH.Dest.User, ccipTH.Dest.Chain, 18, big.NewInt(3e18)) require.NoError(t, err) ccipTH.Dest.Chain.Commit() @@ -74,10 +67,6 @@ func Test_CLOSpecApprovalFlow_dynamicPriceGetter(t *testing.T) { priceGetterConfig := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - srcLinkAddr: { - ChainID: ccipTH.Source.ChainID, - AggregatorContractAddress: aggSrcLnkAddr, - }, srcNativeAddr: { ChainID: ccipTH.Source.ChainID, AggregatorContractAddress: aggSrcNatAddr, @@ -125,11 +114,22 @@ func test_CLOSpecApprovalFlow(t *testing.T, ccipTH integrationtesthelpers.CCIPIn _, err = ccipTH.Source.LinkToken.Approve(ccipTH.Source.User, ccipTH.Source.Router.Address(), new(big.Int).Set(fee)) require.NoError(t, err) - ccipTH.Source.Chain.Commit() + blockHash := ccipTH.Dest.Chain.Commit() + // get the block number + block, err := ccipTH.Dest.Chain.BlockByHash(context.Background(), blockHash) + require.NoError(t, err) + blockNumber := block.Number().Uint64() + 1 // +1 as a block will be mined for the request from EventuallyReportCommitted ccipTH.SendRequest(t, msg) ccipTH.AllNodesHaveReqSeqNum(t, currentSeqNum) ccipTH.EventuallyReportCommitted(t, currentSeqNum) + ccipTH.EventuallyPriceRegistryUpdated( + t, + blockNumber, + ccipTH.Source.ChainSelector, + []common.Address{ccipTH.Dest.LinkToken.Address(), ccipTH.Dest.WrappedNative.Address()}, + ccipTH.Source.WrappedNative.Address(), + ) executionLogs := ccipTH.AllNodesHaveExecutedSeqNums(t, currentSeqNum, currentSeqNum) assert.Len(t, executionLogs, 1) diff --git a/core/services/ocr2/plugins/ccip/integration_legacy_test.go b/core/services/ocr2/plugins/ccip/integration_legacy_test.go index 9bc94b5fe45..d89c50b4070 100644 --- a/core/services/ocr2/plugins/ccip/integration_legacy_test.go +++ b/core/services/ocr2/plugins/ccip/integration_legacy_test.go @@ -63,13 +63,6 @@ func TestIntegration_legacy_CCIP(t *testing.T) { require.NoError(t, err) ccipTH.Source.Chain.Commit() - aggSrcLnkAddr, _, aggSrcLnk, err := mock_v3_aggregator_contract.DeployMockV3AggregatorContract(ccipTH.Source.User, ccipTH.Source.Chain, 18, big.NewInt(3e18)) - require.NoError(t, err) - ccipTH.Dest.Chain.Commit() - _, err = aggSrcLnk.UpdateRoundData(ccipTH.Source.User, big.NewInt(50), big.NewInt(8000000), big.NewInt(1000), big.NewInt(1000)) - require.NoError(t, err) - ccipTH.Source.Chain.Commit() - aggDstLnkAddr, _, aggDstLnk, err := mock_v3_aggregator_contract.DeployMockV3AggregatorContract(ccipTH.Dest.User, ccipTH.Dest.Chain, 18, big.NewInt(3e18)) require.NoError(t, err) ccipTH.Dest.Chain.Commit() @@ -79,10 +72,6 @@ func TestIntegration_legacy_CCIP(t *testing.T) { priceGetterConfig := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - ccipTH.Source.LinkToken.Address(): { - ChainID: ccipTH.Source.ChainID, - AggregatorContractAddress: aggSrcLnkAddr, - }, ccipTH.Source.WrappedNative.Address(): { ChainID: ccipTH.Source.ChainID, AggregatorContractAddress: aggSrcNatAddr, diff --git a/core/services/ocr2/plugins/ccip/integration_test.go b/core/services/ocr2/plugins/ccip/integration_test.go index 202d2ef2304..bbf785efa8e 100644 --- a/core/services/ocr2/plugins/ccip/integration_test.go +++ b/core/services/ocr2/plugins/ccip/integration_test.go @@ -66,13 +66,6 @@ func TestIntegration_CCIP(t *testing.T) { require.NoError(t, err) ccipTH.Source.Chain.Commit() - aggSrcLnkAddr, _, aggSrcLnk, err := mock_v3_aggregator_contract.DeployMockV3AggregatorContract(ccipTH.Source.User, ccipTH.Source.Chain, 18, big.NewInt(3e18)) - require.NoError(t, err) - ccipTH.Dest.Chain.Commit() - _, err = aggSrcLnk.UpdateRoundData(ccipTH.Source.User, big.NewInt(50), big.NewInt(8000000), big.NewInt(1000), big.NewInt(1000)) - require.NoError(t, err) - ccipTH.Source.Chain.Commit() - aggDstLnkAddr, _, aggDstLnk, err := mock_v3_aggregator_contract.DeployMockV3AggregatorContract(ccipTH.Dest.User, ccipTH.Dest.Chain, 18, big.NewInt(3e18)) require.NoError(t, err) ccipTH.Dest.Chain.Commit() @@ -82,10 +75,6 @@ func TestIntegration_CCIP(t *testing.T) { priceGetterConfig := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - ccipTH.Source.LinkToken.Address(): { - ChainID: ccipTH.Source.ChainID, - AggregatorContractAddress: aggSrcLnkAddr, - }, ccipTH.Source.WrappedNative.Address(): { ChainID: ccipTH.Source.ChainID, AggregatorContractAddress: aggSrcNatAddr, diff --git a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go index 4f5ba6cfaea..8372ae47486 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go @@ -32,24 +32,7 @@ type BackfillArgs struct { SourceStartBlock, DestStartBlock uint64 } -// GetFilteredSortedLaneTokens returns union of tokens supported on this lane, including fee tokens from the provided price registry -// and the bridgeable tokens from offRamp. Bridgeable tokens are only included if they are configured on the pricegetter -// Fee tokens are not filtered as they must always be priced -func GetFilteredSortedLaneTokens(ctx context.Context, offRamp ccipdata.OffRampReader, priceRegistry cciptypes.PriceRegistryReader, priceGetter cciptypes.PriceGetter) (laneTokens []cciptypes.Address, excludedTokens []cciptypes.Address, err error) { - destFeeTokens, destBridgeableTokens, err := GetDestinationTokens(ctx, offRamp, priceRegistry) - if err != nil { - return nil, nil, fmt.Errorf("get tokens with batch limit: %w", err) - } - - destTokensWithPrice, destTokensWithoutPrice, err := priceGetter.FilterConfiguredTokens(ctx, destBridgeableTokens) - if err != nil { - return nil, nil, fmt.Errorf("filter for priced tokens: %w", err) - } - - return flattenedAndSortedTokens(destFeeTokens, destTokensWithPrice), destTokensWithoutPrice, nil -} - -func flattenedAndSortedTokens(slices ...[]cciptypes.Address) (tokens []cciptypes.Address) { +func FlattenedAndSortedTokens(slices ...[]cciptypes.Address) (tokens []cciptypes.Address) { // fee token can overlap with bridgeable tokens, we need to dedup them to arrive at lane token set tokens = FlattenUniqueSlice(slices...) diff --git a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go index 73a3b834956..6f1cdb4a6af 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go @@ -3,22 +3,14 @@ package ccipcommon import ( "fmt" "math/rand" - "sort" "strconv" "testing" "time" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccip" - - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils" - "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" - "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" - ccipdatamocks "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks" - "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/pricegetter" ) func TestGetMessageIDsAsHexString(t *testing.T) { @@ -70,77 +62,47 @@ func TestFlattenUniqueSlice(t *testing.T) { } } -func TestGetFilteredChainTokens(t *testing.T) { - const numTokens = 6 - var tokens []cciptypes.Address - for i := 0; i < numTokens; i++ { - tokens = append(tokens, ccipcalc.EvmAddrToGeneric(utils.RandomAddress())) - } - +func TestFlattenedAndSortedTokens(t *testing.T) { testCases := []struct { - name string - feeTokens []cciptypes.Address - destTokens []cciptypes.Address - expectedChainTokens []cciptypes.Address - expectedFilteredTokens []cciptypes.Address + name string + inputSlices [][]cciptypes.Address + expectedOutput []cciptypes.Address }{ + {name: "empty", inputSlices: nil, expectedOutput: []cciptypes.Address{}}, + {name: "empty 2", inputSlices: [][]cciptypes.Address{}, expectedOutput: []cciptypes.Address{}}, { - name: "empty", - feeTokens: []cciptypes.Address{}, - destTokens: []cciptypes.Address{}, - expectedChainTokens: []cciptypes.Address{}, - expectedFilteredTokens: []cciptypes.Address{}, + name: "single", + inputSlices: [][]cciptypes.Address{{"0x1", "0x2", "0x3"}}, + expectedOutput: []cciptypes.Address{"0x1", "0x2", "0x3"}, }, { - name: "unique tokens", - feeTokens: []cciptypes.Address{tokens[0]}, - destTokens: []cciptypes.Address{tokens[1], tokens[2], tokens[3]}, - expectedChainTokens: []cciptypes.Address{tokens[0], tokens[1], tokens[2], tokens[3]}, - expectedFilteredTokens: []cciptypes.Address{tokens[4], tokens[5]}, + name: "simple", + inputSlices: [][]cciptypes.Address{{"0x1", "0x2", "0x3"}, {"0x2", "0x3", "0x4"}}, + expectedOutput: []cciptypes.Address{"0x1", "0x2", "0x3", "0x4"}, }, { - name: "all tokens", - feeTokens: []cciptypes.Address{tokens[0]}, - destTokens: []cciptypes.Address{tokens[1], tokens[2], tokens[3], tokens[4], tokens[5]}, - expectedChainTokens: []cciptypes.Address{tokens[0], tokens[1], tokens[2], tokens[3], tokens[4], tokens[5]}, - expectedFilteredTokens: []cciptypes.Address{}, - }, - { - name: "overlapping tokens", - feeTokens: []cciptypes.Address{tokens[0]}, - destTokens: []cciptypes.Address{tokens[1], tokens[2], tokens[5], tokens[3], tokens[0], tokens[2], tokens[3], tokens[4], tokens[5], tokens[5]}, - expectedChainTokens: []cciptypes.Address{tokens[0], tokens[1], tokens[2], tokens[3], tokens[4], tokens[5]}, - expectedFilteredTokens: []cciptypes.Address{}, - }, - { - name: "unconfigured tokens", - feeTokens: []cciptypes.Address{tokens[0]}, - destTokens: []cciptypes.Address{tokens[0], tokens[1], tokens[2], tokens[3], tokens[0], tokens[2], tokens[3], tokens[4], tokens[5], tokens[5]}, - expectedChainTokens: []cciptypes.Address{tokens[0], tokens[1], tokens[2], tokens[3], tokens[4]}, - expectedFilteredTokens: []cciptypes.Address{tokens[5]}, + name: "more complex case", + inputSlices: [][]cciptypes.Address{ + {"0x1", "0x3"}, + {"0x2", "0x4", "0x3"}, + {"0x5", "0x2", "0x7", "0xa"}, + }, + expectedOutput: []cciptypes.Address{ + "0x1", + "0x2", + "0x3", + "0x4", + "0x5", + "0x7", + "0xa", + }, }, } - ctx := testutils.Context(t) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - priceRegistry := ccipdatamocks.NewPriceRegistryReader(t) - priceRegistry.On("GetFeeTokens", ctx).Return(tc.feeTokens, nil).Once() - - priceGet := pricegetter.NewMockPriceGetter(t) - priceGet.On("FilterConfiguredTokens", mock.Anything, mock.Anything).Return(tc.expectedChainTokens, tc.expectedFilteredTokens, nil) - - offRamp := ccipdatamocks.NewOffRampReader(t) - offRamp.On("GetTokens", ctx).Return(cciptypes.OffRampTokens{DestinationTokens: tc.destTokens}, nil).Once() - - chainTokens, filteredTokens, err := GetFilteredSortedLaneTokens(ctx, offRamp, priceRegistry, priceGet) - assert.NoError(t, err) - - sort.Slice(tc.expectedChainTokens, func(i, j int) bool { - return tc.expectedChainTokens[i] < tc.expectedChainTokens[j] - }) - assert.Equal(t, tc.expectedChainTokens, chainTokens) - assert.Equal(t, tc.expectedFilteredTokens, filteredTokens) + res := FlattenedAndSortedTokens(tc.inputSlices...) + assert.Equal(t, tc.expectedOutput, res) }) } } diff --git a/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service.go b/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service.go index 2118d5832da..ad44555477f 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/big" + "slices" "sort" "sync" "time" @@ -18,6 +19,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" cciporm "github.com/smartcontractkit/chainlink/v2/core/services/ccip" "github.com/smartcontractkit/chainlink/v2/core/services/job" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcommon" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/pricegetter" @@ -72,7 +74,7 @@ type priceService struct { sourceChainSelector uint64 sourceNative cciptypes.Address - priceGetter pricegetter.PriceGetter + priceGetter pricegetter.AllTokensPriceGetter offRampReader ccipdata.OffRampReader gasPriceEstimator prices.GasPriceEstimatorCommit destPriceRegistryReader ccipdata.PriceRegistryReader @@ -92,7 +94,7 @@ func NewPriceService( sourceChainSelector uint64, sourceNative cciptypes.Address, - priceGetter pricegetter.PriceGetter, + priceGetter pricegetter.AllTokensPriceGetter, offRampReader ccipdata.OffRampReader, ) PriceService { ctx, cancel := context.WithCancel(context.Background()) @@ -322,6 +324,7 @@ func (p *priceService) observeGasPriceUpdates( // Include wrapped native to identify the source native USD price, notice USD is in 1e18 scale, i.e. $1 = 1e18 rawTokenPricesUSD, err := p.priceGetter.TokenPricesUSD(ctx, []cciptypes.Address{p.sourceNative}) + if err != nil { return nil, fmt.Errorf("failed to fetch source native price (%s): %w", p.sourceNative, err) } @@ -355,6 +358,9 @@ func (p *priceService) observeGasPriceUpdates( } // All prices are USD ($1=1e18) denominated. All prices must be not nil. +// Jobspec should have the destination tokens (Aggregate Rate Limit, Bps) and 1 source token (source native). +// Not respecting this will error out as we need to fetch the token decimals for all tokens expect sourceNative. +// destTokens is only used to check if sourceNative has the same address as one of the dest tokens. // Return token prices should contain the exact same tokens as in tokenDecimals. func (p *priceService) observeTokenPriceUpdates( ctx context.Context, @@ -363,35 +369,72 @@ func (p *priceService) observeTokenPriceUpdates( if p.destPriceRegistryReader == nil { return nil, fmt.Errorf("destPriceRegistry is not set yet") } - - sortedLaneTokens, filteredLaneTokens, err := ccipcommon.GetFilteredSortedLaneTokens(ctx, p.offRampReader, p.destPriceRegistryReader, p.priceGetter) + rawTokenPricesUSD, err := p.priceGetter.GetJobSpecTokenPricesUSD(ctx) if err != nil { - return nil, fmt.Errorf("get destination tokens: %w", err) + return nil, fmt.Errorf("failed to fetch token prices: %w", err) + } + + // Verify no price returned by price getter is nil + for token, price := range rawTokenPricesUSD { + if price == nil { + return nil, fmt.Errorf("Token price is nil for token %s", token) + } } - lggr.Debugw("Filtered bridgeable tokens with no configured price getter", "filteredLaneTokens", filteredLaneTokens) + lggr.Infow("Raw token prices", "rawTokenPrices", rawTokenPricesUSD) - queryTokens := ccipcommon.FlattenUniqueSlice(sortedLaneTokens) - rawTokenPricesUSD, err := p.priceGetter.TokenPricesUSD(ctx, queryTokens) + sourceNativeEvmAddr, err := ccipcalc.GenericAddrToEvm(p.sourceNative) if err != nil { - return nil, fmt.Errorf("failed to fetch token prices (%v): %w", queryTokens, err) + return nil, fmt.Errorf("failed to convert source native to EVM address: %w", err) } - lggr.Infow("Raw token prices", "rawTokenPrices", rawTokenPricesUSD) - // make sure that we got prices for all the tokens of our query - for _, token := range queryTokens { - if rawTokenPricesUSD[token] == nil { - return nil, fmt.Errorf("missing token price: %+v", token) + // Filter out source native token only if source native not in dest tokens + var finalDestTokens []cciptypes.Address + for token := range rawTokenPricesUSD { + tokenEvmAddr, err2 := ccipcalc.GenericAddrToEvm(token) + if err2 != nil { + return nil, fmt.Errorf("failed to convert token to EVM address: %w", err) + } + + if tokenEvmAddr != sourceNativeEvmAddr { + finalDestTokens = append(finalDestTokens, token) } } - destTokensDecimals, err := p.destPriceRegistryReader.GetTokensDecimals(ctx, sortedLaneTokens) + fee, bridged, err := ccipcommon.GetDestinationTokens(ctx, p.offRampReader, p.destPriceRegistryReader) + if err != nil { + return nil, fmt.Errorf("get destination tokens: %w", err) + } + onchainDestTokens := ccipcommon.FlattenedAndSortedTokens(fee, bridged) + lggr.Debugw("Destination tokens", "destTokens", onchainDestTokens) + + onchainTokensEvmAddr, err := ccipcalc.GenericAddrsToEvm(onchainDestTokens...) + if err != nil { + return nil, fmt.Errorf("failed to convert sorted lane tokens to EVM addresses: %w", err) + } + // Check for case where sourceNative has same address as one of the dest tokens (example: WETH in Base and Optimism) + hasSameDestAddress := slices.Contains(onchainTokensEvmAddr, sourceNativeEvmAddr) + + if hasSameDestAddress { + finalDestTokens = append(finalDestTokens, p.sourceNative) + } + + // Sort tokens to make the order deterministic, easier for testing and debugging + sort.Slice(finalDestTokens, func(i, j int) bool { + return finalDestTokens[i] < finalDestTokens[j] + }) + + destTokensDecimals, err := p.destPriceRegistryReader.GetTokensDecimals(ctx, finalDestTokens) if err != nil { return nil, fmt.Errorf("get tokens decimals: %w", err) } + if len(destTokensDecimals) != len(finalDestTokens) { + return nil, fmt.Errorf("mismatched token decimals and tokens") + } + tokenPricesUSD = make(map[cciptypes.Address]*big.Int, len(rawTokenPricesUSD)) - for i, token := range sortedLaneTokens { + for i, token := range finalDestTokens { tokenPricesUSD[token] = calculateUsdPer1e18TokenAmount(rawTokenPricesUSD[token], destTokensDecimals[i]) } diff --git a/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service_test.go b/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service_test.go index 26721bdf8e4..0468c3addb8 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service_test.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipdb/price_service_test.go @@ -5,6 +5,7 @@ import ( "fmt" "math/big" "reflect" + "slices" "sort" "testing" "time" @@ -24,7 +25,6 @@ import ( cciporm "github.com/smartcontractkit/chainlink/v2/core/services/ccip" ccipmocks "github.com/smartcontractkit/chainlink/v2/core/services/ccip/mocks" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" - "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcommon" ccipdatamocks "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/pricegetter" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/prices" @@ -314,7 +314,7 @@ func TestPriceService_observeGasPriceUpdates(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - priceGetter := pricegetter.NewMockPriceGetter(t) + priceGetter := pricegetter.NewMockAllTokensPriceGetter(t) defer priceGetter.AssertExpectations(t) gasPriceEstimator := prices.NewMockGasPriceEstimatorCommit(t) @@ -358,6 +358,7 @@ func TestPriceService_observeTokenPriceUpdates(t *testing.T) { jobId := int32(1) destChainSelector := uint64(12345) sourceChainSelector := uint64(67890) + sourceNativeToken := cciptypes.Address(utils.RandomAddress().String()) const nTokens = 10 tokens := make([]cciptypes.Address, nTokens) @@ -368,29 +369,50 @@ func TestPriceService_observeTokenPriceUpdates(t *testing.T) { testCases := []struct { name string + destTokens []cciptypes.Address tokenDecimals map[cciptypes.Address]uint8 + sourceNativeToken cciptypes.Address filterOutTokens []cciptypes.Address priceGetterRespData map[cciptypes.Address]*big.Int priceGetterRespErr error expTokenPricesUSD map[cciptypes.Address]*big.Int expErr bool + expDecimalErr bool }{ { - name: "base", + name: "base case with src native token not equals to dest token", + tokenDecimals: map[cciptypes.Address]uint8{ // only destination tokens + tokens[1]: 18, + tokens[2]: 12, + }, + sourceNativeToken: sourceNativeToken, + priceGetterRespData: map[cciptypes.Address]*big.Int{ // should return all tokens (including source native token) + sourceNativeToken: val1e18(100), + tokens[1]: val1e18(200), + tokens[2]: val1e18(300), + }, + priceGetterRespErr: nil, + expTokenPricesUSD: map[cciptypes.Address]*big.Int{ // should only return the tokens in destination chain + tokens[1]: val1e18(200), + tokens[2]: val1e18(300 * 1e6), + }, + expErr: false, + }, + { + name: "base case with src native token equals to dest token", tokenDecimals: map[cciptypes.Address]uint8{ - tokens[0]: 18, - tokens[1]: 12, + sourceNativeToken: 18, + tokens[1]: 12, }, - filterOutTokens: []cciptypes.Address{tokens[2]}, + sourceNativeToken: sourceNativeToken, priceGetterRespData: map[cciptypes.Address]*big.Int{ - tokens[0]: val1e18(100), - 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) + sourceNativeToken: val1e18(100), + tokens[1]: val1e18(200), }, priceGetterRespErr: nil, expTokenPricesUSD: map[cciptypes.Address]*big.Int{ - tokens[0]: val1e18(100), - tokens[1]: val1e18(200 * 1e6), + sourceNativeToken: val1e18(100), + tokens[1]: val1e18(200 * 1e6), }, expErr: false, }, @@ -400,29 +422,73 @@ func TestPriceService_observeTokenPriceUpdates(t *testing.T) { tokens[0]: 18, tokens[1]: 18, }, + sourceNativeToken: tokens[0], priceGetterRespData: nil, priceGetterRespErr: fmt.Errorf("some random network error"), expErr: true, }, + { + name: "price getter returns more prices than destTokens", + destTokens: []cciptypes.Address{tokens[1]}, + tokenDecimals: map[cciptypes.Address]uint8{ + tokens[1]: 18, + tokens[2]: 12, + tokens[3]: 18, + }, + sourceNativeToken: sourceNativeToken, + priceGetterRespData: map[cciptypes.Address]*big.Int{ + sourceNativeToken: val1e18(100), + tokens[1]: val1e18(200), + tokens[2]: val1e18(300), + tokens[3]: val1e18(400), + }, + expTokenPricesUSD: map[cciptypes.Address]*big.Int{ + tokens[1]: val1e18(200), + tokens[2]: val1e18(300 * 1e6), + tokens[3]: val1e18(400), + }, + }, + { + name: "price getter returns more prices with missing decimals", + tokenDecimals: map[cciptypes.Address]uint8{ + tokens[1]: 18, + tokens[2]: 12, + }, + sourceNativeToken: sourceNativeToken, + priceGetterRespData: map[cciptypes.Address]*big.Int{ + sourceNativeToken: val1e18(100), + tokens[1]: val1e18(200), + tokens[2]: val1e18(300), + tokens[3]: val1e18(400), + }, + priceGetterRespErr: nil, + expErr: true, + expDecimalErr: true, + }, { name: "price getter skipped a requested price", tokenDecimals: map[cciptypes.Address]uint8{ tokens[0]: 18, - tokens[1]: 18, }, + sourceNativeToken: tokens[0], priceGetterRespData: map[cciptypes.Address]*big.Int{ tokens[0]: val1e18(100), }, priceGetterRespErr: nil, - expErr: true, + expTokenPricesUSD: map[cciptypes.Address]*big.Int{ + tokens[0]: val1e18(100), + }, + expErr: false, }, { name: "nil token price", tokenDecimals: map[cciptypes.Address]uint8{ tokens[0]: 18, tokens[1]: 18, + tokens[2]: 18, }, - filterOutTokens: []cciptypes.Address{tokens[2]}, + sourceNativeToken: tokens[0], + filterOutTokens: []cciptypes.Address{tokens[2]}, priceGetterRespData: map[cciptypes.Address]*big.Int{ tokens[0]: nil, tokens[1]: val1e18(200), @@ -434,27 +500,34 @@ func TestPriceService_observeTokenPriceUpdates(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - priceGetter := pricegetter.NewMockPriceGetter(t) + priceGetter := pricegetter.NewMockAllTokensPriceGetter(t) defer priceGetter.AssertExpectations(t) var destTokens []cciptypes.Address - for tk := range tc.tokenDecimals { - destTokens = append(destTokens, tk) + if len(tc.destTokens) == 0 { + for tk := range tc.tokenDecimals { + destTokens = append(destTokens, tk) + } + } else { + destTokens = tc.destTokens + } + + finalDestTokens := make([]cciptypes.Address, 0, len(destTokens)) + for addr := range tc.priceGetterRespData { + if (tc.sourceNativeToken != addr) || (slices.Contains(destTokens, addr)) { + finalDestTokens = append(finalDestTokens, addr) + } } - sort.Slice(destTokens, func(i, j int) bool { - return destTokens[i] < destTokens[j] + sort.Slice(finalDestTokens, func(i, j int) bool { + return finalDestTokens[i] < finalDestTokens[j] }) + var destDecimals []uint8 - for _, token := range destTokens { + for _, token := range finalDestTokens { destDecimals = append(destDecimals, tc.tokenDecimals[token]) } - queryTokens := ccipcommon.FlattenUniqueSlice(destTokens) - - if len(queryTokens) > 0 { - priceGetter.On("TokenPricesUSD", mock.Anything, queryTokens).Return(tc.priceGetterRespData, tc.priceGetterRespErr) - priceGetter.On("FilterConfiguredTokens", mock.Anything, mock.Anything).Return(destTokens, tc.filterOutTokens, nil) - } + priceGetter.On("GetJobSpecTokenPricesUSD", mock.Anything).Return(tc.priceGetterRespData, tc.priceGetterRespErr) offRampReader := ccipdatamocks.NewOffRampReader(t) offRampReader.On("GetTokens", mock.Anything).Return(cciptypes.OffRampTokens{ @@ -462,7 +535,11 @@ func TestPriceService_observeTokenPriceUpdates(t *testing.T) { }, nil).Maybe() destPriceReg := ccipdatamocks.NewPriceRegistryReader(t) - destPriceReg.On("GetTokensDecimals", mock.Anything, destTokens).Return(destDecimals, nil).Maybe() + if tc.expDecimalErr { + destPriceReg.On("GetTokensDecimals", mock.Anything, finalDestTokens).Return([]uint8{}, fmt.Errorf("Token not found")).Maybe() + } else { + destPriceReg.On("GetTokensDecimals", mock.Anything, finalDestTokens).Return(destDecimals, nil).Maybe() + } destPriceReg.On("GetFeeTokens", mock.Anything).Return([]cciptypes.Address{destTokens[0]}, nil).Maybe() priceService := NewPriceService( @@ -471,7 +548,7 @@ func TestPriceService_observeTokenPriceUpdates(t *testing.T) { jobId, destChainSelector, sourceChainSelector, - "0x123", + tc.sourceNativeToken, priceGetter, offRampReader, ).(*priceService) @@ -754,39 +831,48 @@ func TestPriceService_priceWriteAndCleanupInBackground(t *testing.T) { sourceChainSelector := uint64(67890) ctx := tests.Context(t) - sourceNative := cciptypes.Address("0x123") - feeTokens := []cciptypes.Address{"0x234"} - rampTokens := []cciptypes.Address{"0x345", "0x456"} - rampFilteredTokens := []cciptypes.Address{"0x345"} - rampFilterOutTokens := []cciptypes.Address{"0x456"} + sourceNative := cciptypes.Address(utils.RandomAddress().String()) + feeToken := cciptypes.Address(utils.RandomAddress().String()) + destToken1 := cciptypes.Address(utils.RandomAddress().String()) + destToken2 := cciptypes.Address(utils.RandomAddress().String()) - laneTokens := []cciptypes.Address{"0x234", "0x345"} - laneTokenDecimals := []uint8{18, 18} + feeTokens := []cciptypes.Address{feeToken} + rampTokens := []cciptypes.Address{destToken1, destToken2} - tokens := []cciptypes.Address{sourceNative, "0x234", "0x345"} - tokenPrices := []int64{2, 3, 4} + laneTokens := []cciptypes.Address{sourceNative, feeToken, destToken1, destToken2} + // sort laneTokens + sort.Slice(laneTokens, func(i, j int) bool { + return laneTokens[i] < laneTokens[j] + }) + laneTokenDecimals := []uint8{18, 18, 18, 18} + + tokens := []cciptypes.Address{sourceNative, feeToken, destToken1, destToken2} + tokenPrices := []int64{2, 3, 4, 5} gasPrice := big.NewInt(10) orm := setupORM(t) - priceGetter := pricegetter.NewMockPriceGetter(t) + priceGetter := pricegetter.NewMockAllTokensPriceGetter(t) defer priceGetter.AssertExpectations(t) gasPriceEstimator := prices.NewMockGasPriceEstimatorCommit(t) defer gasPriceEstimator.AssertExpectations(t) - priceGetter.On("TokenPricesUSD", mock.Anything, tokens[:1]).Return(map[cciptypes.Address]*big.Int{ + priceGetter.On("TokenPricesUSD", mock.Anything, []cciptypes.Address{sourceNative}).Return(map[cciptypes.Address]*big.Int{ tokens[0]: val1e18(tokenPrices[0]), }, nil) - priceGetter.On("TokenPricesUSD", mock.Anything, tokens[1:]).Return(map[cciptypes.Address]*big.Int{ + + priceGetter.On("GetJobSpecTokenPricesUSD", mock.Anything).Return(map[cciptypes.Address]*big.Int{ + tokens[0]: val1e18(tokenPrices[0]), tokens[1]: val1e18(tokenPrices[1]), tokens[2]: val1e18(tokenPrices[2]), + tokens[3]: val1e18(tokenPrices[3]), }, nil) - priceGetter.On("FilterConfiguredTokens", mock.Anything, rampTokens).Return(rampFilteredTokens, rampFilterOutTokens, nil) + destTokens := append(rampTokens, sourceNative) offRampReader := ccipdatamocks.NewOffRampReader(t) offRampReader.On("GetTokens", mock.Anything).Return(cciptypes.OffRampTokens{ - DestinationTokens: rampTokens, + DestinationTokens: destTokens, }, nil).Maybe() gasPriceEstimator.On("GetGasPrice", mock.Anything).Return(gasPrice, nil) diff --git a/core/services/ocr2/plugins/ccip/internal/pricegetter/all_price_getter_mock.go b/core/services/ocr2/plugins/ccip/internal/pricegetter/all_price_getter_mock.go new file mode 100644 index 00000000000..010c955c766 --- /dev/null +++ b/core/services/ocr2/plugins/ccip/internal/pricegetter/all_price_getter_mock.go @@ -0,0 +1,269 @@ +// Code generated by mockery v2.43.2. DO NOT EDIT. + +package pricegetter + +import ( + context "context" + big "math/big" + + ccip "github.com/smartcontractkit/chainlink-common/pkg/types/ccip" + + mock "github.com/stretchr/testify/mock" +) + +// MockAllTokensPriceGetter is an autogenerated mock type for the AllTokensPriceGetter type +type MockAllTokensPriceGetter struct { + mock.Mock +} + +type MockAllTokensPriceGetter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAllTokensPriceGetter) EXPECT() *MockAllTokensPriceGetter_Expecter { + return &MockAllTokensPriceGetter_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with given fields: +func (_m *MockAllTokensPriceGetter) Close() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockAllTokensPriceGetter_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockAllTokensPriceGetter_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockAllTokensPriceGetter_Expecter) Close() *MockAllTokensPriceGetter_Close_Call { + return &MockAllTokensPriceGetter_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockAllTokensPriceGetter_Close_Call) Run(run func()) *MockAllTokensPriceGetter_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAllTokensPriceGetter_Close_Call) Return(_a0 error) *MockAllTokensPriceGetter_Close_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAllTokensPriceGetter_Close_Call) RunAndReturn(run func() error) *MockAllTokensPriceGetter_Close_Call { + _c.Call.Return(run) + return _c +} + +// FilterConfiguredTokens provides a mock function with given fields: ctx, tokens +func (_m *MockAllTokensPriceGetter) FilterConfiguredTokens(ctx context.Context, tokens []ccip.Address) ([]ccip.Address, []ccip.Address, error) { + ret := _m.Called(ctx, tokens) + + if len(ret) == 0 { + panic("no return value specified for FilterConfiguredTokens") + } + + var r0 []ccip.Address + var r1 []ccip.Address + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, []ccip.Address) ([]ccip.Address, []ccip.Address, error)); ok { + return rf(ctx, tokens) + } + if rf, ok := ret.Get(0).(func(context.Context, []ccip.Address) []ccip.Address); ok { + r0 = rf(ctx, tokens) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]ccip.Address) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []ccip.Address) []ccip.Address); ok { + r1 = rf(ctx, tokens) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]ccip.Address) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, []ccip.Address) error); ok { + r2 = rf(ctx, tokens) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MockAllTokensPriceGetter_FilterConfiguredTokens_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FilterConfiguredTokens' +type MockAllTokensPriceGetter_FilterConfiguredTokens_Call struct { + *mock.Call +} + +// FilterConfiguredTokens is a helper method to define mock.On call +// - ctx context.Context +// - tokens []ccip.Address +func (_e *MockAllTokensPriceGetter_Expecter) FilterConfiguredTokens(ctx interface{}, tokens interface{}) *MockAllTokensPriceGetter_FilterConfiguredTokens_Call { + return &MockAllTokensPriceGetter_FilterConfiguredTokens_Call{Call: _e.mock.On("FilterConfiguredTokens", ctx, tokens)} +} + +func (_c *MockAllTokensPriceGetter_FilterConfiguredTokens_Call) Run(run func(ctx context.Context, tokens []ccip.Address)) *MockAllTokensPriceGetter_FilterConfiguredTokens_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]ccip.Address)) + }) + return _c +} + +func (_c *MockAllTokensPriceGetter_FilterConfiguredTokens_Call) Return(configured []ccip.Address, unconfigured []ccip.Address, err error) *MockAllTokensPriceGetter_FilterConfiguredTokens_Call { + _c.Call.Return(configured, unconfigured, err) + return _c +} + +func (_c *MockAllTokensPriceGetter_FilterConfiguredTokens_Call) RunAndReturn(run func(context.Context, []ccip.Address) ([]ccip.Address, []ccip.Address, error)) *MockAllTokensPriceGetter_FilterConfiguredTokens_Call { + _c.Call.Return(run) + return _c +} + +// GetJobSpecTokenPricesUSD provides a mock function with given fields: ctx +func (_m *MockAllTokensPriceGetter) GetJobSpecTokenPricesUSD(ctx context.Context) (map[ccip.Address]*big.Int, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetJobSpecTokenPricesUSD") + } + + var r0 map[ccip.Address]*big.Int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (map[ccip.Address]*big.Int, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) map[ccip.Address]*big.Int); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[ccip.Address]*big.Int) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobSpecTokenPricesUSD' +type MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call struct { + *mock.Call +} + +// GetJobSpecTokenPricesUSD is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockAllTokensPriceGetter_Expecter) GetJobSpecTokenPricesUSD(ctx interface{}) *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call { + return &MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call{Call: _e.mock.On("GetJobSpecTokenPricesUSD", ctx)} +} + +func (_c *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call) Run(run func(ctx context.Context)) *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call) Return(_a0 map[ccip.Address]*big.Int, _a1 error) *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call) RunAndReturn(run func(context.Context) (map[ccip.Address]*big.Int, error)) *MockAllTokensPriceGetter_GetJobSpecTokenPricesUSD_Call { + _c.Call.Return(run) + return _c +} + +// TokenPricesUSD provides a mock function with given fields: ctx, tokens +func (_m *MockAllTokensPriceGetter) TokenPricesUSD(ctx context.Context, tokens []ccip.Address) (map[ccip.Address]*big.Int, error) { + ret := _m.Called(ctx, tokens) + + if len(ret) == 0 { + panic("no return value specified for TokenPricesUSD") + } + + var r0 map[ccip.Address]*big.Int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []ccip.Address) (map[ccip.Address]*big.Int, error)); ok { + return rf(ctx, tokens) + } + if rf, ok := ret.Get(0).(func(context.Context, []ccip.Address) map[ccip.Address]*big.Int); ok { + r0 = rf(ctx, tokens) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[ccip.Address]*big.Int) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []ccip.Address) error); ok { + r1 = rf(ctx, tokens) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAllTokensPriceGetter_TokenPricesUSD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TokenPricesUSD' +type MockAllTokensPriceGetter_TokenPricesUSD_Call struct { + *mock.Call +} + +// TokenPricesUSD is a helper method to define mock.On call +// - ctx context.Context +// - tokens []ccip.Address +func (_e *MockAllTokensPriceGetter_Expecter) TokenPricesUSD(ctx interface{}, tokens interface{}) *MockAllTokensPriceGetter_TokenPricesUSD_Call { + return &MockAllTokensPriceGetter_TokenPricesUSD_Call{Call: _e.mock.On("TokenPricesUSD", ctx, tokens)} +} + +func (_c *MockAllTokensPriceGetter_TokenPricesUSD_Call) Run(run func(ctx context.Context, tokens []ccip.Address)) *MockAllTokensPriceGetter_TokenPricesUSD_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]ccip.Address)) + }) + return _c +} + +func (_c *MockAllTokensPriceGetter_TokenPricesUSD_Call) Return(_a0 map[ccip.Address]*big.Int, _a1 error) *MockAllTokensPriceGetter_TokenPricesUSD_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAllTokensPriceGetter_TokenPricesUSD_Call) RunAndReturn(run func(context.Context, []ccip.Address) (map[ccip.Address]*big.Int, error)) *MockAllTokensPriceGetter_TokenPricesUSD_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAllTokensPriceGetter creates a new instance of MockAllTokensPriceGetter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAllTokensPriceGetter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAllTokensPriceGetter { + mock := &MockAllTokensPriceGetter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/ocr2/plugins/ccip/internal/pricegetter/evm.go b/core/services/ocr2/plugins/ccip/internal/pricegetter/evm.go index ed54428bd9b..ac4002f53fb 100644 --- a/core/services/ocr2/plugins/ccip/internal/pricegetter/evm.go +++ b/core/services/ocr2/plugins/ccip/internal/pricegetter/evm.go @@ -103,6 +103,11 @@ func (d *DynamicPriceGetter) FilterConfiguredTokens(ctx context.Context, tokens return configured, unconfigured, nil } +// It returns the prices of all tokens defined in the price getter. +func (d *DynamicPriceGetter) GetJobSpecTokenPricesUSD(ctx context.Context) (map[cciptypes.Address]*big.Int, error) { + return d.TokenPricesUSD(ctx, d.getAllTokensDefined()) +} + // TokenPricesUSD implements the PriceGetter interface. // It returns static prices stored in the price getter, and batch calls aggregators (one per chain) to retrieve aggregator-based prices. func (d *DynamicPriceGetter) TokenPricesUSD(ctx context.Context, tokens []cciptypes.Address) (map[cciptypes.Address]*big.Int, error) { @@ -116,6 +121,18 @@ func (d *DynamicPriceGetter) TokenPricesUSD(ctx context.Context, tokens []ccipty return prices, nil } +func (d *DynamicPriceGetter) getAllTokensDefined() []cciptypes.Address { + tokens := make([]cciptypes.Address, 0) + + for addr := range d.cfg.AggregatorPrices { + tokens = append(tokens, ccipcalc.EvmAddrToGeneric(addr)) + } + for addr := range d.cfg.StaticPrices { + tokens = append(tokens, ccipcalc.EvmAddrToGeneric(addr)) + } + return tokens +} + // performBatchCalls performs batch calls on all chains to retrieve token prices. func (d *DynamicPriceGetter) performBatchCalls(ctx context.Context, batchCallsPerChain map[uint64]*batchCallsForChain, prices map[cciptypes.Address]*big.Int) error { for chainID, batchCalls := range batchCallsPerChain { diff --git a/core/services/ocr2/plugins/ccip/internal/pricegetter/evm_test.go b/core/services/ocr2/plugins/ccip/internal/pricegetter/evm_test.go index 673b9776c79..78de2699688 100644 --- a/core/services/ocr2/plugins/ccip/internal/pricegetter/evm_test.go +++ b/core/services/ocr2/plugins/ccip/internal/pricegetter/evm_test.go @@ -25,12 +25,27 @@ type testParameters struct { evmClients map[uint64]DynamicPriceGetterClient tokens []common.Address expectedTokenPrices map[common.Address]big.Int + expectedTokenPricesForAll map[common.Address]big.Int evmCallErr bool invalidConfigErrorExpected bool priceResolutionErrorExpected bool } -func TestDynamicPriceGetter(t *testing.T) { +var ( + TK1 common.Address + TK2 common.Address + TK3 common.Address + TK4 common.Address +) + +func init() { + TK1 = utils.RandomAddress() + TK2 = utils.RandomAddress() + TK3 = utils.RandomAddress() + TK4 = utils.RandomAddress() +} + +func TestDynamicPriceGetterWithEmptyInput(t *testing.T) { tests := []struct { name string param testParameters @@ -63,6 +78,22 @@ func TestDynamicPriceGetter(t *testing.T) { name: "batchCall_returns_err", param: testParamBatchCallReturnsErr(t), }, + { + name: "less_inputs_than_defined_prices", + param: testLessInputsThanDefinedPrices(t), + }, + { + name: "get_all_tokens_aggregator_and_static", + param: testGetAllTokensAggregatorAndStatic(t), + }, + { + name: "get_all_tokens_aggregator_only", + param: testGetAllTokensAggregatorOnly(t), + }, + { + name: "get_all_tokens_static_only", + param: testGetAllTokensStaticOnly(), + }, } for _, test := range tests { @@ -74,34 +105,31 @@ func TestDynamicPriceGetter(t *testing.T) { } require.NoError(t, err) ctx := testutils.Context(t) - // Check configured token - unconfiguredTk := cciptypes.Address(utils.RandomAddress().String()) - cfgTokens, uncfgTokens, err := pg.FilterConfiguredTokens(ctx, []cciptypes.Address{unconfiguredTk}) - require.NoError(t, err) - assert.Equal(t, []cciptypes.Address{}, cfgTokens) - assert.Equal(t, []cciptypes.Address{unconfiguredTk}, uncfgTokens) - // Build list of tokens to query. - tokens := make([]cciptypes.Address, 0, len(test.param.tokens)) - for _, tk := range test.param.tokens { - tokenAddr := ccipcalc.EvmAddrToGeneric(tk) - tokens = append(tokens, tokenAddr) - } - prices, err := pg.TokenPricesUSD(ctx, tokens) - if test.param.evmCallErr { - require.Error(t, err) - return - } + var prices map[cciptypes.Address]*big.Int + var expectedTokens map[common.Address]big.Int + if len(test.param.expectedTokenPricesForAll) == 0 { + prices, err = pg.TokenPricesUSD(ctx, ccipcalc.EvmAddrsToGeneric(test.param.tokens...)) + if test.param.evmCallErr { + require.Error(t, err) + return + } - if test.param.priceResolutionErrorExpected { - require.Error(t, err) - return + if test.param.priceResolutionErrorExpected { + require.Error(t, err) + return + } + expectedTokens = test.param.expectedTokenPrices + } else { + prices, err = pg.GetJobSpecTokenPricesUSD(ctx) + expectedTokens = test.param.expectedTokenPricesForAll } + require.NoError(t, err) - // we expect prices for at least all queried tokens (it is possible that additional tokens are returned). - assert.True(t, len(prices) >= len(test.param.expectedTokenPrices)) + // Ensure all expected prices are present. + assert.True(t, len(prices) == len(expectedTokens)) // Check prices are matching expected result. - for tk, expectedPrice := range test.param.expectedTokenPrices { + for tk, expectedPrice := range expectedTokens { if prices[cciptypes.Address(tk.String())] == nil { assert.Fail(t, "Token price not found") } @@ -113,25 +141,21 @@ func TestDynamicPriceGetter(t *testing.T) { } func testParamAggregatorOnly(t *testing.T) testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() - tk4 := utils.RandomAddress() cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - tk1: { + TK1: { ChainID: 101, AggregatorContractAddress: utils.RandomAddress(), }, - tk2: { + TK2: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, - tk3: { + TK3: { ChainID: 103, AggregatorContractAddress: utils.RandomAddress(), }, - tk4: { + TK4: { ChainID: 104, AggregatorContractAddress: utils.RandomAddress(), }, @@ -177,15 +201,15 @@ func testParamAggregatorOnly(t *testing.T) testParameters { uint64(104): mockClient(t, []uint8{20}, []aggregator_v3_interface.LatestRoundData{round4}), } expectedTokenPrices := map[common.Address]big.Int{ - tk1: *multExp(round1.Answer, 10), // expected in 1e18 format. - tk2: *multExp(round2.Answer, 10), // expected in 1e18 format. - tk3: *round3.Answer, // already in 1e18 format (contract decimals==18). - tk4: *multExp(big.NewInt(1234567890), 8), // expected in 1e18 format. + TK1: *multExp(round1.Answer, 10), // expected in 1e18 format. + TK2: *multExp(round2.Answer, 10), // expected in 1e18 format. + TK3: *round3.Answer, // already in 1e18 format (contract decimals==18). + TK4: *multExp(big.NewInt(1234567890), 8), // expected in 1e18 format. } return testParameters{ cfg: cfg, evmClients: evmClients, - tokens: []common.Address{tk1, tk2, tk3, tk4}, + tokens: []common.Address{TK1, TK2, TK3, TK4}, expectedTokenPrices: expectedTokenPrices, invalidConfigErrorExpected: false, } @@ -193,20 +217,17 @@ func testParamAggregatorOnly(t *testing.T) testParameters { // testParamAggregatorOnlyMulti test with several tokens on chain 102. func testParamAggregatorOnlyMulti(t *testing.T) testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - tk1: { + TK1: { ChainID: 101, AggregatorContractAddress: utils.RandomAddress(), }, - tk2: { + TK2: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, - tk3: { + TK3: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, @@ -241,35 +262,32 @@ func testParamAggregatorOnlyMulti(t *testing.T) testParameters { uint64(102): mockClient(t, []uint8{8, 8}, []aggregator_v3_interface.LatestRoundData{round2, round3}), } expectedTokenPrices := map[common.Address]big.Int{ - tk1: *multExp(round1.Answer, 10), - tk2: *multExp(round2.Answer, 10), - tk3: *multExp(round3.Answer, 10), + TK1: *multExp(round1.Answer, 10), + TK2: *multExp(round2.Answer, 10), + TK3: *multExp(round3.Answer, 10), } return testParameters{ cfg: cfg, evmClients: evmClients, invalidConfigErrorExpected: false, - tokens: []common.Address{tk1, tk2, tk3}, + tokens: []common.Address{TK1, TK2, TK3}, expectedTokenPrices: expectedTokenPrices, } } func testParamStaticOnly() testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{}, StaticPrices: map[common.Address]config.StaticPriceConfig{ - tk1: { + TK1: { ChainID: 101, Price: big.NewInt(1_234_000), }, - tk2: { + TK2: { ChainID: 102, Price: big.NewInt(2_234_000), }, - tk3: { + TK3: { ChainID: 103, Price: big.NewInt(3_234_000), }, @@ -278,35 +296,86 @@ func testParamStaticOnly() testParameters { // Real LINK/USD example from OP. evmClients := map[uint64]DynamicPriceGetterClient{} expectedTokenPrices := map[common.Address]big.Int{ - tk1: *cfg.StaticPrices[tk1].Price, - tk2: *cfg.StaticPrices[tk2].Price, - tk3: *cfg.StaticPrices[tk3].Price, + TK1: *cfg.StaticPrices[TK1].Price, + TK2: *cfg.StaticPrices[TK2].Price, + TK3: *cfg.StaticPrices[TK3].Price, } return testParameters{ cfg: cfg, evmClients: evmClients, - tokens: []common.Address{tk1, tk2, tk3}, + tokens: []common.Address{TK1, TK2, TK3}, expectedTokenPrices: expectedTokenPrices, } } +func testParamNoAggregatorForToken(t *testing.T) testParameters { + cfg := config.DynamicPriceGetterConfig{ + AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ + TK1: { + ChainID: 101, + AggregatorContractAddress: utils.RandomAddress(), + }, + TK2: { + ChainID: 102, + AggregatorContractAddress: utils.RandomAddress(), + }, + }, + StaticPrices: map[common.Address]config.StaticPriceConfig{ + TK3: { + ChainID: 103, + Price: big.NewInt(1_234_000), + }, + }, + } + // Real LINK/USD example from OP. + round1 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(1000), + Answer: big.NewInt(1396818990), + StartedAt: big.NewInt(1704896575), + UpdatedAt: big.NewInt(1704896575), + AnsweredInRound: big.NewInt(1000), + } + // Real ETH/USD example from OP. + round2 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(2000), + Answer: big.NewInt(238879815123), + StartedAt: big.NewInt(1704897197), + UpdatedAt: big.NewInt(1704897197), + AnsweredInRound: big.NewInt(2000), + } + evmClients := map[uint64]DynamicPriceGetterClient{ + uint64(101): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round1}), + uint64(102): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round2}), + } + expectedTokenPrices := map[common.Address]big.Int{ + TK1: *round1.Answer, + TK2: *round2.Answer, + TK3: *cfg.StaticPrices[TK3].Price, + TK4: *big.NewInt(0), + } + return testParameters{ + cfg: cfg, + evmClients: evmClients, + tokens: []common.Address{TK1, TK2, TK3, TK4}, + expectedTokenPrices: expectedTokenPrices, + priceResolutionErrorExpected: true, + } +} + func testParamAggregatorAndStaticValid(t *testing.T) testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - tk1: { + TK1: { ChainID: 101, AggregatorContractAddress: utils.RandomAddress(), }, - tk2: { + TK2: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, }, StaticPrices: map[common.Address]config.StaticPriceConfig{ - tk3: { + TK3: { ChainID: 103, Price: big.NewInt(1_234_000), }, @@ -333,39 +402,36 @@ func testParamAggregatorAndStaticValid(t *testing.T) testParameters { uint64(102): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round2}), } expectedTokenPrices := map[common.Address]big.Int{ - tk1: *multExp(round1.Answer, 10), - tk2: *multExp(round2.Answer, 10), - tk3: *cfg.StaticPrices[tk3].Price, + TK1: *multExp(round1.Answer, 10), + TK2: *multExp(round2.Answer, 10), + TK3: *cfg.StaticPrices[TK3].Price, } return testParameters{ cfg: cfg, evmClients: evmClients, - tokens: []common.Address{tk1, tk2, tk3}, + tokens: []common.Address{TK1, TK2, TK3}, expectedTokenPrices: expectedTokenPrices, } } func testParamAggregatorAndStaticTokenCollision(t *testing.T) testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - tk1: { + TK1: { ChainID: 101, AggregatorContractAddress: utils.RandomAddress(), }, - tk2: { + TK2: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, - tk3: { + TK3: { ChainID: 103, AggregatorContractAddress: utils.RandomAddress(), }, }, StaticPrices: map[common.Address]config.StaticPriceConfig{ - tk3: { + TK3: { ChainID: 103, Price: big.NewInt(1_234_000), }, @@ -402,29 +468,25 @@ func testParamAggregatorAndStaticTokenCollision(t *testing.T) testParameters { return testParameters{ cfg: cfg, evmClients: evmClients, - tokens: []common.Address{tk1, tk2, tk3}, + tokens: []common.Address{TK1, TK2, TK3}, invalidConfigErrorExpected: true, } } -func testParamNoAggregatorForToken(t *testing.T) testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() - tk4 := utils.RandomAddress() +func testParamBatchCallReturnsErr(t *testing.T) testParameters { cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - tk1: { + TK1: { ChainID: 101, AggregatorContractAddress: utils.RandomAddress(), }, - tk2: { + TK2: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, }, StaticPrices: map[common.Address]config.StaticPriceConfig{ - tk3: { + TK3: { ChainID: 103, Price: big.NewInt(1_234_000), }, @@ -438,6 +500,51 @@ func testParamNoAggregatorForToken(t *testing.T) testParameters { UpdatedAt: big.NewInt(1704896575), AnsweredInRound: big.NewInt(1000), } + evmClients := map[uint64]DynamicPriceGetterClient{ + uint64(101): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round1}), + uint64(102): { + BatchCaller: mockErrCaller(t), + }, + } + return testParameters{ + cfg: cfg, + evmClients: evmClients, + tokens: []common.Address{TK1, TK2, TK3}, + evmCallErr: true, + } +} + +func testLessInputsThanDefinedPrices(t *testing.T) testParameters { + cfg := config.DynamicPriceGetterConfig{ + AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ + TK1: { + ChainID: 101, + AggregatorContractAddress: utils.RandomAddress(), + }, + TK2: { + ChainID: 102, + AggregatorContractAddress: utils.RandomAddress(), + }, + TK3: { + ChainID: 103, + AggregatorContractAddress: utils.RandomAddress(), + }, + }, + StaticPrices: map[common.Address]config.StaticPriceConfig{ + TK4: { + ChainID: 104, + Price: big.NewInt(1_234_000), + }, + }, + } + // Real LINK/USD example from OP. + round1 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(1000), + Answer: big.NewInt(3749350456), + StartedAt: big.NewInt(1704896575), + UpdatedAt: big.NewInt(1704896575), + AnsweredInRound: big.NewInt(1000), + } // Real ETH/USD example from OP. round2 := aggregator_v3_interface.LatestRoundData{ RoundId: big.NewInt(2000), @@ -446,43 +553,51 @@ func testParamNoAggregatorForToken(t *testing.T) testParameters { UpdatedAt: big.NewInt(1704897197), AnsweredInRound: big.NewInt(2000), } + // Real LINK/ETH example from OP. + round3 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(3000), + Answer: big.NewInt(4468862777874802), + StartedAt: big.NewInt(1715743907), + UpdatedAt: big.NewInt(1715743907), + AnsweredInRound: big.NewInt(3000), + } evmClients := map[uint64]DynamicPriceGetterClient{ uint64(101): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round1}), uint64(102): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round2}), + uint64(103): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round3}), } expectedTokenPrices := map[common.Address]big.Int{ - tk1: *round1.Answer, - tk2: *round2.Answer, - tk3: *cfg.StaticPrices[tk3].Price, - tk4: *big.NewInt(0), + TK1: *multExp(round1.Answer, 10), + TK2: *multExp(round2.Answer, 10), + TK3: *multExp(round3.Answer, 10), } return testParameters{ - cfg: cfg, - evmClients: evmClients, - tokens: []common.Address{tk1, tk2, tk3, tk4}, - expectedTokenPrices: expectedTokenPrices, - priceResolutionErrorExpected: true, + cfg: cfg, + evmClients: evmClients, + tokens: []common.Address{TK1, TK2, TK3}, + expectedTokenPrices: expectedTokenPrices, } } -func testParamBatchCallReturnsErr(t *testing.T) testParameters { - tk1 := utils.RandomAddress() - tk2 := utils.RandomAddress() - tk3 := utils.RandomAddress() +func testGetAllTokensAggregatorAndStatic(t *testing.T) testParameters { cfg := config.DynamicPriceGetterConfig{ AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ - tk1: { + TK1: { ChainID: 101, AggregatorContractAddress: utils.RandomAddress(), }, - tk2: { + TK2: { ChainID: 102, AggregatorContractAddress: utils.RandomAddress(), }, + TK3: { + ChainID: 103, + AggregatorContractAddress: utils.RandomAddress(), + }, }, StaticPrices: map[common.Address]config.StaticPriceConfig{ - tk3: { - ChainID: 103, + TK4: { + ChainID: 104, Price: big.NewInt(1_234_000), }, }, @@ -490,22 +605,133 @@ func testParamBatchCallReturnsErr(t *testing.T) testParameters { // Real LINK/USD example from OP. round1 := aggregator_v3_interface.LatestRoundData{ RoundId: big.NewInt(1000), - Answer: big.NewInt(1396818990), + Answer: big.NewInt(3749350456), StartedAt: big.NewInt(1704896575), UpdatedAt: big.NewInt(1704896575), AnsweredInRound: big.NewInt(1000), } + // Real ETH/USD example from OP. + round2 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(2000), + Answer: big.NewInt(238879815123), + StartedAt: big.NewInt(1704897197), + UpdatedAt: big.NewInt(1704897197), + AnsweredInRound: big.NewInt(2000), + } + // Real LINK/ETH example from OP. + round3 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(3000), + Answer: big.NewInt(4468862777874802), + StartedAt: big.NewInt(1715743907), + UpdatedAt: big.NewInt(1715743907), + AnsweredInRound: big.NewInt(3000), + } evmClients := map[uint64]DynamicPriceGetterClient{ uint64(101): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round1}), - uint64(102): { - BatchCaller: mockErrCaller(t), + uint64(102): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round2}), + uint64(103): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round3}), + } + expectedTokenPricesForAll := map[common.Address]big.Int{ + TK1: *multExp(round1.Answer, 10), + TK2: *multExp(round2.Answer, 10), + TK3: *multExp(round3.Answer, 10), + TK4: *cfg.StaticPrices[TK4].Price, + } + return testParameters{ + cfg: cfg, + evmClients: evmClients, + expectedTokenPricesForAll: expectedTokenPricesForAll, + } +} + +func testGetAllTokensAggregatorOnly(t *testing.T) testParameters { + cfg := config.DynamicPriceGetterConfig{ + AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{ + TK1: { + ChainID: 101, + AggregatorContractAddress: utils.RandomAddress(), + }, + TK2: { + ChainID: 102, + AggregatorContractAddress: utils.RandomAddress(), + }, + TK3: { + ChainID: 103, + AggregatorContractAddress: utils.RandomAddress(), + }, }, + StaticPrices: map[common.Address]config.StaticPriceConfig{}, + } + // Real LINK/USD example from OP. + round1 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(1000), + Answer: big.NewInt(3749350456), + StartedAt: big.NewInt(1704896575), + UpdatedAt: big.NewInt(1704896575), + AnsweredInRound: big.NewInt(1000), + } + // Real ETH/USD example from OP. + round2 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(2000), + Answer: big.NewInt(238879815123), + StartedAt: big.NewInt(1704897197), + UpdatedAt: big.NewInt(1704897197), + AnsweredInRound: big.NewInt(2000), + } + // Real LINK/ETH example from OP. + round3 := aggregator_v3_interface.LatestRoundData{ + RoundId: big.NewInt(3000), + Answer: big.NewInt(4468862777874802), + StartedAt: big.NewInt(1715743907), + UpdatedAt: big.NewInt(1715743907), + AnsweredInRound: big.NewInt(3000), + } + evmClients := map[uint64]DynamicPriceGetterClient{ + uint64(101): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round1}), + uint64(102): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round2}), + uint64(103): mockClient(t, []uint8{8}, []aggregator_v3_interface.LatestRoundData{round3}), + } + expectedTokenPricesForAll := map[common.Address]big.Int{ + TK1: *multExp(round1.Answer, 10), + TK2: *multExp(round2.Answer, 10), + TK3: *multExp(round3.Answer, 10), } return testParameters{ - cfg: cfg, - evmClients: evmClients, - tokens: []common.Address{tk1, tk2, tk3}, - evmCallErr: true, + cfg: cfg, + evmClients: evmClients, + expectedTokenPricesForAll: expectedTokenPricesForAll, + } +} + +func testGetAllTokensStaticOnly() testParameters { + cfg := config.DynamicPriceGetterConfig{ + AggregatorPrices: map[common.Address]config.AggregatorPriceConfig{}, + StaticPrices: map[common.Address]config.StaticPriceConfig{ + TK1: { + ChainID: 101, + Price: big.NewInt(1_234_000), + }, + TK2: { + ChainID: 102, + Price: big.NewInt(2_234_000), + }, + TK3: { + ChainID: 103, + Price: big.NewInt(3_234_000), + }, + }, + } + + evmClients := map[uint64]DynamicPriceGetterClient{} + expectedTokenPricesForAll := map[common.Address]big.Int{ + TK1: *cfg.StaticPrices[TK1].Price, + TK2: *cfg.StaticPrices[TK2].Price, + TK3: *cfg.StaticPrices[TK3].Price, + } + return testParameters{ + cfg: cfg, + evmClients: evmClients, + expectedTokenPricesForAll: expectedTokenPricesForAll, } } diff --git a/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline.go b/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline.go index ae9a10deb65..34977eda9f1 100644 --- a/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline.go +++ b/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline.go @@ -11,7 +11,6 @@ import ( "github.com/pkg/errors" cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccip" - "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipcalc" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/parseutil" @@ -60,28 +59,30 @@ func (d *PipelineGetter) FilterConfiguredTokens(ctx context.Context, tokens []cc return configured, unconfigured, nil } -func (d *PipelineGetter) TokenPricesUSD(ctx context.Context, tokens []cciptypes.Address) (map[cciptypes.Address]*big.Int, error) { - _, trrs, err := d.runner.ExecuteRun(ctx, pipeline.Spec{ - ID: d.jobID, - DotDagSource: d.source, - CreatedAt: time.Now(), - JobID: d.jobID, - JobName: d.name, - JobType: "", - }, pipeline.NewVarsFrom(map[string]interface{}{})) +func (d *PipelineGetter) GetJobSpecTokenPricesUSD(ctx context.Context) (map[cciptypes.Address]*big.Int, error) { + prices, err := d.getPricesFromRunner(ctx) if err != nil { return nil, err } - finalResult := trrs.FinalResult() - if finalResult.HasErrors() { - return nil, errors.Errorf("error getting prices %v", finalResult.AllErrors) - } - if len(finalResult.Values) != 1 { - return nil, errors.Errorf("invalid number of price results, expected 1 got %v", len(finalResult.Values)) + + tokenPrices := make(map[cciptypes.Address]*big.Int) + for tokenAddressStr, rawPrice := range prices { + tokenAddressStr := ccipcalc.HexToAddress(tokenAddressStr) + castedPrice, err := parseutil.ParseBigIntFromAny(rawPrice) + if err != nil { + return nil, err + } + + tokenPrices[tokenAddressStr] = castedPrice } - prices, ok := finalResult.Values[0].(map[string]interface{}) - if !ok { - return nil, errors.Errorf("expected map output of price pipeline, got %T", finalResult.Values[0]) + + return tokenPrices, nil +} + +func (d *PipelineGetter) TokenPricesUSD(ctx context.Context, tokens []cciptypes.Address) (map[cciptypes.Address]*big.Int, error) { + prices, err := d.getPricesFromRunner(ctx) + if err != nil { + return nil, err } providedTokensSet := mapset.NewSet(tokens...) @@ -101,7 +102,7 @@ func (d *PipelineGetter) TokenPricesUSD(ctx context.Context, tokens []cciptypes. // The mapping of token address to source of token price has to live offchain. // Best we can do is sanity check that the token price spec covers all our desired execution token prices. for _, token := range tokens { - if _, ok = tokenPrices[token]; !ok { + if _, ok := tokenPrices[token]; !ok { return nil, errors.Errorf("missing token %s from tokensForFeeCoin spec, got %v", token, prices) } } @@ -109,6 +110,33 @@ func (d *PipelineGetter) TokenPricesUSD(ctx context.Context, tokens []cciptypes. return tokenPrices, nil } +func (d *PipelineGetter) getPricesFromRunner(ctx context.Context) (map[string]interface{}, error) { + _, trrs, err := d.runner.ExecuteRun(ctx, pipeline.Spec{ + ID: d.jobID, + DotDagSource: d.source, + CreatedAt: time.Now(), + JobID: d.jobID, + JobName: d.name, + JobType: "", + }, pipeline.NewVarsFrom(map[string]interface{}{})) + if err != nil { + return nil, err + } + finalResult := trrs.FinalResult() + if finalResult.HasErrors() { + return nil, errors.Errorf("error getting prices %v", finalResult.AllErrors) + } + if len(finalResult.Values) != 1 { + return nil, errors.Errorf("invalid number of price results, expected 1 got %v", len(finalResult.Values)) + } + prices, ok := finalResult.Values[0].(map[string]interface{}) + if !ok { + return nil, errors.Errorf("expected map output of price pipeline, got %T", finalResult.Values[0]) + } + + return prices, nil +} + func (d *PipelineGetter) Close() error { return d.runner.Close() } diff --git a/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline_test.go b/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline_test.go index 37970750732..8aeeff96b57 100644 --- a/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline_test.go +++ b/core/services/ocr2/plugins/ccip/internal/pricegetter/pipeline_test.go @@ -57,19 +57,21 @@ func TestDataSource(t *testing.T) { priceGetter := newTestPipelineGetter(t, source) - // USDC & LINK are configured - confTokens, _, err := priceGetter.FilterConfiguredTokens(context.Background(), []cciptypes.Address{linkTokenAddress, usdcTokenAddress}) + // Ask for all prices present in spec. + prices, err := priceGetter.GetJobSpecTokenPricesUSD(context.Background()) require.NoError(t, err) - assert.Equal(t, linkTokenAddress, confTokens[0]) - assert.Equal(t, usdcTokenAddress, confTokens[1]) + assert.Equal(t, prices, map[cciptypes.Address]*big.Int{ + linkTokenAddress: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1000000000000000000)), + usdcTokenAddress: big.NewInt(0).Mul(big.NewInt(1000), big.NewInt(1000000000000000000)), + }) - // Ask for all prices present in spec. - prices, err := priceGetter.TokenPricesUSD(context.Background(), []cciptypes.Address{ + // Specifically ask for all prices + pricesWithInput, errWithInput := priceGetter.TokenPricesUSD(context.Background(), []cciptypes.Address{ linkTokenAddress, usdcTokenAddress, }) - require.NoError(t, err) - assert.Equal(t, prices, map[cciptypes.Address]*big.Int{ + require.NoError(t, errWithInput) + assert.Equal(t, pricesWithInput, map[cciptypes.Address]*big.Int{ linkTokenAddress: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1000000000000000000)), usdcTokenAddress: big.NewInt(0).Mul(big.NewInt(1000), big.NewInt(1000000000000000000)), }) diff --git a/core/services/ocr2/plugins/ccip/internal/pricegetter/pricegetter.go b/core/services/ocr2/plugins/ccip/internal/pricegetter/pricegetter.go index 9ee0e8f3d0a..d4bcfc57b6e 100644 --- a/core/services/ocr2/plugins/ccip/internal/pricegetter/pricegetter.go +++ b/core/services/ocr2/plugins/ccip/internal/pricegetter/pricegetter.go @@ -1,7 +1,18 @@ package pricegetter -import cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccip" +import ( + "context" + "math/big" + + cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccip" +) type PriceGetter interface { cciptypes.PriceGetter } + +type AllTokensPriceGetter interface { + PriceGetter + // GetJobSpecTokenPricesUSD returns all token prices defined in the jobspec. + GetJobSpecTokenPricesUSD(ctx context.Context) (map[cciptypes.Address]*big.Int, error) +} diff --git a/core/services/ocr2/plugins/ccip/testhelpers/integration/chainlink.go b/core/services/ocr2/plugins/ccip/testhelpers/integration/chainlink.go index 177ccf323b7..fe9021e4c14 100644 --- a/core/services/ocr2/plugins/ccip/testhelpers/integration/chainlink.go +++ b/core/services/ocr2/plugins/ccip/testhelpers/integration/chainlink.go @@ -7,6 +7,7 @@ import ( "math/big" "net/http" "net/http/httptest" + "slices" "strconv" "strings" "testing" @@ -49,6 +50,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_offramp" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_onramp" "github.com/smartcontractkit/chainlink/v2/core/internal/cltest/heavyweight" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/price_registry_1_2_0" "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/logger/audit" @@ -762,6 +764,47 @@ func (c *CCIPIntegrationTestHarness) NoNodesHaveExecutedSeqNum(t *testing.T, seq return log } +func (c *CCIPIntegrationTestHarness) EventuallyPriceRegistryUpdated(t *testing.T, block uint64, srcSelector uint64, tokens []common.Address, sourceNative common.Address, priceRegistryOpts ...common.Address) { + var priceRegistry *price_registry_1_2_0.PriceRegistry + var err error + if len(priceRegistryOpts) > 0 { + priceRegistry, err = price_registry_1_2_0.NewPriceRegistry(priceRegistryOpts[0], c.Dest.Chain) + require.NoError(t, err) + } else { + require.NotNil(t, c.Dest.PriceRegistry, "no priceRegistry configured") + priceRegistry = c.Dest.PriceRegistry + } + + g := gomega.NewGomegaWithT(t) + g.Eventually(func() bool { + it, err := priceRegistry.FilterUsdPerTokenUpdated(&bind.FilterOpts{Start: block}, tokens) + g.Expect(err).NotTo(gomega.HaveOccurred(), "Error filtering UsdPerTokenUpdated event") + + tokensFetched := make([]common.Address, 0, len(tokens)) + for it.Next() { + tokenFetched := it.Event.Token + tokensFetched = append(tokensFetched, tokenFetched) + t.Log("Token price updated", tokenFetched.String(), it.Event.Value.String(), it.Event.Timestamp.String()) + } + + for _, token := range tokens { + if !slices.Contains(tokensFetched, token) { + return false + } + } + + return true + }, testutils.WaitTimeout(t), 10*time.Second).Should(gomega.BeTrue(), "Tokens prices has not been updated") + + g.Eventually(func() bool { + it, err := priceRegistry.FilterUsdPerUnitGasUpdated(&bind.FilterOpts{Start: block}, []uint64{srcSelector}) + g.Expect(err).NotTo(gomega.HaveOccurred(), "Error filtering UsdPerUnitGasUpdated event") + g.Expect(it.Next()).To(gomega.BeTrue(), "No UsdPerUnitGasUpdated event found") + + return true + }, testutils.WaitTimeout(t), 10*time.Second).Should(gomega.BeTrue(), "source gas price has not been updated") +} + func (c *CCIPIntegrationTestHarness) EventuallyCommitReportAccepted(t *testing.T, currentBlock uint64, commitStoreOpts ...common.Address) commit_store.CommitStoreCommitReport { var commitStore *commit_store.CommitStore var err error