diff --git a/chain_capabilities/common/go.mod b/chain_capabilities/common/go.mod index 3460480ae..9e1d7302e 100644 --- a/chain_capabilities/common/go.mod +++ b/chain_capabilities/common/go.mod @@ -5,7 +5,7 @@ go 1.26.2 require ( github.com/jpillora/backoff v1.0.0 github.com/smartcontractkit/capabilities/libs v0.0.0-20260602154159-3bc5aa37c661 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260601211238-9f526774fef0 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260604234426-27a72f7f8cf8 github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 @@ -55,7 +55,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/smartcontractkit/chain-selectors v1.0.100 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 // indirect - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/chain_capabilities/common/go.sum b/chain_capabilities/common/go.sum index 3e5dfb939..06e6ed197 100644 --- a/chain_capabilities/common/go.sum +++ b/chain_capabilities/common/go.sum @@ -94,12 +94,12 @@ github.com/smartcontractkit/capabilities/libs v0.0.0-20260602154159-3bc5aa37c661 github.com/smartcontractkit/capabilities/libs v0.0.0-20260602154159-3bc5aa37c661/go.mod h1:LS7F8U2YZNc0Vt8f6SVWUUigGLxdxZMpyC7VCcUTagg= github.com/smartcontractkit/chain-selectors v1.0.100 h1:wpiSpmI/eFjY+wx/nPr5VuNF4hki0prIBMKEaQWn3g4= github.com/smartcontractkit/chain-selectors v1.0.100/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260601211238-9f526774fef0 h1:ekpMT6wV+caBWnaBGUD/j1eoal+DhNLq7jv1hFf/nyU= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260601211238-9f526774fef0/go.mod h1:6jgqiFXFJHqjkvFFmuf8gvoUFa6Ygx/D1tKnIL+CCF8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260604234426-27a72f7f8cf8 h1:pm4xOepOj4uGYKqzVJxIi3MnLleYKTuLcxBNW51s9jI= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260604234426-27a72f7f8cf8/go.mod h1:fP9RqD25/gTx3XqRstN8o4lAI3jp42vwJBLRZwRoOOM= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 h1:NExKM/D0HneOq/N5LGTbkV4VOa0UHCvfTNEb4GqYpto= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 h1:iljEJss3WOwcsMkWy72Yn2zvjw7Gyxc+RXL7r8YKM6g= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f h1:t+OoYaXLdH0WHK2pbWKjTSnSQa5JBQD1+gf0yISYfQk= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d h1:LokA9PoCNb8mm8mDT52c3RECPMRsGz1eCQORq+J3n74= diff --git a/chain_capabilities/solana/actions/actions.go b/chain_capabilities/solana/actions/actions.go index d5b9a0036..72f45f405 100644 --- a/chain_capabilities/solana/actions/actions.go +++ b/chain_capabilities/solana/actions/actions.go @@ -1,26 +1,33 @@ package actions import ( + "bytes" "context" "errors" "fmt" + "math/big" + "slices" "strings" "time" "github.com/smartcontractkit/capabilities/chain_capabilities/solana/metering" "github.com/smartcontractkit/capabilities/libs/chainconsensus" ctypes "github.com/smartcontractkit/capabilities/libs/chainconsensus/types" + commonMon "github.com/smartcontractkit/capabilities/libs/monitoring" "github.com/smartcontractkit/chainlink-common/pkg/beholder" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" solcap "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana" + capmon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/monitoring" commoncfg "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types" + soltypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/solana" "github.com/smartcontractkit/chainlink-framework/multinode" + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" capcommon "github.com/smartcontractkit/capabilities/chain_capabilities/common" ts "github.com/smartcontractkit/capabilities/chain_capabilities/common/transmission_schedule" @@ -82,7 +89,7 @@ func (s *Solana) GetAccountInfoWithOpts( if err != nil { return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) } - + request.IsExternal = true lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) lggr.Debugw("Received GetAccountInfoWithOpts request") cReq := ctypes.NewVolatileRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetAccountInfoWithOptsReply, uint64, error) { @@ -108,85 +115,313 @@ func (s *Solana) GetAccountInfoWithOpts( return responseAndMetadata, nil } -func getReadError(lggr logger.SugaredLogger, err error) caperrors.Error { - if err == nil { - return nil +func (s *Solana) GetBalance( + ctx context.Context, + metadata capabilities.RequestMetadata, + input *solcap.GetBalanceRequest) (*capabilities.ResponseAndMetadata[*solcap.GetBalanceReply], caperrors.Error) { + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) } - - isUserErr := isUserError(err) - capErr := GetError(err, isUserErr) - - // TODO: logging of init, success and error should be move to a higher level - lggr = lggr.With("error", err) - const msg = "Read operation failed" - if isUserErr { - lggr.Debug(msg) - } else { - lggr.Error(msg) + request, err := solcap.ConvertGetBalanceRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) } - return capErr -} + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetBalance request") + cReq := ctypes.NewVolatileRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetBalanceReply, uint64, error) { + rawResponse, err := s.SolanaService.GetBalance(ctx, request) + if err != nil { + return nil, 0, err + } -func isUserError(err error) bool { - return !errors.Is(err, context.DeadlineExceeded) && !isNodeInfraError(err) -} + response, err := solcap.ConvertGetBalanceReplyToProto(rawResponse) + if err != nil { + return nil, 0, caperrors.NewPublicSystemError(fmt.Errorf("failed to convert response to proto: %w", err), caperrors.Internal) + } -func isNodeInfraError(err error) bool { - return errors.Is(err, multinode.ErrNodeError) || - strings.Contains(err.Error(), multinode.ErrNodeError.Error()) -} + return response, 0, nil + }, lggr) + responseAndMetadata, err := chainconsensus.ReadHashableRequestReport[*solcap.GetBalanceReply](ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetBalance: %w", err)) + } -func (s *Solana) GetBalance( - ctx context.Context, - metadata capabilities.RequestMetadata, - input *solcap.GetBalanceRequest) (*capabilities.ResponseAndMetadata[*solcap.GetBalanceReply], caperrors.Error) { - // TODO - return nil, GetError(errors.New("unimplemented"), false) + lggr.Debugw("Successfully handled GetBalance") + return responseAndMetadata, nil } func (s *Solana) GetBlock( ctx context.Context, metadata capabilities.RequestMetadata, input *solcap.GetBlockRequest) (*capabilities.ResponseAndMetadata[*solcap.GetBlockReply], caperrors.Error) { - // TODO - return nil, GetError(errors.New("unimplemented"), false) + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetBlockRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetBlock request") + cReq := ctypes.NewECHashableRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetBlockReply, error) { + rawResponse, err := s.SolanaService.GetBlock(ctx, *request) + if err != nil { + return nil, err + } + + response, err := solcap.ConvertGetBlockReplyToProto(rawResponse) + if err != nil { + return nil, caperrors.NewPublicSystemError(fmt.Errorf("failed to convert response to proto: %w", err), caperrors.Internal) + } + + return response, nil + }) + responseAndMetadata, err := chainconsensus.ReadHashableRequestReport[*solcap.GetBlockReply](ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetBlock: %w", err)) + } + + lggr.Debugw("Successfully handled GetBlock") + return responseAndMetadata, nil } func (s *Solana) GetFeeForMessage( ctx context.Context, metadata capabilities.RequestMetadata, input *solcap.GetFeeForMessageRequest) (*capabilities.ResponseAndMetadata[*solcap.GetFeeForMessageReply], caperrors.Error) { - // TODO - return nil, GetError(errors.New("unimplemented"), false) + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetFeeForMessageRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetFeeForMessage request") + cReq := ctypes.NewAggregatableRequest(commonMon.RequestID(metadata.WorkflowExecutionID, metadata.ReferenceID), func(ctx context.Context) (*ctypes.AggregatableObservation, error) { + rawResponse, err := s.SolanaService.GetFeeForMessage(ctx, *request) + if err != nil { + return nil, err + } + + return &ctypes.AggregatableObservation{ + Method: ctypes.AggregationMethodFPlusOneHighest, + Value: &valuespb.Decimal{ + Coefficient: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(rawResponse.Fee)), + Exponent: 0, + }, + }, nil + }) + aggregatedFee, err := chainconsensus.ReadDecimal(ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetFeeForMessage: %w", err)) + } + + lggr.Debugw("Successfully handled GetFeeForMessage") + return &capabilities.ResponseAndMetadata[*solcap.GetFeeForMessageReply]{ + Response: &solcap.GetFeeForMessageReply{Fee: aggregatedFee.BigInt().Uint64()}, + ResponseMetadata: metering.GetResponseMetadata(metering.GetAccountInfo), + }, nil } func (s *Solana) GetMultipleAccountsWithOpts( ctx context.Context, metadata capabilities.RequestMetadata, input *solcap.GetMultipleAccountsWithOptsRequest) (*capabilities.ResponseAndMetadata[*solcap.GetMultipleAccountsWithOptsReply], caperrors.Error) { - return nil, GetError(errors.New("unimplemented"), false) + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetMultipleAccountsRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + request.IsExternal = true + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetMultipleAccountsWithOpts request") + cReq := ctypes.NewVolatileRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetMultipleAccountsWithOptsReply, uint64, error) { + rawResponse, err := s.SolanaService.GetMultipleAccountsWithOpts(ctx, *request) + if err != nil { + return nil, 0, err + } + + response, err := solcap.ConvertGetMultipleAccountsReplyToProto(rawResponse) + if err != nil { + return nil, 0, caperrors.NewPublicSystemError(fmt.Errorf("failed to convert response to proto: %w", err), caperrors.Internal) + } + + return response, rawResponse.Slot, nil + }, lggr) + responseAndMetadata, err := chainconsensus.ReadHashableRequestReport[*solcap.GetMultipleAccountsWithOptsReply](ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetMultipleAccountsWithOpts: %w", err)) + } + + lggr.Debugw("Successfully handled GetMultipleAccountsWithOpts") + return responseAndMetadata, nil } func (s *Solana) GetSignatureStatuses( ctx context.Context, metadata capabilities.RequestMetadata, input *solcap.GetSignatureStatusesRequest) (*capabilities.ResponseAndMetadata[*solcap.GetSignatureStatusesReply], caperrors.Error) { - return nil, GetError(errors.New("unimplemented"), false) + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetSignatureStatusesRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetSignatureStatuses request") + cReq := ctypes.NewECHashableRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetSignatureStatusesReply, error) { + rawResponse, err := s.SolanaService.GetSignatureStatuses(ctx, *request) + if err != nil { + return nil, err + } + + response, err := solcap.ConvertGetSignatureStatusesReplyToProto(rawResponse) + if err != nil { + return nil, caperrors.NewPublicSystemError(fmt.Errorf("failed to convert response to proto: %w", err), caperrors.Internal) + } + + return response, nil + }) + responseAndMetadata, err := chainconsensus.ReadHashableRequestReport[*solcap.GetSignatureStatusesReply](ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetSignatureStatuses: %w", err)) + } + + lggr.Debugw("Successfully handled GetSignatureStatuses") + return responseAndMetadata, nil } func (s *Solana) GetSlotHeight( ctx context.Context, metadata capabilities.RequestMetadata, input *solcap.GetSlotHeightRequest) (*capabilities.ResponseAndMetadata[*solcap.GetSlotHeightReply], caperrors.Error) { - return nil, GetError(errors.New("unimplemented"), false) + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetSlotHeightRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetSlotHeight request") + cReq := ctypes.NewAggregatableRequest(commonMon.RequestID(metadata.WorkflowExecutionID, metadata.ReferenceID), func(ctx context.Context) (*ctypes.AggregatableObservation, error) { + rawResponse, err := s.SolanaService.GetSlotHeight(ctx, request) + if err != nil { + return nil, err + } + + return &ctypes.AggregatableObservation{ + Method: ctypes.AggregationMethodFPlusOneHighest, + Value: &valuespb.Decimal{ + Coefficient: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(rawResponse.Height)), + Exponent: 0, + }, + }, nil + }) + aggregatedHeight, err := chainconsensus.ReadDecimal(ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetSlotHeight: %w", err)) + } + + lggr.Debugw("Successfully handled GetSlotHeight") + return &capabilities.ResponseAndMetadata[*solcap.GetSlotHeightReply]{ + Response: &solcap.GetSlotHeightReply{Height: aggregatedHeight.BigInt().Uint64()}, + ResponseMetadata: metering.GetResponseMetadata(metering.GetAccountInfo), + }, nil } func (s *Solana) GetTransaction( ctx context.Context, metadata capabilities.RequestMetadata, input *solcap.GetTransactionRequest) (*capabilities.ResponseAndMetadata[*solcap.GetTransactionReply], caperrors.Error) { - return nil, GetError(errors.New("unimplemented"), false) + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetTransactionRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + request.IsExternal = true + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetTransaction request") + cReq := ctypes.NewECHashableRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetTransactionReply, error) { + rawResponse, err := s.SolanaService.GetTransaction(ctx, request) + if err != nil { + return nil, err + } + + response, err := solcap.ConvertGetTransactionReplyToProto(rawResponse) + if err != nil { + return nil, caperrors.NewPublicSystemError(fmt.Errorf("failed to convert response to proto: %w", err), caperrors.Internal) + } + + return response, nil + }) + responseAndMetadata, err := chainconsensus.ReadHashableRequestReport[*solcap.GetTransactionReply](ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetTransaction: %w", err)) + } + + lggr.Debugw("Successfully handled GetTransaction") + return responseAndMetadata, nil +} + +func (s *Solana) GetProgramAccounts( + ctx context.Context, + metadata capabilities.RequestMetadata, + input *solcap.GetProgramAccountsRequest) (*capabilities.ResponseAndMetadata[*solcap.GetProgramAccountsReply], caperrors.Error) { + if !s.readsEnabled { + return nil, caperrors.NewPublicSystemError(errors.New("reads are not available"), caperrors.Internal) + } + request, err := solcap.ConvertGetProgramAccountsRequestFromProto(input) + if err != nil { + return nil, NewUserError(fmt.Errorf("invalid request: %w", err)) + } + request.IsExternal = true + lggr := s.messageBuilder.RequestLggr(s.lggr, monitoring.TelemetryContext{TsStart: time.Now().UnixMilli(), RequestMetadata: metadata}).With("request", request) + lggr.Debugw("Received GetProgramAccounts request") + cReq := ctypes.NewVolatileRequest(metadata.WorkflowExecutionID, metadata.ReferenceID, metering.GetResponseMetadata(metering.GetAccountInfo), func(ctx context.Context) (*solcap.GetProgramAccountsReply, uint64, error) { + rawResponse, err := s.SolanaService.GetProgramAccounts(ctx, *request) + if err != nil { + return nil, 0, err + } + + // getProgramAccounts does not guarantee ordering across RPC nodes. + // Sort by pubkey so all nodes produce an identical hash. + slices.SortFunc(rawResponse.Value, func(a, b *soltypes.KeyedAccount) int { + return bytes.Compare(a.Pubkey[:], b.Pubkey[:]) + }) + + response, err := solcap.ConvertGetProgramAccountsReplyToProto(rawResponse) + if err != nil { + return nil, 0, caperrors.NewPublicSystemError(fmt.Errorf("failed to convert response to proto: %w", err), caperrors.Internal) + } + + return response, 0, nil + }, lggr) + responseAndMetadata, err := chainconsensus.ReadHashableRequestReport[*solcap.GetProgramAccountsReply](ctx, s.handler, cReq) + if err != nil { + return nil, getReadError(lggr, fmt.Errorf("failed to GetProgramAccounts: %w", err)) + } + + lggr.Debugw("Successfully handled GetProgramAccounts") + return responseAndMetadata, nil +} + +func (s *Solana) MonitoringContext() capmon.MonitoringContext { + return capmon.MonitoringContext{ + Logger: s.lggr, + MetricsAttributes: s.messageBuilder.CapabilityMetricsAttributes, + } } func (s *Solana) initLimiters(limitsFactory limits.Factory) (err error) { @@ -203,5 +438,34 @@ func (s *Solana) initLimiters(limitsFactory limits.Factory) (err error) { return } +func getReadError(lggr logger.SugaredLogger, err error) caperrors.Error { + if err == nil { + return nil + } + + isUserErr := isUserError(err) + capErr := GetError(err, isUserErr) + + // TODO: logging of init, success and error should be move to a higher level + lggr = lggr.With("error", err) + const msg = "Read operation failed" + if isUserErr { + lggr.Debug(msg) + } else { + lggr.Error(msg) + } + + return capErr +} + +func isUserError(err error) bool { + return !errors.Is(err, context.DeadlineExceeded) && !isNodeInfraError(err) +} + +func isNodeInfraError(err error) bool { + return errors.Is(err, multinode.ErrNodeError) || + strings.Contains(err.Error(), multinode.ErrNodeError.Error()) +} + var GetError = capcommon.GetError var NewUserError = capcommon.NewUserError diff --git a/chain_capabilities/solana/actions/actions_test.go b/chain_capabilities/solana/actions/actions_test.go index 388e97e2d..a8730bb7c 100644 --- a/chain_capabilities/solana/actions/actions_test.go +++ b/chain_capabilities/solana/actions/actions_test.go @@ -167,7 +167,7 @@ func newMockedSolana(t *testing.T, readsEnabled bool) *mockedSolana { lggr := logger.Test(t) service := &Solana{ readsEnabled: readsEnabled, - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), beholderProcessor: NopBeholderProcessor{}, messageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), chainSelector: 1, @@ -239,3 +239,711 @@ func runVolatileHashableHandle(ctx context.Context, req ctypes.Request) (<-chan ch <- reply return ch, nil } + +// runECHashableHandle simulates OCR consensus for ECHashableRequest (EventuallyConsistent). +func runECHashableHandle(ctx context.Context, req ctypes.Request) (<-chan ctypes.Reply, error) { + observableRequest, ok := req.(ctypes.ObservableRequest) + if !ok { + return nil, fmt.Errorf("request is not an ObservableRequest") + } + _ = observableRequest.CaptureObservation(ctx) + observation, err := observableRequest.GetOCRObservation() + if err != nil { + return nil, fmt.Errorf("failed to get OCR observation: %w", err) + } + if observation == nil { + ch := make(chan ctypes.Reply, 1) + ch <- ctypes.Reply{Err: fmt.Errorf("no observation captured")} + return ch, nil + } + + var reply ctypes.Reply + switch tObs := observation.Observation.(type) { + case *ctypes.RequestObservation_Hashable: + hashBytes := tObs.Hashable + if len(hashBytes) != ctypes.HashLength { + return nil, fmt.Errorf("unexpected hash length: got %d, want %d", len(hashBytes), ctypes.HashLength) + } + var reportData ctypes.Hash + copy(reportData[:], hashBytes) + reply = ctypes.Reply{Value: ctypes.NewHashableRequestReport(ocrtypes.ConfigDigest{}, 0, reportData, nil)} + case *ctypes.RequestObservation_Error: + reply = ctypes.Reply{Err: ctypes.ObservationError(tObs.Error).Err()} + default: + return nil, fmt.Errorf("unexpected observation type: %T", observation.Observation) + } + + ch := make(chan ctypes.Reply, 1) + ch <- reply + return ch, nil +} + +// runAggregatableHandle simulates OCR consensus for AggregatableRequest (Volatile Aggregatable). +// It passes the observed Decimal value straight through as the "aggregated" result. +func runAggregatableHandle(ctx context.Context, req ctypes.Request) (<-chan ctypes.Reply, error) { + observableRequest, ok := req.(ctypes.ObservableRequest) + if !ok { + return nil, fmt.Errorf("request is not an ObservableRequest") + } + _ = observableRequest.CaptureObservation(ctx) + observation, err := observableRequest.GetOCRObservation() + if err != nil { + return nil, fmt.Errorf("failed to get OCR observation: %w", err) + } + if observation == nil { + ch := make(chan ctypes.Reply, 1) + ch <- ctypes.Reply{Err: fmt.Errorf("no observation captured")} + return ch, nil + } + + var reply ctypes.Reply + switch tObs := observation.Observation.(type) { + case *ctypes.RequestObservation_Aggregatable: + if tObs.Aggregatable == nil { + return nil, fmt.Errorf("nil aggregatable observation") + } + reply = ctypes.Reply{Value: tObs.Aggregatable.Value} + case *ctypes.RequestObservation_Error: + reply = ctypes.Reply{Err: ctypes.ObservationError(tObs.Error).Err()} + default: + return nil, fmt.Errorf("unexpected observation type: %T", observation.Observation) + } + + ch := make(chan ctypes.Reply, 1) + ch <- reply + return ch, nil +} + +// validSig returns a valid 64-byte signature slice. +func validSig() []byte { + sig := make([]byte, soltypes.SignatureLength) + for i := range sig { + sig[i] = byte(i + 1) + } + return sig +} + +// ---- GetBalance ---- + +func TestGetBalance(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + _, err := helper.solana.GetBalance(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBalanceRequest{Addr: solana.NewWallet().PublicKey().Bytes()}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid addr", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + _, err := helper.solana.GetBalance(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBalanceRequest{Addr: []byte{1, 2, 3}}) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + require.Contains(t, err.Error(), "invalid request") + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + helper.solanaService.EXPECT(). + GetBalance(mock.Anything, mock.MatchedBy(func(req soltypes.GetBalanceRequest) bool { + return req.Addr == soltypes.PublicKey(key.PublicKey()) + })). + Return(&soltypes.GetBalanceReply{Value: 1_000_000}, nil).Once() + + resp, err := helper.solana.GetBalance(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBalanceRequest{Addr: key.PublicKey().Bytes()}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, uint64(1_000_000), resp.Response.Value) + require.NotNil(t, resp.OCRAttestation) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + svcErr := errors.New("rpc down") + + helper.solanaService.EXPECT(). + GetBalance(mock.Anything, mock.Anything). + Return((*soltypes.GetBalanceReply)(nil), svcErr).Once() + + _, err = helper.solana.GetBalance(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBalanceRequest{Addr: key.PublicKey().Bytes()}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetMultipleAccountsWithOpts ---- + +func TestGetMultipleAccountsWithOpts(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + _, err := helper.solana.GetMultipleAccountsWithOpts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetMultipleAccountsWithOptsRequest{ + Accounts: [][]byte{solana.NewWallet().PublicKey().Bytes()}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid account key", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + _, err := helper.solana.GetMultipleAccountsWithOpts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetMultipleAccountsWithOptsRequest{ + Accounts: [][]byte{{1, 2, 3}}, + }) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + serviceReply := &soltypes.GetMultipleAccountsReply{ + RPCContext: soltypes.RPCContext{Slot: 77}, + Value: []*soltypes.Account{{Lamports: 500}}, + } + helper.solanaService.EXPECT(). + GetMultipleAccountsWithOpts(mock.Anything, mock.Anything). + Return(serviceReply, nil).Once() + + resp, err := helper.solana.GetMultipleAccountsWithOpts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetMultipleAccountsWithOptsRequest{ + Accounts: [][]byte{key.PublicKey().Bytes()}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Response.Value, 1) + require.NotNil(t, resp.OCRAttestation) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + svcErr := errors.New("rpc timeout") + + helper.solanaService.EXPECT(). + GetMultipleAccountsWithOpts(mock.Anything, mock.Anything). + Return((*soltypes.GetMultipleAccountsReply)(nil), svcErr).Once() + + _, err = helper.solana.GetMultipleAccountsWithOpts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetMultipleAccountsWithOptsRequest{ + Accounts: [][]byte{key.PublicKey().Bytes()}, + }) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetProgramAccounts ---- + +func TestGetProgramAccounts(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + _, err := helper.solana.GetProgramAccounts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetProgramAccountsRequest{Program: solana.NewWallet().PublicKey().Bytes()}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid program key", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + _, err := helper.solana.GetProgramAccounts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetProgramAccountsRequest{Program: []byte{1, 2, 3}}) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + require.Contains(t, err.Error(), "invalid request") + }) + + t.Run("nil filter entry", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + _, err = helper.solana.GetProgramAccounts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetProgramAccountsRequest{ + Program: key.PublicKey().Bytes(), + Opts: &solcap.GetProgramAccountsOpts{Filters: []*solcap.RPCFilter{nil}}, + }) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + serviceReply := &soltypes.GetProgramAccountsReply{ + Value: []*soltypes.KeyedAccount{ + {Pubkey: soltypes.PublicKey(key.PublicKey())}, + }, + } + helper.solanaService.EXPECT(). + GetProgramAccounts(mock.Anything, mock.MatchedBy(func(req soltypes.GetProgramAccountsRequest) bool { + return req.Program == soltypes.PublicKey(key.PublicKey()) + })). + Return(serviceReply, nil).Once() + + resp, err := helper.solana.GetProgramAccounts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetProgramAccountsRequest{Program: key.PublicKey().Bytes()}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Response.Value, 1) + require.NotNil(t, resp.OCRAttestation) + }) + + t.Run("accounts sorted by pubkey for deterministic hashing", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + program, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + // Build three keys whose raw bytes are deliberately out of order. + highKey, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + lowKey, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + midKey, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + // Force a deterministic ordering: low < mid < high by assigning first byte. + var low, mid, high soltypes.PublicKey + lowPK, midPK, highPK := lowKey.PublicKey(), midKey.PublicKey(), highKey.PublicKey() + low[0], mid[0], high[0] = 0x10, 0x50, 0xFF + copy(low[1:], lowPK[1:]) + copy(mid[1:], midPK[1:]) + copy(high[1:], highPK[1:]) + + // RPC returns accounts in reverse order (high → mid → low). + serviceReply := &soltypes.GetProgramAccountsReply{ + Value: []*soltypes.KeyedAccount{ + {Pubkey: high}, + {Pubkey: mid}, + {Pubkey: low}, + }, + } + helper.solanaService.EXPECT(). + GetProgramAccounts(mock.Anything, mock.Anything). + Return(serviceReply, nil).Once() + + resp, err := helper.solana.GetProgramAccounts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetProgramAccountsRequest{Program: program.PublicKey().Bytes()}) + require.NoError(t, err) + require.Len(t, resp.Response.Value, 3) + + // After sorting the response must be low → mid → high. + require.Equal(t, low[:], resp.Response.Value[0].Pubkey) + require.Equal(t, mid[:], resp.Response.Value[1].Pubkey) + require.Equal(t, high[:], resp.Response.Value[2].Pubkey) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + svcErr := errors.New("rpc unavailable") + + helper.solanaService.EXPECT(). + GetProgramAccounts(mock.Anything, mock.Anything). + Return((*soltypes.GetProgramAccountsReply)(nil), svcErr).Once() + + _, err = helper.solana.GetProgramAccounts(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetProgramAccountsRequest{Program: key.PublicKey().Bytes()}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetTransaction ---- + +func TestGetTransaction(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + _, err := helper.solana.GetTransaction(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetTransactionRequest{Signature: validSig()}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid signature", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + _, err := helper.solana.GetTransaction(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetTransactionRequest{Signature: []byte{1, 2, 3}}) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + + serviceReply := &soltypes.GetTransactionReply{Slot: 99} + helper.solanaService.EXPECT(). + GetTransaction(mock.Anything, mock.Anything). + Return(serviceReply, nil).Once() + + resp, err := helper.solana.GetTransaction(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetTransactionRequest{Signature: validSig()}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, uint64(99), resp.Response.Slot) + require.NotNil(t, resp.OCRAttestation) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + svcErr := errors.New("tx not found") + + helper.solanaService.EXPECT(). + GetTransaction(mock.Anything, mock.Anything). + Return((*soltypes.GetTransactionReply)(nil), svcErr).Once() + + _, err := helper.solana.GetTransaction(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetTransactionRequest{Signature: validSig()}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetSignatureStatuses ---- + +func TestGetSignatureStatuses(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + _, err := helper.solana.GetSignatureStatuses(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSignatureStatusesRequest{Sigs: [][]byte{validSig()}}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid signature", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + _, err := helper.solana.GetSignatureStatuses(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSignatureStatusesRequest{Sigs: [][]byte{{1, 2, 3}}}) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + + conf := uint64(10) + serviceReply := &soltypes.GetSignatureStatusesReply{ + Results: []soltypes.GetSignatureStatusesResult{ + {Slot: 42, Confirmations: &conf, ConfirmationStatus: soltypes.ConfirmationStatusConfirmed}, + }, + } + helper.solanaService.EXPECT(). + GetSignatureStatuses(mock.Anything, mock.Anything). + Return(serviceReply, nil).Once() + + resp, err := helper.solana.GetSignatureStatuses(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSignatureStatusesRequest{Sigs: [][]byte{validSig()}}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Response.Results, 1) + require.Equal(t, uint64(42), resp.Response.Results[0].Slot) + require.NotNil(t, resp.OCRAttestation) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + svcErr := errors.New("sig lookup failed") + + helper.solanaService.EXPECT(). + GetSignatureStatuses(mock.Anything, mock.Anything). + Return((*soltypes.GetSignatureStatusesReply)(nil), svcErr).Once() + + _, err := helper.solana.GetSignatureStatuses(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSignatureStatusesRequest{Sigs: [][]byte{validSig()}}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetBlock ---- + +func TestGetBlock(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + _, err := helper.solana.GetBlock(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBlockRequest{Slot: 1}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid commitment", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + _, err := helper.solana.GetBlock(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBlockRequest{ + Slot: 1, + Opts: &solcap.GetBlockOpts{Commitment: solcap.CommitmentType(999)}, + }) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + + serviceReply := &soltypes.GetBlockReply{ParentSlot: 41} + helper.solanaService.EXPECT(). + GetBlock(mock.Anything, mock.MatchedBy(func(req soltypes.GetBlockRequest) bool { + return req.Slot == 42 + })). + Return(serviceReply, nil).Once() + + resp, err := helper.solana.GetBlock(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBlockRequest{Slot: 42}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, uint64(41), resp.Response.ParentSlot) + require.NotNil(t, resp.OCRAttestation) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runECHashableHandle} + svcErr := errors.New("block not available") + + helper.solanaService.EXPECT(). + GetBlock(mock.Anything, mock.Anything). + Return((*soltypes.GetBlockReply)(nil), svcErr).Once() + + _, err := helper.solana.GetBlock(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetBlockRequest{Slot: 42}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetSlotHeight ---- + +func TestGetSlotHeight(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + _, err := helper.solana.GetSlotHeight(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSlotHeightRequest{}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid commitment", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + _, err := helper.solana.GetSlotHeight(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSlotHeightRequest{Commitment: solcap.CommitmentType(999)}) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + + const height uint64 = 500_000 + helper.solanaService.EXPECT(). + GetSlotHeight(mock.Anything, mock.Anything). + Return(&soltypes.GetSlotHeightReply{Height: height}, nil).Once() + + resp, err := helper.solana.GetSlotHeight(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSlotHeightRequest{Commitment: solcap.CommitmentType_COMMITMENT_TYPE_CONFIRMED}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, height, resp.Response.Height) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + svcErr := errors.New("slot unavailable") + + helper.solanaService.EXPECT(). + GetSlotHeight(mock.Anything, mock.Anything). + Return((*soltypes.GetSlotHeightReply)(nil), svcErr).Once() + + _, err := helper.solana.GetSlotHeight(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetSlotHeightRequest{}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} + +// ---- GetFeeForMessage ---- + +func TestGetFeeForMessage(t *testing.T) { + t.Parallel() + + t.Run("reads disabled", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, false) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + _, err := helper.solana.GetFeeForMessage(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetFeeForMessageRequest{Message: "someMsg"}) + require.Error(t, err) + require.Contains(t, err.Error(), "reads are not available") + }) + + t.Run("invalid commitment", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + _, err := helper.solana.GetFeeForMessage(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetFeeForMessageRequest{ + Message: "someMsg", + Commitment: solcap.CommitmentType(999), + }) + require.Error(t, err) + var capErr caperrors.Error + require.True(t, errors.As(err, &capErr)) + require.Equal(t, caperrors.OriginUser, capErr.Origin()) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + + const fee uint64 = 5000 + helper.solanaService.EXPECT(). + GetFeeForMessage(mock.Anything, mock.MatchedBy(func(req soltypes.GetFeeForMessageRequest) bool { + return req.Message == "someMsg" + })). + Return(&soltypes.GetFeeForMessageReply{Fee: fee}, nil).Once() + + resp, err := helper.solana.GetFeeForMessage(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetFeeForMessageRequest{Message: "someMsg"}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, fee, resp.Response.Fee) + }) + + t.Run("service error", func(t *testing.T) { + t.Parallel() + helper := newMockedSolana(t, true) + helper.solana.handler = testConsensusHandler{handle: runAggregatableHandle} + svcErr := errors.New("fee estimation failed") + + helper.solanaService.EXPECT(). + GetFeeForMessage(mock.Anything, mock.Anything). + Return((*soltypes.GetFeeForMessageReply)(nil), svcErr).Once() + + _, err := helper.solana.GetFeeForMessage(t.Context(), capabilities.RequestMetadata{ + WorkflowExecutionID: "weid", ReferenceID: "ref", + }, &solcap.GetFeeForMessageRequest{Message: "someMsg"}) + require.Error(t, err) + require.ErrorContains(t, err, svcErr.Error()) + }) +} diff --git a/chain_capabilities/solana/actions/write_report_test.go b/chain_capabilities/solana/actions/write_report_test.go index 597120d55..3899fb7d4 100644 --- a/chain_capabilities/solana/actions/write_report_test.go +++ b/chain_capabilities/solana/actions/write_report_test.go @@ -324,7 +324,7 @@ func createMocksAndCapability(t *testing.T, lggr logger.Logger) *testHelper { mockTrInfo := NewTransmissionInfoProvider_mock(t) mockClient := NewCREForwarderClient_mock(t) service := &Solana{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), forwarderClient: mockClient, transmissionInfoProvider: mockTrInfo, beholderProcessor: NopBeholderProcessor{}, @@ -736,7 +736,7 @@ func TestGetFee(t *testing.T) { mockSolanaService := mocks.NewSolanaService(t) wr := &WriteReport{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), lggr: testLogger, } @@ -756,7 +756,7 @@ func TestGetFee(t *testing.T) { mockSolanaService := mocks.NewSolanaService(t) wr := &WriteReport{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), lggr: testLogger, } @@ -776,7 +776,7 @@ func TestGetFee(t *testing.T) { mockSolanaService := mocks.NewSolanaService(t) wr := &WriteReport{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), lggr: testLogger, } diff --git a/chain_capabilities/solana/go.mod b/chain_capabilities/solana/go.mod index 5b80fb145..dd91e904c 100644 --- a/chain_capabilities/solana/go.mod +++ b/chain_capabilities/solana/go.mod @@ -6,13 +6,13 @@ require ( github.com/gagliardetto/solana-go v1.14.0 github.com/google/go-cmp v0.7.0 github.com/mr-tron/base58 v1.2.0 - github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260602135542-db372a4c73d4 - github.com/smartcontractkit/capabilities/libs v0.0.0-20260602135542-db372a4c73d4 + github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260601161303-0b4b1ac1c3fb + github.com/smartcontractkit/capabilities/libs v0.0.0-20260604135015-e9711af26f89 github.com/smartcontractkit/chain-selectors v1.0.100 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260602135221-cc7a5b50532a - github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260326180413-c69f27e37a13 - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 - github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260420191419-ea62f88cbdb4 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260618045055-d385247612ec + github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260521164805-26d78d5e1243 + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f + github.com/smartcontractkit/chainlink-solana v1.3.1-0.20260605150919-6d1f761cc654 github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260421131224-c46cbfe7bc6c github.com/smartcontractkit/libocr v0.0.0-20260403184524-b6409238958d github.com/stretchr/testify v1.11.1 @@ -132,7 +132,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 // indirect - github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20251210101658-1c5c8e4c4f15 // indirect + github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260521164805-26d78d5e1243 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad // indirect diff --git a/chain_capabilities/solana/go.sum b/chain_capabilities/solana/go.sum index 544bc7dfd..56aedf7b7 100644 --- a/chain_capabilities/solana/go.sum +++ b/chain_capabilities/solana/go.sum @@ -617,10 +617,10 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260602135542-db372a4c73d4 h1:3yqrDt7eG0q09XwEsRBCDzsFZ0c2z5xYUFVfmLBi8MA= -github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260602135542-db372a4c73d4/go.mod h1:PvKEGpgXj36MV0zvAiPFbmWuITuiInug+ygVMJ+JsWo= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260602135542-db372a4c73d4 h1:yS513wsePMBCVGlsMoyKb/ilLtOqgq4Md+xDXaKNoRQ= -github.com/smartcontractkit/capabilities/libs v0.0.0-20260602135542-db372a4c73d4/go.mod h1:LS7F8U2YZNc0Vt8f6SVWUUigGLxdxZMpyC7VCcUTagg= +github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260601161303-0b4b1ac1c3fb h1:rS8SazTuMNU2dX8D3lPw8dBUIl1nZdcfojIQsBxeC68= +github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260601161303-0b4b1ac1c3fb/go.mod h1:bdh6ANWBJiDEC8tp66ZyLxdfTJSdb00Bgu38Fb+nWyE= +github.com/smartcontractkit/capabilities/libs v0.0.0-20260604135015-e9711af26f89 h1:sff0gl1oh8iz+ANmXUpMXyEzF/Q8jYGchsug/FpFG/A= +github.com/smartcontractkit/capabilities/libs v0.0.0-20260604135015-e9711af26f89/go.mod h1:LS7F8U2YZNc0Vt8f6SVWUUigGLxdxZMpyC7VCcUTagg= github.com/smartcontractkit/chain-selectors v1.0.100 h1:wpiSpmI/eFjY+wx/nPr5VuNF4hki0prIBMKEaQWn3g4= github.com/smartcontractkit/chain-selectors v1.0.100/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= @@ -629,28 +629,28 @@ github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288/go.mod h1:jPHlo/IN2YAArI001JJixmm6ZHQwgnAVJXY8VBFiFTc= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 h1:eEjTgIQn4RW0ZPRepUDYTdgGwaRCMawMwgXkHItUc9U= github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288/go.mod h1:67YbnoglYD61Pz/jTVCgav9wFq7S35OU8UyQSvPllRw= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260602135221-cc7a5b50532a h1:DHdGiDkMe+WS2e+Cy6ooNxGVX4gwiRRcgperFWmR4R4= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260602135221-cc7a5b50532a/go.mod h1:6jgqiFXFJHqjkvFFmuf8gvoUFa6Ygx/D1tKnIL+CCF8= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260618045055-d385247612ec h1:YAFL8c7TN5choX7jf66wrr5FLr0n8hKJ/OyafNGSnrc= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260618045055-d385247612ec/go.mod h1:fP9RqD25/gTx3XqRstN8o4lAI3jp42vwJBLRZwRoOOM= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 h1:NExKM/D0HneOq/N5LGTbkV4VOa0UHCvfTNEb4GqYpto= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= github.com/smartcontractkit/chainlink-common/pkg/monitoring v0.0.0-20251215152504-b1e41f508340 h1:PsjEI+5jZIz9AS4eOsLS5VpSWJINf38clXV3wryPyMk= github.com/smartcontractkit/chainlink-common/pkg/monitoring v0.0.0-20251215152504-b1e41f508340/go.mod h1:P/0OSXUlFaxxD4B/P6HWbxYtIRmmWGDJAvanq19879c= -github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20251210101658-1c5c8e4c4f15 h1:IXF7+k8I1YY/yvXC1wnS3FAAggtCy6ByEQ9hv/F2FvQ= -github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20251210101658-1c5c8e4c4f15/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4= -github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260326180413-c69f27e37a13 h1:3KLLkTCIAy9CvT35Ey0k6pcWX/u+qsm3Y/58TI5VSAg= -github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260326180413-c69f27e37a13/go.mod h1:Y7h84PqCe/Vimf2h1Nc6tMiOJStDbtM33fEUeaaF5xk= +github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260521164805-26d78d5e1243 h1:vaFBupfFfImQgqOeuC7Muk2GflbYP6Gpi0Y/TLroFU8= +github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260521164805-26d78d5e1243/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4= +github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260521164805-26d78d5e1243 h1:71PGTkjdFZ0JrloEC2Fs8eHl1b1gmUuH+bq7q23usKk= +github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260521164805-26d78d5e1243/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4/go.mod h1:HHGeDUpAsPa0pmOx7wrByCitjQ0mbUxf0R9v+g67uCA= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 h1:iljEJss3WOwcsMkWy72Yn2zvjw7Gyxc+RXL7r8YKM6g= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f h1:t+OoYaXLdH0WHK2pbWKjTSnSQa5JBQD1+gf0yISYfQk= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 h1:oli+2uLU6jcrJGCuYFqk3475hiwL17SWlITWLv+tx/w= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785/go.mod h1:dkR2uYg9XYJuT1JASkPzWE51jjFkVb86P7a/yXe5/GM= github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 h1:LQy2j2+TdKLSWsUTUYuqmQPn8kjqCLjGI3ZJYGtDc08= github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9/go.mod h1:GTpDgyK0OObf7jpch6p8N281KxN92wbB8serZhU9yRc= -github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260420191419-ea62f88cbdb4 h1:WaQzRyMCmi7gddZraPtHSqCjZ5dnVYFYXqEtq2DsQjw= -github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260420191419-ea62f88cbdb4/go.mod h1:sUsEwLtVPBlz0wPcysaolS+HVj9cOAt4jYhwE6J8dXg= +github.com/smartcontractkit/chainlink-solana v1.3.1-0.20260605150919-6d1f761cc654 h1:uPWmfISAfBB59gJ/refKlB5FxatF4xk0rKUSW0q+754= +github.com/smartcontractkit/chainlink-solana v1.3.1-0.20260605150919-6d1f761cc654/go.mod h1:JPSK+7ejSuLnxwrJpywm9umUQKXuWm35uk3NIbwFSs0= github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260421131224-c46cbfe7bc6c h1:Hn/80PyYFrQhRlNSaq9HY4cjc/7AuP9zyWLle22t34A= github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260421131224-c46cbfe7bc6c/go.mod h1:C5pZsbYX3qkhZTYWr1aYJi9QMfonFAun+Jl1npQ7UJA= github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad h1:lgHxTHuzJIF3Vj6LSMOnjhqKgRqYW+0MV2SExtCYL1Q= diff --git a/chain_capabilities/solana/main.go b/chain_capabilities/solana/main.go index 290b8465f..e4c1a48e2 100644 --- a/chain_capabilities/solana/main.go +++ b/chain_capabilities/solana/main.go @@ -35,6 +35,7 @@ import ( caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana" solcapserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana/server" + capmon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/monitoring" ) const ( @@ -67,7 +68,7 @@ var _ solcapserver.ClientCapability = &capabilityGRPCService{} func main() { loopserver.ServeNew(CapabilityName, func(s *loop.Server) loop.StandardCapabilities { return solcapserver.NewClientServer(&capabilityGRPCService{lggr: s.Logger.Named(CapabilityName), limitsFactory: s.LimitsFactory}) - }) + }, loop.WithOtelViews(append(consMetrics.MetricViews(), capmon.MetricViews()...))) } func (c *capabilityGRPCService) ChainSelector() uint64 { @@ -125,6 +126,7 @@ func (c *capabilityGRPCService) Description() string { func (c *capabilityGRPCService) Ready() error { return nil } + func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies core.StandardCapabilitiesDependencies) error { c.lggr.Infof("Initialising %s", CapabilityName) @@ -150,10 +152,17 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor } c.id = "solana" + ":ChainSelector:" + strconv.FormatUint(c.chainSelector, 10) + "@1.0.0" + c.CapabilityInfo = capabilities.CapabilityInfo{ + ID: c.id, + CapabilityType: capabilities.CapabilityTypeCombined, + Description: c.Description(), + IsLocal: cfg.IsLocal, + } var chainInfo types.ChainInfo - // protection for e2e tests when we run against local validator - if !cfg.IsLocal { + if cfg.IsLocal { + chainInfo = localChainInfo(cfg, c.chainSelector) + } else { chainInfo, err = relayer.GetChainInfo(ctx) if err != nil { return fmt.Errorf("failed to fetch chain info for chainID %s from relayer: %w", cfg.ChainID, err) @@ -264,6 +273,29 @@ func (s *capabilityGRPCService) setSelector(cfg *config.Config) error { return nil } +// localChainInfo builds monitoring labels for local CRE runs where the relayer +// cannot resolve chain metadata from a fixed genesis hash. +func localChainInfo(cfg *config.Config, chainSelector uint64) types.ChainInfo { + chainID := cfg.ChainID + if chainID == "" { + chainID = strconv.FormatUint(chainSelector, 10) + } + + networkName := cfg.Network + if networkName == "" { + networkName = "local" + } + + networkNameFull := networkName + "-local" + + return types.ChainInfo{ + FamilyName: "solana", + ChainID: chainID, + NetworkName: networkName, + NetworkNameFull: networkNameFull, + } +} + func (c *capabilityGRPCService) unmarshalConfig(configStr string) (*config.Config, error) { var cfg config.Config if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { diff --git a/chain_capabilities/solana/monitoring/messages.go b/chain_capabilities/solana/monitoring/messages.go index fe1fb1b3a..772754155 100644 --- a/chain_capabilities/solana/monitoring/messages.go +++ b/chain_capabilities/solana/monitoring/messages.go @@ -12,6 +12,7 @@ import ( "github.com/mr-tron/base58" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + capmon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/monitoring" solanacappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana" "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/types/chains/solana" @@ -36,6 +37,18 @@ func NewMessageBuilder(chainInfo types.ChainInfo, capInfo capabilities.Capabilit } } +// CapabilityMetricsAttributes returns capability-scoped OTel labels for v2 action metrics. +func (m *MessageBuilder) CapabilityMetricsAttributes() []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String(capmon.LabelChainFamilyName, capmon.ValOrUnknown(m.ChainInfo.FamilyName)), + attribute.String(capmon.LabelChainID, capmon.ValOrUnknown(m.ChainInfo.ChainID)), + attribute.String(capmon.LabelNetworkName, capmon.ValOrUnknown(m.ChainInfo.NetworkName)), + attribute.String(capmon.LabelNetworkNameFull, capmon.ValOrUnknown(m.ChainInfo.NetworkNameFull)), + attribute.String(capmon.LabelCapabilityType, capmon.ValOrUnknown(string(m.CapInfo.CapabilityType))), + attribute.String(capmon.LabelCapabilityID, capmon.ValOrUnknown(m.CapInfo.ID)), + } +} + func (m *MessageBuilder) BuildWriteReportTxFeeCalculationError(tc TelemetryContext, req *solcap.WriteReportRequest, signature solgo.Signature, cause string) ErrorMessage { summary := "Failed to calculate transaction fee" if !signature.IsZero() { diff --git a/chain_capabilities/solana/trigger/trigger_test.go b/chain_capabilities/solana/trigger/trigger_test.go index 31fa3f02c..178c307de 100644 --- a/chain_capabilities/solana/trigger/trigger_test.go +++ b/chain_capabilities/solana/trigger/trigger_test.go @@ -118,7 +118,7 @@ func setupTest(t *testing.T) (*SolanaLogTriggerService, *mocks.SolanaService) { lggr := logger.Test(t) opts := LogTriggerServiceOpts{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), Logger: lggr, BeholderProcessor: NopBeholderProcessor{}, MessageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), @@ -624,7 +624,7 @@ func TestStartPolling(t *testing.T) { store := NewSolanaLogTriggerStore() opts := LogTriggerServiceOpts{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), Logger: logger.Nop(), BeholderProcessor: NopBeholderProcessor{}, MessageBuilder: monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""), @@ -733,7 +733,7 @@ func TestStartPolling(t *testing.T) { // Create service with very small buffer opts := LogTriggerServiceOpts{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), Logger: logger.Test(t), Triggers: store, LogTriggerPollInterval: 1 * time.Millisecond, @@ -786,7 +786,7 @@ func TestStartPolling(t *testing.T) { store := NewSolanaLogTriggerStore() opts := LogTriggerServiceOpts{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), Logger: logger.Test(t), Triggers: store, LogTriggerPollInterval: 10 * time.Millisecond, @@ -844,7 +844,7 @@ func TestStartPolling(t *testing.T) { store := NewSolanaLogTriggerStore() opts := LogTriggerServiceOpts{ - SolanaService: mockSolanaService, + SolanaService: mocks.WrapSolanaService(mockSolanaService), Logger: logger.Test(t), Triggers: store, LogTriggerPollInterval: 5 * time.Millisecond, @@ -1125,11 +1125,12 @@ func TestSolanaLogTriggerService_NewLogTriggerService(t *testing.T) { t.Run("respects provided values", func(t *testing.T) { mockService := mocks.NewSolanaService(t) + wrappedService := mocks.WrapSolanaService(mockService) store := NewSolanaLogTriggerStore() lggr := logger.Test(t) opts := LogTriggerServiceOpts{ - SolanaService: mockService, + SolanaService: wrappedService, Logger: lggr, Triggers: store, LogTriggerPollInterval: 5 * time.Second, @@ -1144,7 +1145,7 @@ func TestSolanaLogTriggerService_NewLogTriggerService(t *testing.T) { require.NoError(t, err) require.NotNil(t, service) - assert.Equal(t, mockService, service.SolanaService) + assert.Equal(t, wrappedService, service.SolanaService) assert.Equal(t, store, service.triggers) assert.Equal(t, 5*time.Second, service.logTriggerPollInterval) assert.Equal(t, uint64(2000), service.logTriggerSendChannelBufferSize) diff --git a/integration_tests/go.mod b/integration_tests/go.mod index 584a95c78..c3d5818e5 100644 --- a/integration_tests/go.mod +++ b/integration_tests/go.mod @@ -26,10 +26,10 @@ require ( github.com/smartcontractkit/capabilities/http_trigger v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/capabilities/loadtestwritetarget v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chain-selectors v1.0.101 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260603103051-071d30a40591 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260604234426-27a72f7f8cf8 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260512150409-b4068bf735e6 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260602131523-5168ac1ba014 + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260528173149-f5b8336b19d9 github.com/smartcontractkit/chainlink/v2 v2.29.1-cre-beta.0.0.20260512171733-704bf3201286 github.com/smartcontractkit/cre-sdk-go v1.5.0 diff --git a/integration_tests/go.sum b/integration_tests/go.sum index f982f85a0..43bf6270b 100644 --- a/integration_tests/go.sum +++ b/integration_tests/go.sum @@ -1162,8 +1162,8 @@ github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc/go.mod h1:67YbnoglYD61Pz/jTVCgav9wFq7S35OU8UyQSvPllRw= github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd h1:IMopuENFVS63AerRELdfWo6o60UNUidcldJOxJLmk24= github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd/go.mod h1:SBN8Urnh5sQvrQRbSo1Nr8coWatHg8LZoPw3R/42sho= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260603103051-071d30a40591 h1:KQHgXMoNJcSLesRwVc2gJ2Wmc/AbUv3lcdDrXKd+4Eo= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260603103051-071d30a40591/go.mod h1:zC5csAXmmn2FZbZ78Rrfc4AvmEKJzKQLMEY/w2SRwDo= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260604234426-27a72f7f8cf8 h1:pm4xOepOj4uGYKqzVJxIi3MnLleYKTuLcxBNW51s9jI= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260604234426-27a72f7f8cf8/go.mod h1:fP9RqD25/gTx3XqRstN8o4lAI3jp42vwJBLRZwRoOOM= github.com/smartcontractkit/chainlink-common/keystore v1.1.1-0.20260529092756-a94bc8ce96d6 h1:fWsYxxj35fp1/6YZngoTsOTMLqDie4N5X0osAOdhUTE= github.com/smartcontractkit/chainlink-common/keystore v1.1.1-0.20260529092756-a94bc8ce96d6/go.mod h1:6JexOOhPhknQ0QMuppFIlOpm6wCp54yZMxai+tWugwY= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260601211238-9f526774fef0 h1:NExKM/D0HneOq/N5LGTbkV4VOa0UHCvfTNEb4GqYpto= @@ -1196,8 +1196,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0. github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:ATjAPIVJibHRcIfiG47rEQkUIOoYa6KDvWj3zwCAw6g= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d h1:AJy55QJ/pBhXkZjc7N+ATnWfxrcjq9BI9DmdtdjwDUQ= github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260602131523-5168ac1ba014 h1:4rxcbbe1qe1yR+HcclvOi/e0CFLcBLfx2fgiWxBMMZ4= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260602131523-5168ac1ba014/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f h1:t+OoYaXLdH0WHK2pbWKjTSnSQa5JBQD1+gf0yISYfQk= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260604171908-6734db2d444f/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0= github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=