diff --git a/go.mod b/go.mod index ddc14d053..adeb98cf7 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/pelletier/go-toml/v2 v2.1.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.17.0 - github.com/smartcontractkit/chainlink-common v0.1.7-0.20240419161010-71a18c1ab9a2 + github.com/smartcontractkit/chainlink-common v0.1.7-0.20240422215914-3b758c48e596 github.com/smartcontractkit/libocr v0.0.0-20240326191951-2bbe9382d052 github.com/stretchr/testify v1.9.0 go.uber.org/multierr v1.11.0 @@ -50,6 +50,7 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect diff --git a/go.sum b/go.sum index a4e1eb7ad..b9c75ac78 100644 --- a/go.sum +++ b/go.sum @@ -456,8 +456,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartcontractkit/chainlink-common v0.1.7-0.20240419161010-71a18c1ab9a2 h1:1gkiOu6GfzfeDu6XyYB0G9A6HYsz1lDWx8gM1jReSqI= -github.com/smartcontractkit/chainlink-common v0.1.7-0.20240419161010-71a18c1ab9a2/go.mod h1:GTDBbovHUSAUk+fuGIySF2A/whhdtHGaWmU61BoERks= +github.com/smartcontractkit/chainlink-common v0.1.7-0.20240422215914-3b758c48e596 h1:Wo+i2cPSO8eBQ3wgJFi1rBuh01n4yHcVZTkb1hYXti4= +github.com/smartcontractkit/chainlink-common v0.1.7-0.20240422215914-3b758c48e596/go.mod h1:GTDBbovHUSAUk+fuGIySF2A/whhdtHGaWmU61BoERks= github.com/smartcontractkit/go-plugin v0.0.0-20231003134350-e49dad63b306 h1:ko88+ZznniNJZbZPWAvHQU8SwKAdHngdDZ+pvVgB5ss= github.com/smartcontractkit/go-plugin v0.0.0-20231003134350-e49dad63b306/go.mod h1:w1sAEES3g3PuV/RzUrgow20W2uErMly84hhD3um1WL4= github.com/smartcontractkit/grpc-proxy v0.0.0-20230731113816-f1be6620749f h1:hgJif132UCdjo8u43i7iPN1/MFnu49hv7lFGFftCHKU= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 690a2caf0..044bb62ed 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -12,7 +12,7 @@ require ( github.com/lib/pq v1.10.9 github.com/onsi/gomega v1.30.0 github.com/rs/zerolog v1.30.0 - github.com/smartcontractkit/chainlink-common v0.1.7-0.20240419205832-845fa69af8d9 + github.com/smartcontractkit/chainlink-common v0.1.7-0.20240422215914-3b758c48e596 github.com/smartcontractkit/chainlink-solana v1.0.3-0.20240422131138-ec3bcf1a0821 github.com/smartcontractkit/chainlink-testing-framework v1.28.3 github.com/smartcontractkit/chainlink/integration-tests v0.0.0-20240422131834-9efe7cab4c4d diff --git a/integration-tests/go.sum b/integration-tests/go.sum index aaaec4c2b..3867413e8 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -1412,8 +1412,8 @@ github.com/smartcontractkit/chain-selectors v1.0.10 h1:t9kJeE6B6G+hKD0GYR4kGJSCq github.com/smartcontractkit/chain-selectors v1.0.10/go.mod h1:d4Hi+E1zqjy9HqMkjBE5q1vcG9VGgxf5VxiRHfzi2kE= github.com/smartcontractkit/chainlink-automation v1.0.3 h1:h/ijT0NiyV06VxYVgcNfsE3+8OEzT3Q0Z9au0z1BPWs= github.com/smartcontractkit/chainlink-automation v1.0.3/go.mod h1:RjboV0Qd7YP+To+OrzHGXaxUxoSONveCoAK2TQ1INLU= -github.com/smartcontractkit/chainlink-common v0.1.7-0.20240419205832-845fa69af8d9 h1:elDIBChe7ByPNvCyrSjMLTPKrgY+sKgzzlWe2p3wokY= -github.com/smartcontractkit/chainlink-common v0.1.7-0.20240419205832-845fa69af8d9/go.mod h1:GTDBbovHUSAUk+fuGIySF2A/whhdtHGaWmU61BoERks= +github.com/smartcontractkit/chainlink-common v0.1.7-0.20240422215914-3b758c48e596 h1:Wo+i2cPSO8eBQ3wgJFi1rBuh01n4yHcVZTkb1hYXti4= +github.com/smartcontractkit/chainlink-common v0.1.7-0.20240422215914-3b758c48e596/go.mod h1:GTDBbovHUSAUk+fuGIySF2A/whhdtHGaWmU61BoERks= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240419131812-73d148593d92 h1:MvaNzuaQh1vX4CAYLM8qFd99cf0ZF1JNwtDZtLU7WvU= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240419131812-73d148593d92/go.mod h1:uATrrJ8IsuBkOBJ46USuf73gz9gZy5k5bzGE5/ji/rc= github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240220203239-09be0ea34540 h1:xFSv8561jsLtF6gYZr/zW2z5qUUAkcFkApin2mnbYTo= diff --git a/pkg/monitoring/exporter/fees.go b/pkg/monitoring/exporter/fees.go new file mode 100644 index 000000000..c876c09ca --- /dev/null +++ b/pkg/monitoring/exporter/fees.go @@ -0,0 +1,97 @@ +package exporter + +import ( + "context" + + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + "github.com/smartcontractkit/chainlink-common/pkg/utils/mathutil" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" + "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" +) + +func NewFeesFactory( + log commonMonitoring.Logger, + metrics metrics.Fees, +) commonMonitoring.ExporterFactory { + return &feesFactory{ + log, + metrics, + } +} + +type feesFactory struct { + log commonMonitoring.Logger + metrics metrics.Fees +} + +func (p *feesFactory) NewExporter( + params commonMonitoring.ExporterParams, +) (commonMonitoring.Exporter, error) { + return &feesExporter{ + metrics.FeedInput{ + AccountAddress: params.FeedConfig.GetContractAddress(), + FeedID: params.FeedConfig.GetContractAddress(), + ChainID: params.ChainConfig.GetChainID(), + ContractStatus: params.FeedConfig.GetContractStatus(), + ContractType: params.FeedConfig.GetContractType(), + FeedName: params.FeedConfig.GetName(), + FeedPath: params.FeedConfig.GetPath(), + NetworkID: params.ChainConfig.GetNetworkID(), + NetworkName: params.ChainConfig.GetNetworkName(), + }, + p.log, + p.metrics, + }, nil +} + +type feesExporter struct { + label metrics.FeedInput // static for each feed + log commonMonitoring.Logger + metrics metrics.Fees +} + +func (f *feesExporter) Export(ctx context.Context, data interface{}) { + details, err := types.MakeTxDetails(data) + if err != nil { + return // skip if input could not be parsed + } + + // skip on no updates + if len(details) == 0 { + return + } + + // calculate average of non empty TxDetails + var feeArr []uint64 + var computeUnitsArr []fees.ComputeUnitPrice + for _, d := range details { + if d.Empty() { + continue + } + feeArr = append(feeArr, d.Fee) + computeUnitsArr = append(computeUnitsArr, d.ComputeUnitPrice) + } + if len(feeArr) == 0 || len(computeUnitsArr) == 0 { + f.log.Errorf("exporter could not find non-empty TxDetails") + return + } + + fee, err := mathutil.Avg(feeArr...) + if err != nil { + f.log.Errorf("fee average: %w", err) + return + } + computeUnits, err := mathutil.Avg(computeUnitsArr...) + if err != nil { + f.log.Errorf("computeUnits average: %w", err) + return + } + + f.metrics.Set(fee, computeUnits, f.label) +} + +func (f *feesExporter) Cleanup(_ context.Context) { + f.metrics.Cleanup(f.label) +} diff --git a/pkg/monitoring/exporter/fees_test.go b/pkg/monitoring/exporter/fees_test.go new file mode 100644 index 000000000..2f9bc478a --- /dev/null +++ b/pkg/monitoring/exporter/fees_test.go @@ -0,0 +1,53 @@ +package exporter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + "github.com/smartcontractkit/chainlink-common/pkg/utils" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics/mocks" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/testutils" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" + "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" +) + +func TestFees(t *testing.T) { + ctx := utils.Context(t) + lgr, logs := logger.TestObserved(t, zapcore.ErrorLevel) + m := mocks.NewFees(t) + m.On("Set", mock.Anything, mock.Anything, mock.Anything).Once() + m.On("Cleanup", mock.Anything).Once() + + factory := NewFeesFactory(lgr, m) + + chainConfig := testutils.GenerateChainConfig() + feedConfig := testutils.GenerateFeedConfig() + exporter, err := factory.NewExporter(commonMonitoring.ExporterParams{ChainConfig: chainConfig, FeedConfig: feedConfig, Nodes: []commonMonitoring.NodeConfig{}}) + require.NoError(t, err) + + // happy path + exporter.Export(ctx, []types.TxDetails{{Fee: 1, ComputeUnitPrice: 1}}) + exporter.Cleanup(ctx) + + // not txdetails type - no calls to mock + assert.NotPanics(t, func() { exporter.Export(ctx, 1) }) + + // zero txdetails - no calls to mock + exporter.Export(ctx, []types.TxDetails{}) + + // empty txdetails + exporter.Export(ctx, []types.TxDetails{{}}) + assert.Equal(t, 1, logs.FilterMessage("exporter could not find non-empty TxDetails").Len()) + + // multiple TxDetails should return average + // skip empty + m.On("Set", uint64(1), fees.ComputeUnitPrice(10), mock.Anything).Once() + exporter.Export(ctx, []types.TxDetails{{}, {Fee: 2}, {ComputeUnitPrice: 20}}) +} diff --git a/pkg/monitoring/metrics/fees.go b/pkg/monitoring/metrics/fees.go new file mode 100644 index 000000000..883b69485 --- /dev/null +++ b/pkg/monitoring/metrics/fees.go @@ -0,0 +1,39 @@ +package metrics + +import ( + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" + "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" +) + +//go:generate mockery --name Fees --output ./mocks/ + +type Fees interface { + Set(txFee uint64, computeUnitPrice fees.ComputeUnitPrice, feedInput FeedInput) + Cleanup(feedInput FeedInput) +} + +var _ Fees = (*feeMetrics)(nil) + +type feeMetrics struct { + txFee simpleGauge + computeUnit simpleGauge +} + +func NewFees(log commonMonitoring.Logger) *feeMetrics { + return &feeMetrics{ + txFee: newSimpleGauge(log, types.TxFeeMetric), + computeUnit: newSimpleGauge(log, types.ComputeUnitPriceMetric), + } +} + +func (sh *feeMetrics) Set(txFee uint64, computeUnitPrice fees.ComputeUnitPrice, feedInput FeedInput) { + sh.txFee.set(float64(txFee), feedInput.ToPromLabels()) + sh.computeUnit.set(float64(computeUnitPrice), feedInput.ToPromLabels()) +} + +func (sh *feeMetrics) Cleanup(feedInput FeedInput) { + sh.txFee.delete(feedInput.ToPromLabels()) + sh.computeUnit.delete(feedInput.ToPromLabels()) +} diff --git a/pkg/monitoring/metrics/fees_test.go b/pkg/monitoring/metrics/fees_test.go new file mode 100644 index 000000000..9e2818fb9 --- /dev/null +++ b/pkg/monitoring/metrics/fees_test.go @@ -0,0 +1,45 @@ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" + "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" +) + +func TestFees(t *testing.T) { + lgr := logger.Test(t) + m := NewFees(lgr) + + // fetching gauges + gFees, ok := gauges[types.TxFeeMetric] + require.True(t, ok) + gComputeUnits, ok := gauges[types.ComputeUnitPriceMetric] + require.True(t, ok) + + v0 := 1 + v1 := 10 + l := FeedInput{NetworkID: t.Name()} + + // set gauge + assert.NotPanics(t, func() { + m.Set(uint64(v0), fees.ComputeUnitPrice(v1), l) + }) + num := testutil.ToFloat64(gFees.With(l.ToPromLabels())) + assert.Equal(t, float64(v0), num) + num = testutil.ToFloat64(gComputeUnits.With(l.ToPromLabels())) + assert.Equal(t, float64(v1), num) + + // cleanup gauges + assert.Equal(t, 1, testutil.CollectAndCount(gFees)) + assert.Equal(t, 1, testutil.CollectAndCount(gComputeUnits)) + assert.NotPanics(t, func() { m.Cleanup(l) }) + assert.Equal(t, 0, testutil.CollectAndCount(gFees)) + assert.Equal(t, 0, testutil.CollectAndCount(gComputeUnits)) +} diff --git a/pkg/monitoring/metrics/metrics.go b/pkg/monitoring/metrics/metrics.go index 32d2d44f8..e258854bd 100644 --- a/pkg/monitoring/metrics/metrics.go +++ b/pkg/monitoring/metrics/metrics.go @@ -57,13 +57,15 @@ func init() { nodeLabels, ) - // init gauge for observation count tracking - gauges[types.ReportObservationMetric] = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: types.ReportObservationMetric, - }, - feedLabels, - ) + // init gauges for tx details tracking + for _, txDetailMetric := range types.TxDetailsMetrics { + gauges[txDetailMetric] = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: txDetailMetric, + }, + feedLabels, + ) + } // init gauge for slot height gauges[types.SlotHeightMetric] = promauto.NewGaugeVec( diff --git a/pkg/monitoring/metrics/mocks/Fees.go b/pkg/monitoring/metrics/mocks/Fees.go new file mode 100644 index 000000000..aa1bddd2f --- /dev/null +++ b/pkg/monitoring/metrics/mocks/Fees.go @@ -0,0 +1,40 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + metrics "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics" + fees "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" + + mock "github.com/stretchr/testify/mock" +) + +// Fees is an autogenerated mock type for the Fees type +type Fees struct { + mock.Mock +} + +// Cleanup provides a mock function with given fields: feedInput +func (_m *Fees) Cleanup(feedInput metrics.FeedInput) { + _m.Called(feedInput) +} + +// Set provides a mock function with given fields: txFee, computeUnitPrice, feedInput +func (_m *Fees) Set(txFee uint64, computeUnitPrice fees.ComputeUnitPrice, feedInput metrics.FeedInput) { + _m.Called(txFee, computeUnitPrice, feedInput) +} + +type mockConstructorTestingTNewFees interface { + mock.TestingT + Cleanup(func()) +} + +// NewFees creates a new instance of Fees. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewFees(t mockConstructorTestingTNewFees) *Fees { + mock := &Fees{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/monitoring/types/examples.go b/pkg/monitoring/types/examples.go index 9331b4d85..776140855 100644 --- a/pkg/monitoring/types/examples.go +++ b/pkg/monitoring/types/examples.go @@ -3,6 +3,7 @@ package types import "github.com/gagliardetto/solana-go" var ( + sampleTxResultSigner = solana.MustPublicKeyFromBase58("9YR7YttJFfptQJSo5xrnYoAw1fJyVonC1vxUSqzAgyjY") SampleTxResultProgram = solana.MustPublicKeyFromBase58("cjg3oHmg9uuPsP8D6g29NWvhySJkdYdAo9D25PRbKXJ") SampleTxResultJSON = `{"blockTime":1712887149,"meta":{"computeUnitsConsumed":64949,"err":null,"fee":5000,"innerInstructions":[{"index":1,"instructions":[{"accounts":[2,4],"data":"6y43XFem5gk9n8ESJ4pGFboagJiimTtvvy2VCjAUur3y","programIdIndex":3,"stackHeight":2}]}],"loadedAddresses":{"readonly":[],"writable":[]},"logMessages":["Program ComputeBudget111111111111111111111111111111 invoke [1]","Program ComputeBudget111111111111111111111111111111 success","Program cjg3oHmg9uuPsP8D6g29NWvhySJkdYdAo9D25PRbKXJ invoke [1]","Program data: gjbLTR5rT6hW4eUAAAN30/iLBm0GRKxe6y9hGtvvKCPLmscA16aVgw6AKe17ouFpAAAAAAAAAAAAAAAAA2uVGGYEAwECAAAAAAAAAAAAAAAAAAAAAKom6kICAAAAsr0AAAAAAAA=","Program HEvSKofvBgfaexv23kMabbYqxasxU3mQ4ibBMEmJWHny invoke [2]","Program log: Instruction: Submit","Program HEvSKofvBgfaexv23kMabbYqxasxU3mQ4ibBMEmJWHny consumed 4427 of 140121 compute units","Program HEvSKofvBgfaexv23kMabbYqxasxU3mQ4ibBMEmJWHny success","Program cjg3oHmg9uuPsP8D6g29NWvhySJkdYdAo9D25PRbKXJ consumed 64799 of 199850 compute units","Program cjg3oHmg9uuPsP8D6g29NWvhySJkdYdAo9D25PRbKXJ success"],"postBalances":[1019067874127,49054080,2616960,1141440,0,0,1141440,1],"postTokenBalances":[],"preBalances":[1019067879127,49054080,2616960,1141440,0,0,1141440,1],"preTokenBalances":[],"rewards":[],"status":{"Ok":null}},"slot":291748793,"transaction":{"message":{"accountKeys":["9YR7YttJFfptQJSo5xrnYoAw1fJyVonC1vxUSqzAgyjY","Ghm1a2c2NGPg6pKGG3PP1GLAuJkHm1RKMPqqJwPM7JpJ","HXoZZBWv25N4fm2vfSKnHXTeDJ31qaAcWZe3ZKeM6dQv","HEvSKofvBgfaexv23kMabbYqxasxU3mQ4ibBMEmJWHny","3u6T92C2x18s39a7WNM8NGaQK1YEtstTtqamZGsLvNZN","Sysvar1nstructions1111111111111111111111111","cjg3oHmg9uuPsP8D6g29NWvhySJkdYdAo9D25PRbKXJ","ComputeBudget111111111111111111111111111111"],"header":{"numReadonlySignedAccounts":0,"numReadonlyUnsignedAccounts":5,"numRequiredSignatures":1},"instructions":[{"accounts":[],"data":"3DTZbgwsozUF","programIdIndex":7,"stackHeight":null},{"accounts":[1,0,2,3,4,5],"data":"4W4pS7SH6dugDLwXWijhmW3dGTP7WENQa9vbUjvati1j95ghou2jUJHxvPUoowhZk2bHk21uKk4uFRQrpVF5e54NejQLtAT4DeZPC8n3QudjXhAHgBvFjYvDZDhCKRBK4nvdysDh7aKSE4nb3RiampwUo4u5WsKFfXYZnzbn8edC6jwuJVju1DczQPiLuzuCUps99C8rxwE9XkonGMrjc3Pj4cArMggk5fitRkfdaUn4mGRXDHzPFSg63YTZEn7tnnJd8pWEu9v9H8wBKcN1ptLiY5QmKSnayRcfYvd8MZ9wWf8bD7iVGSNUnwJToyFBVyBNabibozthXSDNmxr3yz1uR9vE3HFq6C2i1LX32a2aqZWzJjmvgdVNfNZZxqDxR6GvWYMw35","programIdIndex":6,"stackHeight":null}],"recentBlockhash":"BKUsMxK39LcgXKm8j5LuYyhig2kgQtRBkxR89szEzaSU"},"signatures":["2eEb8FeJyhczELJ3XKc6yvNLi3jYoC9vdpaR6WUN5vJ3f15ZV1d7LGZZqrqseQFEedgE4cxwcd3S3jYLmvJWBrNg"]}}` ) diff --git a/pkg/monitoring/types/txdetails.go b/pkg/monitoring/types/txdetails.go index ce526e8d8..a18ee7a09 100644 --- a/pkg/monitoring/types/txdetails.go +++ b/pkg/monitoring/types/txdetails.go @@ -16,7 +16,15 @@ import ( var ( TxDetailsType = "txdetails" - ReportObservationMetric = "report_observations" + ReportObservationMetric = "sol_report_observations" + TxFeeMetric = "sol_tx_fee" + ComputeUnitPriceMetric = "sol_tx_compute_unit_price" + + TxDetailsMetrics = []string{ + ReportObservationMetric, + TxFeeMetric, + ComputeUnitPriceMetric, + } ) type TxDetails struct { @@ -26,15 +34,17 @@ type TxDetails struct { Sender solanaGo.PublicKey - // report information - only supports single report per tx + // report tx information - only supports single report per tx ObservationCount uint8 + ComputeUnitPrice fees.ComputeUnitPrice } func (td TxDetails) Empty() bool { return td.Fee == 0 && td.Slot == 0 && td.Sender == solanaGo.PublicKey{} && - td.ObservationCount == 0 + td.ObservationCount == 0 && + td.ComputeUnitPrice == 0 } // MakeTxDetails casts an interface to []TxDetails @@ -93,15 +103,15 @@ func ParseTx(tx *solanaGo.Transaction, programAddr solanaGo.PublicKey) (TxDetail // The signature at index i corresponds to the public key at index i in message.accountKeys. sender := tx.Message.AccountKeys[0] - // CL node DF transactions should only have a compute budget + ocr2 instruction + // CL node DF transactions should only have a compute unit price + ocr2 instruction if len(tx.Message.Instructions) != 2 { return TxDetails{}, fmt.Errorf("not a node transaction") } - var obsCount uint8 var totalErr error var foundTransmit bool var foundFee bool + txDetails := TxDetails{Sender: sender} for _, instruction := range tx.Message.Instructions { // protect against invalid index if int(instruction.ProgramIDIndex) >= len(tx.Message.AccountKeys) { @@ -113,21 +123,35 @@ func ParseTx(tx *solanaGo.Transaction, programAddr solanaGo.PublicKey) (TxDetail // parse report from tx data (see solana/transmitter.go) start := solana.StoreNonceLen + solana.ReportContextLen end := start + int(solana.ReportLen) + + // handle invalid length + if len(instruction.Data) < (solana.StoreNonceLen + solana.ReportContextLen + int(solana.ReportLen)) { + totalErr = errors.Join(totalErr, fmt.Errorf("transmit: invalid instruction length (%+v)", instruction)) + continue + } + report := types.Report(instruction.Data[start:end]) - count, err := solana.ReportCodec{}.ObserversCountFromReport(report) + var err error + txDetails.ObservationCount, err = solana.ReportCodec{}.ObserversCountFromReport(report) if err != nil { totalErr = errors.Join(totalErr, fmt.Errorf("%w (%+v)", err, instruction)) continue } - obsCount = count foundTransmit = true continue } // find compute budget program instruction if tx.Message.AccountKeys[instruction.ProgramIDIndex] == solanaGo.MustPublicKeyFromBase58(fees.COMPUTE_BUDGET_PROGRAM) { - // future: parsing fee calculation + // parsing compute unit price + var err error + txDetails.ComputeUnitPrice, err = fees.ParseComputeUnitPrice(instruction.Data) + if err != nil { + totalErr = errors.Join(totalErr, fmt.Errorf("computeUnitPrice: %w (%+v)", err, instruction)) + continue + } foundFee = true + continue } } if totalErr != nil { @@ -139,8 +163,5 @@ func ParseTx(tx *solanaGo.Transaction, programAddr solanaGo.PublicKey) (TxDetail return TxDetails{}, fmt.Errorf("unable to parse both Transmit and Fee instructions") } - return TxDetails{ - Sender: sender, - ObservationCount: obsCount, - }, nil + return txDetails, nil } diff --git a/pkg/monitoring/types/txdetails_test.go b/pkg/monitoring/types/txdetails_test.go index e18d482d2..8b83e9b7a 100644 --- a/pkg/monitoring/types/txdetails_test.go +++ b/pkg/monitoring/types/txdetails_test.go @@ -6,20 +6,22 @@ import ( "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var ( - sampleTxResultSigner = solana.MustPublicKeyFromBase58("9YR7YttJFfptQJSo5xrnYoAw1fJyVonC1vxUSqzAgyjY") - - sampleTxResult = rpc.GetTransactionResult{} -) +func getTestTxResult(t *testing.T) *rpc.GetTransactionResult { + out := &rpc.GetTransactionResult{} + require.NoError(t, json.Unmarshal([]byte(SampleTxResultJSON), out)) + return out +} -func init() { - if err := json.Unmarshal([]byte(SampleTxResultJSON), &sampleTxResult); err != nil { - panic("unable to unmarshal sampleTxResult") - } +func getTestTx(t *testing.T) *solana.Transaction { + tx, err := getTestTxResult(t).Transaction.GetTransaction() + require.NoError(t, err) + require.NotNil(t, tx) + return tx } func TestParseTxResult(t *testing.T) { @@ -36,7 +38,7 @@ func TestParseTxResult(t *testing.T) { require.ErrorContains(t, err, "txResult.Transaction") // happy path - res, err := ParseTxResult(&sampleTxResult, SampleTxResultProgram) + res, err := ParseTxResult(getTestTxResult(t), SampleTxResultProgram) require.NoError(t, err) assert.Equal(t, nil, res.Err) @@ -47,40 +49,44 @@ func TestParseTx(t *testing.T) { _, err := ParseTx(nil, SampleTxResultProgram) require.ErrorContains(t, err, "tx is nil") - tx, err := sampleTxResult.Transaction.GetTransaction() - require.NoError(t, err) - require.NotNil(t, tx) - - txMissingSig := *tx // copy + txMissingSig := getTestTx(t) // copy txMissingSig.Signatures = []solana.Signature{} - _, err = ParseTx(&txMissingSig, SampleTxResultProgram) + _, err = ParseTx(txMissingSig, SampleTxResultProgram) require.ErrorContains(t, err, "invalid number of signatures") - txMissingAccounts := *tx // copy + txMissingAccounts := getTestTx(t) // copy txMissingAccounts.Message.AccountKeys = []solana.PublicKey{} - _, err = ParseTx(&txMissingAccounts, SampleTxResultProgram) + _, err = ParseTx(txMissingAccounts, SampleTxResultProgram) require.ErrorContains(t, err, "invalid number of signatures") - prevIndex := tx.Message.Instructions[1].ProgramIDIndex - txInvalidProgramIndex := *tx // copy + txInvalidProgramIndex := getTestTx(t) // copy txInvalidProgramIndex.Message.Instructions[1].ProgramIDIndex = 100 // index 1 is ocr transmit call - out, err := ParseTx(&txInvalidProgramIndex, SampleTxResultProgram) + out, err := ParseTx(txInvalidProgramIndex, SampleTxResultProgram) require.Error(t, err) - tx.Message.Instructions[1].ProgramIDIndex = prevIndex // reset - something shares memory underneath // don't match program - out, err = ParseTx(tx, solana.PublicKey{}) + out, err = ParseTx(getTestTx(t), solana.PublicKey{}) require.Error(t, err) + // invalid length transmit instruction + compute budget instruction + txInvalidTransmitInstruction := getTestTx(t) + txInvalidTransmitInstruction.Message.Instructions[0].Data = []byte{} + txInvalidTransmitInstruction.Message.Instructions[1].Data = []byte{} + _, err = ParseTx(txInvalidTransmitInstruction, SampleTxResultProgram) + require.ErrorContains(t, err, "transmit: invalid instruction length") + + require.ErrorContains(t, err, "computeUnitPrice") + // happy path - out, err = ParseTx(tx, SampleTxResultProgram) + out, err = ParseTx(getTestTx(t), SampleTxResultProgram) require.NoError(t, err) assert.Equal(t, sampleTxResultSigner, out.Sender) assert.Equal(t, uint8(4), out.ObservationCount) + assert.Equal(t, fees.ComputeUnitPrice(0), out.ComputeUnitPrice) // multiple instructions - currently not the case - txMultipleTransmit := *tx - txMultipleTransmit.Message.Instructions = append(tx.Message.Instructions, tx.Message.Instructions[1]) - out, err = ParseTx(&txMultipleTransmit, SampleTxResultProgram) + txMultipleTransmit := getTestTx(t) + txMultipleTransmit.Message.Instructions = append(txMultipleTransmit.Message.Instructions, getTestTx(t).Message.Instructions[1]) + out, err = ParseTx(txMultipleTransmit, SampleTxResultProgram) require.Error(t, err) } diff --git a/pkg/solana/fees/computebudget.go b/pkg/solana/fees/computebudget.go index f0c4a1ec2..ba980f0a2 100644 --- a/pkg/solana/fees/computebudget.go +++ b/pkg/solana/fees/computebudget.go @@ -3,6 +3,7 @@ package fees import ( "bytes" "encoding/binary" + "fmt" "github.com/gagliardetto/solana-go" ) @@ -63,6 +64,19 @@ func (val ComputeUnitPrice) Data() ([]byte, error) { return buf.Bytes(), nil } +func ParseComputeUnitPrice(data []byte) (ComputeUnitPrice, error) { + if len(data) != (1 + 8) { // instruction byte + uint64 + return 0, fmt.Errorf("invalid length: %d", len(data)) + } + + if data[0] != Instruction_SetComputeUnitPrice { + return 0, fmt.Errorf("not SetComputeUnitPrice identifier: %d", data[0]) + } + + // guarantees length 8 + return ComputeUnitPrice(binary.LittleEndian.Uint64(data[1:])), nil +} + // modifies passed in tx to set compute unit price func SetComputeUnitPrice(tx *solana.Transaction, price ComputeUnitPrice) error { // find ComputeBudget program to accounts if it exists diff --git a/pkg/solana/fees/computebudget_test.go b/pkg/solana/fees/computebudget_test.go index b4003281e..4fc3a35c9 100644 --- a/pkg/solana/fees/computebudget_test.go +++ b/pkg/solana/fees/computebudget_test.go @@ -101,3 +101,23 @@ func TestSetComputeUnitPrice(t *testing.T) { }) } + +func TestParseComputeUnitPrice(t *testing.T) { + data, err := ComputeUnitPrice(100).Data() + assert.NoError(t, err) + + v, err := ParseComputeUnitPrice(data) + assert.NoError(t, err) + assert.Equal(t, ComputeUnitPrice(100), v) + + _, err = ParseComputeUnitPrice([]byte{}) + assert.ErrorContains(t, err, "invalid length") + tooLong := [10]byte{} + _, err = ParseComputeUnitPrice(tooLong[:]) + assert.ErrorContains(t, err, "invalid length") + + invalidData := data + invalidData[0] = Instruction_RequestHeapFrame + _, err = ParseComputeUnitPrice(invalidData) + assert.ErrorContains(t, err, "not SetComputeUnitPrice identifier") +}