From 1b2d9ea02c355627b2008d8a307767b1a2ee45c1 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Soliman (Boda)" <2677789+asoliman92@users.noreply.github.com> Date: Tue, 20 Aug 2024 18:24:24 +0400 Subject: [PATCH] [CCIP-2958] Token price reader implementation (#67) * WIP new token price reader * Bind first token aggregator to price reader * Moving price reader binding to chainlink inprocess.go and removing from commit factory * Calculate USD price per 1e18 of smallest token denomination with 18 decimal precision * Fix call to GetLatestValue Signed-off-by: asoliman * Add decimals to offchain config Signed-off-by: asoliman * Use TokenDecimals in on chain reader Signed-off-by: asoliman * Normalize raw token prices Signed-off-by: asoliman * Validate all tokens has decimals in offchain config Signed-off-by: asoliman * Add comments Signed-off-by: asoliman * Add new on chain prices reader to commitocb factory Signed-off-by: asoliman * Validate ArbitrumPriceSource in the offchain config Signed-off-by: asoliman * Add comments Signed-off-by: asoliman * Fix tests - failing because of race condition Signed-off-by: asoliman * Make the test work with one token as expected Signed-off-by: asoliman * Update pluginconfig/commit.go Co-authored-by: Makram * Update pluginconfig/commit.go Co-authored-by: Makram * review comments Signed-off-by: asoliman --------- Signed-off-by: asoliman Co-authored-by: Makram --- commit/factory.go | 19 ++- commit/plugin.go | 5 +- commit/plugin_functions.go | 4 +- commit/plugin_functions_test.go | 2 +- commitrmnocb/factory.go | 19 ++- go.mod | 19 +-- go.sum | 40 ++--- internal/reader/onchain_prices_reader.go | 135 +++++++++++---- internal/reader/onchain_prices_reader_test.go | 157 ++++++++++++------ pkg/consts/consts.go | 5 + pluginconfig/commit.go | 21 +++ pluginconfig/commit_test.go | 6 + 12 files changed, 301 insertions(+), 131 deletions(-) diff --git a/commit/factory.go b/commit/factory.go index 2967320e8..197497412 100644 --- a/commit/factory.go +++ b/commit/factory.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math/big" "google.golang.org/grpc" @@ -15,7 +14,6 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/types/core" "github.com/smartcontractkit/libocr/commontypes" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" - ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" ragep2ptypes "github.com/smartcontractkit/libocr/ragep2p/types" "github.com/smartcontractkit/chainlink-ccip/internal/reader" @@ -94,12 +92,17 @@ func (p *PluginFactory) NewReportingPlugin(config ocr3types.ReportingPluginConfi oracleIDToP2PID[commontypes.OracleID(oracleID)] = p2pID } - onChainTokenPricesReader := reader.NewOnchainTokenPricesReader( - reader.TokenPriceConfig{ // TODO: Inject config - StaticPrices: map[ocr2types.Account]big.Int{}, - }, - nil, // TODO: Inject this - ) + var onChainTokenPricesReader reader.TokenPrices + // The node supports the chain that the token prices are on. + tokenPricesCr, ok := p.contractReaders[cciptypes.ChainSelector(offchainConfig.TokenPriceChainSelector)] + if ok { + onChainTokenPricesReader = reader.NewOnchainTokenPricesReader( + tokenPricesCr, + offchainConfig.PriceSources, + offchainConfig.TokenDecimals, + ) + } + ccipReader := reader.NewCCIPChainReader( p.lggr, p.contractReaders, diff --git a/commit/plugin.go b/commit/plugin.go index 76f58ffb2..3d29087e4 100644 --- a/commit/plugin.go +++ b/commit/plugin.go @@ -127,7 +127,8 @@ func (p *Plugin) Observation( // observe token prices if the node supports the token price chain // otherwise move on to gas prices. var tokenPrices []cciptypes.TokenPrice - if supportTPChain, err := p.supportsTokenPriceChain(); err == nil && supportTPChain { + if supportTPChain, err := p.supportsTokenPriceChain(); err == nil && supportTPChain && p.tokenPricesReader != nil { + p.lggr.Infow("observing token prices") tokenPrices, err = observeTokenPrices( ctx, p.tokenPricesReader, @@ -276,7 +277,7 @@ func (p *Plugin) Outcome( } p.lggr.Infow("new messages consensus", "merkleRoots", merkleRoots) - tokenPrices := tokenPricesConsensus(decodedObservations, fChainDest) + tokenPrices := tokenPricesMedianized(decodedObservations, fChainDest) gasPrices := gasPricesConsensus(p.lggr, decodedObservations, fChainDest) p.lggr.Infow("gas prices consensus", "gasPrices", gasPrices) diff --git a/commit/plugin_functions.go b/commit/plugin_functions.go index 650ce36e9..e8f93ca79 100644 --- a/commit/plugin_functions.go +++ b/commit/plugin_functions.go @@ -388,8 +388,8 @@ func maxSeqNumsConsensus( return seqNums } -// tokenPricesConsensus returns the median price for tokens that have at least 2f_chain+1 observations. -func tokenPricesConsensus(observations []plugintypes.CommitPluginObservation, fChain int) []cciptypes.TokenPrice { +// tokenPricesMedianized returns the median price for tokens that have at least 2f_chain+1 observations. +func tokenPricesMedianized(observations []plugintypes.CommitPluginObservation, fChain int) []cciptypes.TokenPrice { pricesPerToken := make(map[types.Account][]cciptypes.BigInt) for _, obs := range observations { for _, price := range obs.TokenPrices { diff --git a/commit/plugin_functions_test.go b/commit/plugin_functions_test.go index 87e5c6b69..576eb3da7 100644 --- a/commit/plugin_functions_test.go +++ b/commit/plugin_functions_test.go @@ -1404,7 +1404,7 @@ func Test_tokenPricesConsensus(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - prices := tokenPricesConsensus(tc.observations, tc.fChain) + prices := tokenPricesMedianized(tc.observations, tc.fChain) assert.Equal(t, tc.expPrices, prices) }) } diff --git a/commitrmnocb/factory.go b/commitrmnocb/factory.go index 6fe4996b2..05927af1a 100644 --- a/commitrmnocb/factory.go +++ b/commitrmnocb/factory.go @@ -4,13 +4,11 @@ import ( "context" "errors" "fmt" - "math/big" "google.golang.org/grpc" "github.com/smartcontractkit/libocr/commontypes" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" - ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" ragep2ptypes "github.com/smartcontractkit/libocr/ragep2p/types" "github.com/smartcontractkit/chainlink-common/pkg/logger" @@ -98,12 +96,17 @@ func (p *PluginFactory) NewReportingPlugin(config ocr3types.ReportingPluginConfi oracleIDToP2PID[commontypes.OracleID(oracleID)] = p2pID } - onChainTokenPricesReader := reader.NewOnchainTokenPricesReader( - reader.TokenPriceConfig{ // TODO: Inject config - StaticPrices: map[ocr2types.Account]big.Int{}, - }, - nil, // TODO: Inject this - ) + var onChainTokenPricesReader reader.TokenPrices + // The node supports the chain that the token prices are on. + tokenPricesCr, ok := p.contractReaders[cciptypes.ChainSelector(offchainConfig.TokenPriceChainSelector)] + if ok { + onChainTokenPricesReader = reader.NewOnchainTokenPricesReader( + tokenPricesCr, + offchainConfig.PriceSources, + offchainConfig.TokenDecimals, + ) + } + ccipReader := reader.NewCCIPChainReader( p.lggr, p.contractReaders, diff --git a/go.mod b/go.mod index ecf4e9170..d4c9afa18 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.22.5 require ( github.com/deckarep/golang-set/v2 v2.6.0 github.com/smartcontractkit/chainlink-common v0.1.7-0.20240712162033-89bd3351ce6e - github.com/smartcontractkit/libocr v0.0.0-20240419185742-fd3cab206b2c + github.com/smartcontractkit/libocr v0.0.0-20240717100443-f6226e09bee7 github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.24.0 + golang.org/x/crypto v0.25.0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/sync v0.7.0 google.golang.org/grpc v1.64.1 @@ -20,28 +20,27 @@ require ( github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.17.0 // indirect - github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect - github.com/prometheus/common v0.44.0 // indirect - github.com/prometheus/procfs v0.11.1 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/go.sum b/go.sum index dc2ce0055..07ffbc2bd 100644 --- a/go.sum +++ b/go.sum @@ -12,9 +12,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -28,8 +25,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/nolag/mapstructure v1.5.2-0.20240625151721-90ea83a3f479 h1:1jCGDLFXDOHF2sdeTJYKrIuSLGMpQZpgXXHNGXR5Ouk= @@ -43,22 +40,22 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= -github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 h1:WCcC4vZDS1tYNxjWlwRJZQy28r8CMoggKnxNzxsVDMQ= -github.com/santhosh-tekuri/jsonschema/v5 v5.2.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/smartcontractkit/chainlink-common v0.1.7-0.20240712162033-89bd3351ce6e h1:vKVNJfFXy4Wdq5paOV0/fNgql2GoXkei10+D+SmC+Qs= github.com/smartcontractkit/chainlink-common v0.1.7-0.20240712162033-89bd3351ce6e/go.mod h1:fh9eBbrReCmv31bfz52ENCAMa7nTKQbdhb2B3+S2VGo= -github.com/smartcontractkit/libocr v0.0.0-20240419185742-fd3cab206b2c h1:lIyMbTaF2H0Q71vkwZHX/Ew4KF2BxiKhqEXwF8rn+KI= -github.com/smartcontractkit/libocr v0.0.0-20240419185742-fd3cab206b2c/go.mod h1:fb1ZDVXACvu4frX3APHZaEBp0xi1DIm34DcA0CwTsZM= +github.com/smartcontractkit/libocr v0.0.0-20240717100443-f6226e09bee7 h1:e38V5FYE7DA1JfKXeD5Buo/7lczALuVXlJ8YNTAUxcw= +github.com/smartcontractkit/libocr v0.0.0-20240717100443-f6226e09bee7/go.mod h1:fb1ZDVXACvu4frX3APHZaEBp0xi1DIm34DcA0CwTsZM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -77,17 +74,16 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= diff --git a/internal/reader/onchain_prices_reader.go b/internal/reader/onchain_prices_reader.go index 197537d20..6545d0c91 100644 --- a/internal/reader/onchain_prices_reader.go +++ b/internal/reader/onchain_prices_reader.go @@ -5,11 +5,16 @@ import ( "fmt" "math/big" + "github.com/smartcontractkit/chainlink-ccip/pkg/consts" + "github.com/smartcontractkit/chainlink-ccip/pluginconfig" + + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - "golang.org/x/sync/errgroup" - commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + commontyps "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/types/query/primitives" + + "golang.org/x/sync/errgroup" ) type TokenPrices interface { @@ -18,55 +23,62 @@ type TokenPrices interface { GetTokenPricesUSD(ctx context.Context, tokens []ocr2types.Account) ([]*big.Int, error) } -type TokenPriceConfig struct { - // This is mainly used for inputTokens on testnet to give them a price - StaticPrices map[ocr2types.Account]big.Int `json:"staticPrices"` -} - type OnchainTokenPricesReader struct { - TokenPriceConfig TokenPriceConfig // Reader for the chain that will have the token prices on-chain - ContractReader commontypes.ContractReader + ContractReader commontyps.ContractReader + PriceSources map[types.Account]pluginconfig.ArbitrumPriceSource + TokenDecimals map[types.Account]uint8 } func NewOnchainTokenPricesReader( - tokenPriceConfig TokenPriceConfig, contractReader commontypes.ContractReader, + contractReader commontyps.ContractReader, + priceSources map[types.Account]pluginconfig.ArbitrumPriceSource, + tokenDecimals map[types.Account]uint8, ) *OnchainTokenPricesReader { return &OnchainTokenPricesReader{ - TokenPriceConfig: tokenPriceConfig, - ContractReader: contractReader, + ContractReader: contractReader, + PriceSources: priceSources, + TokenDecimals: tokenDecimals, } } +// LatestRoundData is what AggregatorV3Interface returns for price feed +// https://github.com/smartcontractkit/ccip/blob/8f3486ced41a414f724e6b12b1528db80b72346c/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol#L19 +// +//nolint:lll +type LatestRoundData struct { + RoundID *big.Int + Answer *big.Int + StartedAt *big.Int + UpdatedAt *big.Int + AnsweredInRound *big.Int +} + func (pr *OnchainTokenPricesReader) GetTokenPricesUSD( ctx context.Context, tokens []ocr2types.Account, ) ([]*big.Int, error) { - const ( - contractName = "PriceAggregator" - functionName = "getTokenPrice" - ) prices := make([]*big.Int, len(tokens)) eg := new(errgroup.Group) for idx, token := range tokens { idx := idx token := token eg.Go(func() error { - price := new(big.Int) - if staticPrice, exists := pr.TokenPriceConfig.StaticPrices[token]; exists { - price.Set(&staticPrice) - } else { - if err := - pr.ContractReader.GetLatestValue( - ctx, - contractName, - functionName, - primitives.Finalized, - token, - price); err != nil { - return fmt.Errorf("failed to get token price for %s: %w", token, err) - } + //TODO: Once chainreader new changes https://github.com/smartcontractkit/chainlink-common/pull/603 + // are merged we'll need to use the bound contract + //boundContract := commontypes.BoundContract{ + // Address: pr.PriceSources[token].AggregatorAddress, + // Name: consts.ContractNamePriceAggregator, + //} + rawTokenPrice, err := pr.getRawTokenPriceE18Normalized(ctx, token) + if err != nil { + return fmt.Errorf("failed to get token price for %s: %w", token, err) + } + decimals, ok := pr.TokenDecimals[token] + if !ok { + return fmt.Errorf("failed to get decimals for %s: %w", token, err) } - prices[idx] = price + + prices[idx] = calculateUsdPer1e18TokenAmount(rawTokenPrice, decimals) return nil }) } @@ -84,5 +96,66 @@ func (pr *OnchainTokenPricesReader) GetTokenPricesUSD( return prices, nil } +func (pr *OnchainTokenPricesReader) getFeedDecimals(ctx context.Context, token types.Account) (uint8, error) { + var decimals uint8 + if err := + pr.ContractReader.GetLatestValue( + ctx, + consts.ContractNamePriceAggregator, + consts.MethodNameGetDecimals, + primitives.Unconfirmed, + nil, + &decimals, + //boundContract, + ); err != nil { + return 0, fmt.Errorf("decimals call failed for token %s: %w", token, err) + } + + return decimals, nil +} + +func (pr *OnchainTokenPricesReader) getRawTokenPriceE18Normalized( + ctx context.Context, + token types.Account, +) (*big.Int, error) { + var latestRoundData LatestRoundData + if err := + pr.ContractReader.GetLatestValue( + ctx, + consts.ContractNamePriceAggregator, + consts.MethodNameGetLatestRoundData, + primitives.Unconfirmed, + nil, + &latestRoundData, + //boundContract, + ); err != nil { + return nil, fmt.Errorf("latestRoundData call failed for token %s: %w", token, err) + } + + decimals, err1 := pr.getFeedDecimals(ctx, token) + if err1 != nil { + return nil, fmt.Errorf("failed to get decimals for token %s: %w", token, err1) + } + answer := latestRoundData.Answer + if decimals < 18 { + answer.Mul(answer, big.NewInt(0).Exp(big.NewInt(10), big.NewInt(18-int64(decimals)), nil)) + } else if decimals > 18 { + answer.Div(answer, big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(decimals)-18), nil)) + } + return answer, nil +} + +// Input price is USD per full token, with 18 decimal precision +// Result price is USD per 1e18 of smallest token denomination, with 18 decimal precision +// Examples: +// +// 1 USDC = 1.00 USD per full token, each full token is 1e6 units -> 1 * 1e18 * 1e18 / 1e6 = 1e30 +// 1 ETH = 2,000 USD per full token, each full token is 1e18 units -> 2000 * 1e18 * 1e18 / 1e18 = 2_000e18 +// 1 LINK = 5.00 USD per full token, each full token is 1e18 units -> 5 * 1e18 * 1e18 / 1e18 = 5e18 +func calculateUsdPer1e18TokenAmount(price *big.Int, decimals uint8) *big.Int { + tmp := big.NewInt(0).Mul(price, big.NewInt(1e18)) + return tmp.Div(tmp, big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)) +} + // Ensure OnchainTokenPricesReader implements TokenPrices var _ TokenPrices = (*OnchainTokenPricesReader)(nil) diff --git a/internal/reader/onchain_prices_reader_test.go b/internal/reader/onchain_prices_reader_test.go index 40e1810cc..ab0d799a4 100644 --- a/internal/reader/onchain_prices_reader_test.go +++ b/internal/reader/onchain_prices_reader_test.go @@ -7,62 +7,61 @@ import ( "testing" "github.com/smartcontractkit/chainlink-ccip/internal/mocks" + "github.com/smartcontractkit/chainlink-ccip/pkg/consts" + "github.com/smartcontractkit/chainlink-ccip/pluginconfig" ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) const ( - EthAcc = ocr2types.Account("ETH") - OpAcc = ocr2types.Account("OP") - ArbAcc = ocr2types.Account("ARB") + EthAddr = ocr2types.Account("0x2e03388D351BF87CF2409EFf18C45Df59775Fbb2") + OpAddr = ocr2types.Account("0x3e03388D351BF87CF2409EFf18C45Df59775Fbb2") + ArbAddr = ocr2types.Account("0x4e03388D351BF87CF2409EFf18C45Df59775Fbb2") ) var ( - EthPrice = big.NewInt(100) - OpPrice = big.NewInt(10) - ArbPrice = big.NewInt(1) + EthPrice = big.NewInt(1).Mul(big.NewInt(7), big.NewInt(1e18)) + OpPrice = big.NewInt(1).Mul(big.NewInt(6), big.NewInt(1e18)) + ArbPrice = big.NewInt(1).Mul(big.NewInt(5), big.NewInt(1e18)) + OnlyPrice = big.NewInt(1).Mul(big.NewInt(5), big.NewInt(1e18)) + Decimals18 = uint8(18) ) func TestOnchainTokenPricesReader_GetTokenPricesUSD(t *testing.T) { testCases := []struct { name string - staticPrices map[ocr2types.Account]big.Int inputTokens []ocr2types.Account - mockPrices map[ocr2types.Account]*big.Int + priceSources map[ocr2types.Account]pluginconfig.ArbitrumPriceSource + tokenDecimals map[ocr2types.Account]uint8 + mockPrices []*big.Int want []*big.Int errorAccounts []ocr2types.Account wantErr bool }{ { - name: "Static price only", - staticPrices: map[ocr2types.Account]big.Int{EthAcc: *EthPrice, OpAcc: *OpPrice}, - inputTokens: []ocr2types.Account{EthAcc, OpAcc}, - mockPrices: map[ocr2types.Account]*big.Int{}, - want: []*big.Int{EthPrice, OpPrice}, - }, - { - name: "On-chain price only", - staticPrices: map[ocr2types.Account]big.Int{}, - inputTokens: []ocr2types.Account{ArbAcc, OpAcc, EthAcc}, - mockPrices: map[ocr2types.Account]*big.Int{OpAcc: OpPrice, ArbAcc: ArbPrice, EthAcc: EthPrice}, - want: []*big.Int{ArbPrice, OpPrice, EthPrice}, - }, - { - name: "Mix of static price and onchain price", - staticPrices: map[ocr2types.Account]big.Int{EthAcc: *EthPrice}, - inputTokens: []ocr2types.Account{EthAcc, OpAcc, ArbAcc}, - mockPrices: map[ocr2types.Account]*big.Int{ArbAcc: ArbPrice, OpAcc: OpPrice}, - want: []*big.Int{EthPrice, OpPrice, ArbPrice}, + name: "On-chain one price", + // No need to put sources as we're mocking the reader + priceSources: map[ocr2types.Account]pluginconfig.ArbitrumPriceSource{}, + tokenDecimals: map[ocr2types.Account]uint8{ + ArbAddr: Decimals18, + OpAddr: Decimals18, + EthAddr: Decimals18, + }, + inputTokens: []ocr2types.Account{ArbAddr}, + //TODO: change once we have control to return different prices in mock depending on the token + mockPrices: []*big.Int{ArbPrice}, + want: []*big.Int{ArbPrice}, }, { name: "Missing price should error", - staticPrices: map[ocr2types.Account]big.Int{}, - inputTokens: []ocr2types.Account{ArbAcc, OpAcc, EthAcc}, - mockPrices: map[ocr2types.Account]*big.Int{OpAcc: OpPrice, ArbAcc: ArbPrice}, - errorAccounts: []ocr2types.Account{EthAcc}, + priceSources: map[ocr2types.Account]pluginconfig.ArbitrumPriceSource{}, + inputTokens: []ocr2types.Account{ArbAddr}, + mockPrices: []*big.Int{}, + errorAccounts: []ocr2types.Account{EthAddr}, want: nil, wantErr: true, }, @@ -71,8 +70,9 @@ func TestOnchainTokenPricesReader_GetTokenPricesUSD(t *testing.T) { for _, tc := range testCases { contractReader := createMockReader(tc.mockPrices, tc.errorAccounts) tokenPricesReader := OnchainTokenPricesReader{ - TokenPriceConfig: TokenPriceConfig{StaticPrices: tc.staticPrices}, - ContractReader: contractReader, + ContractReader: contractReader, + PriceSources: tc.priceSources, + TokenDecimals: tc.tokenDecimals, } t.Run(tc.name, func(t *testing.T) { ctx := context.Background() @@ -82,7 +82,6 @@ func TestOnchainTokenPricesReader_GetTokenPricesUSD(t *testing.T) { require.Error(t, err) return } - require.NoError(t, err) require.Equal(t, tc.want, result) }) @@ -90,24 +89,88 @@ func TestOnchainTokenPricesReader_GetTokenPricesUSD(t *testing.T) { } +func TestPriceService_calculateUsdPer1e18TokenAmount(t *testing.T) { + testCases := []struct { + name string + price *big.Int + decimal uint8 + wantResult *big.Int + }{ + { + name: "18-decimal token, $6.5 per token", + price: big.NewInt(65e17), + decimal: 18, + wantResult: big.NewInt(65e17), + }, + { + name: "6-decimal token, $1 per token", + price: big.NewInt(1e18), + decimal: 6, + wantResult: new(big.Int).Mul(big.NewInt(1e18), big.NewInt(1e12)), // 1e30 + }, + { + name: "0-decimal token, $1 per token", + price: big.NewInt(1e18), + decimal: 0, + wantResult: new(big.Int).Mul(big.NewInt(1e18), big.NewInt(1e18)), // 1e36 + }, + { + name: "36-decimal token, $1 per token", + price: big.NewInt(1e18), + decimal: 36, + wantResult: big.NewInt(1), + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := calculateUsdPer1e18TokenAmount(tt.price, tt.decimal) + assert.Equal(t, tt.wantResult, got) + }) + } +} + +// nolint unparam func createMockReader( - mockPrices map[ocr2types.Account]*big.Int, errorAccounts []ocr2types.Account, + mockPrices []*big.Int, + errorAccounts []ocr2types.Account, ) *mocks.ContractReaderMock { reader := mocks.NewContractReaderMock() - for _, acc := range errorAccounts { - acc := acc - reader.On( - "GetLatestValue", mock.Anything, "PriceAggregator", "getTokenPrice", mock.Anything, acc, mock.Anything, - ).Return(fmt.Errorf("error")) - } - for acc, price := range mockPrices { - acc := acc + // TODO: Create a list of bound contracts from priceSources and return the price given in mockPrices + reader.On("GetLatestValue", + mock.Anything, + consts.ContractNamePriceAggregator, + consts.MethodNameGetDecimals, + mock.Anything, + nil, + mock.Anything).Run( + func(args mock.Arguments) { + arg := args.Get(5).(*uint8) + *arg = Decimals18 + }).Return(nil) + + for _, price := range mockPrices { price := price - reader.On("GetLatestValue", mock.Anything, "PriceAggregator", "getTokenPrice", mock.Anything, acc, mock.Anything).Run( + reader.On("GetLatestValue", + mock.Anything, + consts.ContractNamePriceAggregator, + consts.MethodNameGetLatestRoundData, + mock.Anything, + nil, + mock.Anything).Run( func(args mock.Arguments) { - arg := args.Get(5).(*big.Int) - arg.Set(price) - }).Return(nil) + arg := args.Get(5).(*LatestRoundData) + arg.Answer = big.NewInt(price.Int64()) + }).Return(nil).Once() + } + + for i := 0; i < len(errorAccounts); i++ { + reader.On("GetLatestValue", + mock.Anything, + consts.ContractNamePriceAggregator, + consts.MethodNameGetLatestRoundData, + mock.Anything, + nil, + mock.Anything).Return(fmt.Errorf("error")).Once() } return reader } diff --git a/pkg/consts/consts.go b/pkg/consts/consts.go index bde09b1c1..19a8f8c2f 100644 --- a/pkg/consts/consts.go +++ b/pkg/consts/consts.go @@ -9,6 +9,7 @@ const ( ContractNamePriceRegistry = "PriceRegistry" ContractNameCapabilitiesRegistry = "CapabilitiesRegistry" ContractNameCCIPConfig = "CCIPConfig" + ContractNamePriceAggregator = "AggregatorV3Interface" ) // Method Names @@ -38,6 +39,10 @@ const ( MethodNameGetValidatedTokenPrice = "GetValidatedTokenPrice" MethodNameGetFeeTokens = "GetFeeTokens" + // Aggregator methods + MethodNameGetLatestRoundData = "latestRoundData" + MethodNameGetDecimals = "decimals" + /* // On EVM: function commit( diff --git a/pluginconfig/commit.go b/pluginconfig/commit.go index db99daf29..5f6b2e819 100644 --- a/pluginconfig/commit.go +++ b/pluginconfig/commit.go @@ -99,6 +99,14 @@ type CommitOffchainConfig struct { // Note that the token address is that on the remote chain. PriceSources map[types.Account]ArbitrumPriceSource `json:"priceSources"` + // TokenDecimals is a map of token decimals for each token. + // As **not necessarily** each node supports both the + // 1. Token price feed chain (where we get the token price in USD) + // 2. Destination chain (where we can get the token decimals from PriceRegistry). + // So to be able to calculate the effective price we need both token decimals and feed decimals. + // This is why we need to store the token decimals in the config. + TokenDecimals map[types.Account]uint8 `json:"decimals"` + // TokenPriceChainSelector is the chain selector for the chain on which // the token prices are read from. // This will typically be an arbitrum testnet/mainnet chain depending on @@ -121,6 +129,19 @@ func (c CommitOffchainConfig) Validate() error { c.TokenPriceBatchWriteFrequency, c.TokenPriceChainSelector) } + for token, arbSource := range c.PriceSources { + if err := arbSource.Validate(); err != nil { + return fmt.Errorf("invalid arbitrum price source for token %s: %w", token, err) + } + + if _, exists := c.TokenDecimals[token]; !exists { + return fmt.Errorf("missing TokenDecimals for token: %s", token) + } + if c.TokenDecimals[token] == 0 { + return fmt.Errorf("invalid TokenDecimals for token: %s", token) + } + } + // if len(c.PriceSources) == 0 the other fields are ignored. return nil diff --git a/pluginconfig/commit_test.go b/pluginconfig/commit_test.go index bcbd86125..3fcd5b5f8 100644 --- a/pluginconfig/commit_test.go +++ b/pluginconfig/commit_test.go @@ -150,6 +150,7 @@ func TestCommitOffchainConfig_Validate(t *testing.T) { TokenPriceBatchWriteFrequency commonconfig.Duration PriceSources map[types.Account]ArbitrumPriceSource TokenPriceChainSelector uint64 + TokenDecimals map[types.Account]uint8 } //nolint:gosec const remoteTokenAddress = "0x260fAB5e97758BaB75C1216873Ec4F88C11E57E3" @@ -171,6 +172,7 @@ func TestCommitOffchainConfig_Validate(t *testing.T) { }, }, TokenPriceChainSelector: 10, + TokenDecimals: map[types.Account]uint8{remoteTokenAddress: 18}, }, false, }, @@ -180,6 +182,7 @@ func TestCommitOffchainConfig_Validate(t *testing.T) { RemoteGasPriceBatchWriteFrequency: *commonconfig.MustNewDuration(1), TokenPriceBatchWriteFrequency: *commonconfig.MustNewDuration(0), PriceSources: map[types.Account]ArbitrumPriceSource{}, + TokenDecimals: map[types.Account]uint8{}, }, false, }, @@ -194,6 +197,7 @@ func TestCommitOffchainConfig_Validate(t *testing.T) { DeviationPPB: cciptypes.BigInt{Int: big.NewInt(1)}, }, }, + TokenDecimals: map[types.Account]uint8{remoteTokenAddress: 18}, }, true, }, @@ -208,6 +212,7 @@ func TestCommitOffchainConfig_Validate(t *testing.T) { DeviationPPB: cciptypes.BigInt{Int: big.NewInt(1)}, }, }, + TokenDecimals: map[types.Account]uint8{remoteTokenAddress: 18}, }, true, }, @@ -219,6 +224,7 @@ func TestCommitOffchainConfig_Validate(t *testing.T) { TokenPriceBatchWriteFrequency: tt.fields.TokenPriceBatchWriteFrequency, PriceSources: tt.fields.PriceSources, TokenPriceChainSelector: tt.fields.TokenPriceChainSelector, + TokenDecimals: tt.fields.TokenDecimals, } err := c.Validate() if tt.wantErr {