diff --git a/capabilities/go.mod b/capabilities/go.mod index 3f645ae..07f5718 100644 --- a/capabilities/go.mod +++ b/capabilities/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.3 require ( github.com/google/uuid v1.6.0 github.com/jpillora/backoff v1.0.0 + github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chainlink-common v0.7.1-0.20250618162808-a5a42ee8701b github.com/stretchr/testify v1.10.0 go.opentelemetry.io/otel v1.35.0 @@ -49,7 +50,6 @@ require ( github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 // indirect - github.com/shopspring/decimal v1.4.0 // indirect github.com/smartcontractkit/libocr v0.0.0-20250328171017-609ec10a5510 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/capabilities/writetarget/mocks/target_strategy.go b/capabilities/writetarget/mocks/target_strategy.go index 6f2740c..a52c7fe 100644 --- a/capabilities/writetarget/mocks/target_strategy.go +++ b/capabilities/writetarget/mocks/target_strategy.go @@ -7,6 +7,8 @@ import ( capabilities "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + decimal "github.com/shopspring/decimal" + mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/chainlink-common/pkg/types" @@ -27,6 +29,123 @@ func (_m *TargetStrategy) EXPECT() *TargetStrategy_Expecter { return &TargetStrategy_Expecter{mock: &_m.Mock} } +// GetEstimateFee provides a mock function with given fields: ctx, report, reportContext, signatures, request +func (_m *TargetStrategy) GetEstimateFee(ctx context.Context, report []byte, reportContext []byte, signatures [][]byte, request capabilities.CapabilityRequest) (types.EstimateFee, error) { + ret := _m.Called(ctx, report, reportContext, signatures, request) + + if len(ret) == 0 { + panic("no return value specified for GetEstimateFee") + } + + var r0 types.EstimateFee + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []byte, []byte, [][]byte, capabilities.CapabilityRequest) (types.EstimateFee, error)); ok { + return rf(ctx, report, reportContext, signatures, request) + } + if rf, ok := ret.Get(0).(func(context.Context, []byte, []byte, [][]byte, capabilities.CapabilityRequest) types.EstimateFee); ok { + r0 = rf(ctx, report, reportContext, signatures, request) + } else { + r0 = ret.Get(0).(types.EstimateFee) + } + + if rf, ok := ret.Get(1).(func(context.Context, []byte, []byte, [][]byte, capabilities.CapabilityRequest) error); ok { + r1 = rf(ctx, report, reportContext, signatures, request) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TargetStrategy_GetEstimateFee_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEstimateFee' +type TargetStrategy_GetEstimateFee_Call struct { + *mock.Call +} + +// GetEstimateFee is a helper method to define mock.On call +// - ctx context.Context +// - report []byte +// - reportContext []byte +// - signatures [][]byte +// - request capabilities.CapabilityRequest +func (_e *TargetStrategy_Expecter) GetEstimateFee(ctx interface{}, report interface{}, reportContext interface{}, signatures interface{}, request interface{}) *TargetStrategy_GetEstimateFee_Call { + return &TargetStrategy_GetEstimateFee_Call{Call: _e.mock.On("GetEstimateFee", ctx, report, reportContext, signatures, request)} +} + +func (_c *TargetStrategy_GetEstimateFee_Call) Run(run func(ctx context.Context, report []byte, reportContext []byte, signatures [][]byte, request capabilities.CapabilityRequest)) *TargetStrategy_GetEstimateFee_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]byte), args[2].([]byte), args[3].([][]byte), args[4].(capabilities.CapabilityRequest)) + }) + return _c +} + +func (_c *TargetStrategy_GetEstimateFee_Call) Return(_a0 types.EstimateFee, _a1 error) *TargetStrategy_GetEstimateFee_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TargetStrategy_GetEstimateFee_Call) RunAndReturn(run func(context.Context, []byte, []byte, [][]byte, capabilities.CapabilityRequest) (types.EstimateFee, error)) *TargetStrategy_GetEstimateFee_Call { + _c.Call.Return(run) + return _c +} + +// GetTransactionFee provides a mock function with given fields: ctx, transactionID +func (_m *TargetStrategy) GetTransactionFee(ctx context.Context, transactionID string) (decimal.Decimal, error) { + ret := _m.Called(ctx, transactionID) + + if len(ret) == 0 { + panic("no return value specified for GetTransactionFee") + } + + var r0 decimal.Decimal + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (decimal.Decimal, error)); ok { + return rf(ctx, transactionID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) decimal.Decimal); ok { + r0 = rf(ctx, transactionID) + } else { + r0 = ret.Get(0).(decimal.Decimal) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, transactionID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TargetStrategy_GetTransactionFee_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTransactionFee' +type TargetStrategy_GetTransactionFee_Call struct { + *mock.Call +} + +// GetTransactionFee is a helper method to define mock.On call +// - ctx context.Context +// - transactionID string +func (_e *TargetStrategy_Expecter) GetTransactionFee(ctx interface{}, transactionID interface{}) *TargetStrategy_GetTransactionFee_Call { + return &TargetStrategy_GetTransactionFee_Call{Call: _e.mock.On("GetTransactionFee", ctx, transactionID)} +} + +func (_c *TargetStrategy_GetTransactionFee_Call) Run(run func(ctx context.Context, transactionID string)) *TargetStrategy_GetTransactionFee_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *TargetStrategy_GetTransactionFee_Call) Return(_a0 decimal.Decimal, _a1 error) *TargetStrategy_GetTransactionFee_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TargetStrategy_GetTransactionFee_Call) RunAndReturn(run func(context.Context, string) (decimal.Decimal, error)) *TargetStrategy_GetTransactionFee_Call { + _c.Call.Return(run) + return _c +} + // GetTransactionStatus provides a mock function with given fields: ctx, transactionID func (_m *TargetStrategy) GetTransactionStatus(ctx context.Context, transactionID string) (types.TransactionStatus, error) { ret := _m.Called(ctx, transactionID) diff --git a/capabilities/writetarget/write_target.go b/capabilities/writetarget/write_target.go index 386b6dc..baec333 100644 --- a/capabilities/writetarget/write_target.go +++ b/capabilities/writetarget/write_target.go @@ -9,6 +9,7 @@ import ( "fmt" "time" + "github.com/shopspring/decimal" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -57,6 +58,11 @@ type TargetStrategy interface { TransmitReport(ctx context.Context, report []byte, reportContext []byte, signatures [][]byte, request capabilities.CapabilityRequest) (string, error) // Wrapper around the ChainWriter to get the transaction status GetTransactionStatus(ctx context.Context, transactionID string) (commontypes.TransactionStatus, error) + // Wrapper around the ChainWriter to get the fee esimate + GetEstimateFee(ctx context.Context, report []byte, reportContext []byte, signatures [][]byte, request capabilities.CapabilityRequest) (commontypes.EstimateFee, error) + // GetTransactionFee retrieves the actual transaction fee in native currency from the transaction receipt. + // This method should be implemented by chain-specific services and handle the conversion of gas units to native currency. + GetTransactionFee(ctx context.Context, transactionID string) (decimal.Decimal, error) } var ( @@ -342,7 +348,25 @@ func (c *writeTarget) Execute(ctx context.Context, request capabilities.Capabili if err != nil { return capabilities.CapabilityResponse{}, err } - return success(), nil + + // Get the transaction fee + fee, err := c.targetStrategy.GetTransactionFee(ctx, txID) + if err != nil { + c.lggr.Errorw("failed to get transaction fee: %w", err) + return capabilities.CapabilityResponse{}, nil + } + + return capabilities.CapabilityResponse{ + Metadata: capabilities.ResponseMetadata{ + Metering: []capabilities.MeteringNodeDetail{ + { + // Peer2PeerID from remote peers is ignored by engine + SpendUnit: "GAS." + c.chainInfo.ChainID, + SpendValue: fee.String(), + }, + }, + }, + }, nil } func (c *writeTarget) RegisterToWorkflow(ctx context.Context, request capabilities.RegisterToWorkflowRequest) error { diff --git a/capabilities/writetarget/write_target_test.go b/capabilities/writetarget/write_target_test.go index 00b23d1..4f5ab81 100644 --- a/capabilities/writetarget/write_target_test.go +++ b/capabilities/writetarget/write_target_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -43,6 +44,8 @@ func setupWriteTarget( if productSpecificProcessor { platformProcessors["test"] = newMockProductSpecificProcessor(t) } + // Always add a mock for the default processor (writetarget) to prevent nil pointer dereference + platformProcessors["writetarget"] = newMockProductSpecificProcessor(t) monClient, err := writetarget.NewMonitor(writetarget.MonitorOpts{lggr, platformProcessors, processor.PlatformDefaultProcessors, emitter}) require.NoError(t, err) @@ -56,7 +59,7 @@ func setupWriteTarget( PollPeriod: pollPeriod, AcceptanceTimeout: timeout, }, - ChainInfo: monitor.ChainInfo{}, + ChainInfo: monitor.ChainInfo{ChainID: "1"}, Logger: lggr, Beholder: monClient, ChainService: chainSvc, @@ -105,7 +108,9 @@ func setupWriteTarget( func newMockProductSpecificProcessor(t *testing.T) beholder.ProtoProcessor { processor := monmocks.NewProtoProcessor(t) - processor.EXPECT().Process(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + // Handle both 3-arg and 4-arg Process calls + processor.EXPECT().Process(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() + processor.EXPECT().Process(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() return processor } @@ -118,6 +123,10 @@ type testCase struct { errorContains string productSpecificProcessor bool requiredLogMessage string + // Gas estimation and transaction fee fields + transactionFeeError error + transactionFee decimal.Decimal + expectTransactionFee bool } func TestWriteTarget_Execute(t *testing.T) { @@ -128,6 +137,8 @@ func TestWriteTarget_Execute(t *testing.T) { txState: commontypes.Finalized, expectError: false, requiredLogMessage: "no matching processor for MetaCapabilityProcessor=test", + transactionFee: decimal.NewFromFloat(0.0001), + expectTransactionFee: true, }, { name: "succeeds transmission state is already succeeded", @@ -142,6 +153,8 @@ func TestWriteTarget_Execute(t *testing.T) { expectError: false, productSpecificProcessor: true, requiredLogMessage: "confirmed - transmission state visible", + transactionFee: decimal.NewFromFloat(0.0001), + expectTransactionFee: true, }, { name: "already succeeded with product specific processor", @@ -204,6 +217,8 @@ func TestWriteTarget_Execute(t *testing.T) { simulateTxError: false, expectError: false, requiredLogMessage: "confirmed - transmission state visible but submitted by another node. This node's tx failed", + transactionFee: decimal.NewFromFloat(0.0001), + expectTransactionFee: true, }, { name: "Returns success if report is on-chain but tx is failed", @@ -212,6 +227,18 @@ func TestWriteTarget_Execute(t *testing.T) { simulateTxError: false, expectError: false, requiredLogMessage: "confirmed - transmission state visible but submitted by another node. This node's tx failed", + transactionFee: decimal.NewFromFloat(0.0001), + expectTransactionFee: true, + }, + // Gas estimation and transaction fee test cases + { + name: "succeeds when no spend limit is specified", + initialTransmissionState: writetarget.TransmissionState{Status: writetarget.TransmissionStateNotAttempted}, + txState: commontypes.Finalized, + expectError: false, + transactionFee: decimal.NewFromFloat(0.0005), + expectTransactionFee: true, + requiredLogMessage: "no matching processor for MetaCapabilityProcessor=test", }, } @@ -225,10 +252,10 @@ func TestWriteTarget_Execute(t *testing.T) { mockTransmissionState(tc, strategy) mockBeholderMessages(tc, emitter) mockTransmit(tc, strategy, emitter) + mockTransactionFee(tc, strategy) chainSvc := wtmocks.NewChainService(t) - chainSvc.EXPECT().LatestHead(mock.Anything). - Return(commontypes.Head{Height: "100"}, nil) + chainSvc.EXPECT().LatestHead(mock.Anything).Return(commontypes.Head{Height: "100"}, nil) target, req := setupWriteTarget(t, lggr, strategy, chainSvc, tc.productSpecificProcessor, emitter) @@ -239,6 +266,14 @@ func TestWriteTarget_Execute(t *testing.T) { } else { require.NoError(t, err) assert.NotNil(t, resp) + + // Verify transaction fee in response metadata for successful cases + if tc.expectTransactionFee && tc.transactionFeeError == nil { + require.NotEmpty(t, resp.Metadata.Metering) + require.Empty(t, resp.Metadata.Metering[0].Peer2PeerID) + require.Equal(t, "GAS.1", resp.Metadata.Metering[0].SpendUnit) + require.Equal(t, tc.transactionFee.String(), resp.Metadata.Metering[0].SpendValue) + } } if tc.requiredLogMessage != "" { @@ -338,6 +373,18 @@ func mockTransmit(tc testCase, strategy *wtmocks.TargetStrategy, emitter *monmoc } } +func mockTransactionFee(tc testCase, strategy *wtmocks.TargetStrategy) { + // Only set up transaction fee mock if we expect the execution to reach that point + if !tc.expectError && tc.expectTransactionFee { + ex := strategy.EXPECT().GetTransactionFee(mock.Anything, mock.Anything) + if tc.transactionFeeError != nil { + ex.Return(decimal.Decimal{}, tc.transactionFeeError) + } else { + ex.Return(tc.transactionFee, nil) + } + } +} + func TestNewWriteTargetID(t *testing.T) { tests := []struct { name string