Skip to content
2 changes: 1 addition & 1 deletion capabilities/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions capabilities/writetarget/mocks/target_strategy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 25 additions & 1 deletion capabilities/writetarget/write_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"time"

"github.com/shopspring/decimal"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"

Expand Down Expand Up @@ -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)
Copy link
Contributor

@ilija42 ilija42 Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gas values are always returned as big.Int, I'm assuming this value will be created like this: decimal.New(val, -18)? Could be annoying converting it back to big.Int

}

var (
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 51 additions & 4 deletions capabilities/writetarget/write_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand All @@ -56,7 +59,7 @@ func setupWriteTarget(
PollPeriod: pollPeriod,
AcceptanceTimeout: timeout,
},
ChainInfo: monitor.ChainInfo{},
ChainInfo: monitor.ChainInfo{ChainID: "1"},
Logger: lggr,
Beholder: monClient,
ChainService: chainSvc,
Expand Down Expand Up @@ -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
}

Expand All @@ -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) {
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
},
}

Expand All @@ -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)

Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down
Loading