Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion multinode/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/prometheus/client_model v0.6.2
github.com/smartcontractkit/chainlink-common v0.10.1-0.20260305114348-b8bbac30bfc7
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20250717121125-2350c82883e2
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260326180413-c69f27e37a13
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1
)
Expand Down
4 changes: 2 additions & 2 deletions multinode/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ github.com/smartcontractkit/chainlink-common v0.10.1-0.20260305114348-b8bbac30bf
github.com/smartcontractkit/chainlink-common v0.10.1-0.20260305114348-b8bbac30bfc7/go.mod h1:0ghbAr7tRO0tT5ZqBXhOyzgUO37tNNe33Yn0hskauVM=
github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg=
github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY=
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20250717121125-2350c82883e2 h1:ysZjKH+BpWlQhF93kr/Lc668UlCvT9NjfcsGdZT19I8=
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20250717121125-2350c82883e2/go.mod h1:jo+cUqNcHwN8IF7SInQNXDZ8qzBsyMpnLdYbDswviFc=
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260326180413-c69f27e37a13 h1:Homq1KxVUoL1rEtEv1N+BL0JJdMdQcDBnJw53vn+/qY=
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260326180413-c69f27e37a13/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e h1:Hv9Mww35LrufCdM9wtS9yVi/rEWGI1UnjHbcKKU0nVY=
github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e/go.mod h1:T4zH9R8R8lVWKfU7tUvYz2o2jMv1OpGCdpY2j2QZXzU=
github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d h1:LokA9PoCNb8mm8mDT52c3RECPMRsGz1eCQORq+J3n74=
Expand Down
47 changes: 44 additions & 3 deletions multinode/rpc_client_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,27 @@ import (

"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/services"
frameworkmetrics "github.com/smartcontractkit/chainlink-framework/metrics"
)

var errInvalidHead = errors.New("invalid head")

const (
rpcCallNameLatestBlock = "latest_block"
rpcCallNameLatestFinalizedBlock = "latest_finalized_block"
)

type RPCClientBaseConfig interface {
NewHeadsPollInterval() time.Duration
FinalizedBlockPollInterval() time.Duration
}

type RPCClientBaseMetricsConfig struct {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It doesn't make sense IMO to create a config that contains a RPCClientMetrics which the caller needs to construct with a RPCClientMetricsConfig that itself contains the chainFamily and ID.

I think it is a worthy change to have the caller call an interface instead of the metric directly so we can take care of things like moving from promauto to beholder and other details, but we should have the rpc_client care about it the least possible amount, and that'd be simply providing only the necessary config once.

So, if the URL and SendOnly are constant throughout the lifetime of the client, they can be part of RPCClientMetricsConfig, and that's it.

RPCClientMetrics frameworkmetrics.RPCClientMetrics
RPCURL string
IsSendOnly bool
}

// RPCClientBase is used to integrate multinode into chain-specific clients.
// For new MultiNode integrations, we wrap the RPC client and inherit from the RPCClientBase
// to get the required RPCClient methods and enable the use of MultiNode.
Expand Down Expand Up @@ -46,14 +60,19 @@ type RPCClientBase[HEAD Head] struct {
highestUserObservations ChainInfo
// most recent chain info observed during current lifecycle
latestChainInfo ChainInfo

rpcMetrics frameworkmetrics.RPCClientMetrics
rpcURL string
isSendOnly bool
}

func NewRPCClientBase[HEAD Head](
cfg RPCClientBaseConfig, ctxTimeout time.Duration, log logger.Logger,
latestBlock func(ctx context.Context) (HEAD, error),
latestFinalizedBlock func(ctx context.Context) (HEAD, error),
rpcMetrics *RPCClientBaseMetricsConfig,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Here I think it makes more sense to accept an already RPCClientMetrics. The caller might (should?) itself want to call RecordRequest in other chain-specific places.

) *RPCClientBase[HEAD] {
return &RPCClientBase[HEAD]{
base := &RPCClientBase[HEAD]{
cfg: cfg,
log: log,
ctxTimeout: ctxTimeout,
Expand All @@ -62,6 +81,12 @@ func NewRPCClientBase[HEAD Head](
subs: make(map[Subscription]struct{}),
lifeCycleCh: make(chan struct{}),
}
if rpcMetrics != nil {
base.rpcMetrics = rpcMetrics.RPCClientMetrics
base.rpcURL = rpcMetrics.RPCURL
base.isSendOnly = rpcMetrics.IsSendOnly
}
return base
}

func (m *RPCClientBase[HEAD]) lenSubs() int {
Expand Down Expand Up @@ -155,37 +180,53 @@ func (m *RPCClientBase[HEAD]) LatestBlock(ctx context.Context) (HEAD, error) {
// capture lifeCycleCh to ensure we are not updating chainInfo with observations related to previous life cycle
ctx, cancel, lifeCycleCh := m.AcquireQueryCtx(ctx, m.ctxTimeout)
defer cancel()
start := time.Now()

head, err := m.latestBlock(ctx)
if err != nil {
m.recordRPCRequest(ctx, rpcCallNameLatestBlock, start, err)
return head, err
}

if !head.IsValid() {
return head, errors.New("invalid head")
m.recordRPCRequest(ctx, rpcCallNameLatestBlock, start, errInvalidHead)
return head, errInvalidHead
}

m.recordRPCRequest(ctx, rpcCallNameLatestBlock, start, nil)
m.OnNewHead(ctx, lifeCycleCh, head)
return head, nil
}

func (m *RPCClientBase[HEAD]) LatestFinalizedBlock(ctx context.Context) (HEAD, error) {
ctx, cancel, lifeCycleCh := m.AcquireQueryCtx(ctx, m.ctxTimeout)
defer cancel()
start := time.Now()

head, err := m.latestFinalizedBlock(ctx)
if err != nil {
m.recordRPCRequest(ctx, rpcCallNameLatestFinalizedBlock, start, err)
return head, err
}

if !head.IsValid() {
return head, errors.New("invalid head")
m.recordRPCRequest(ctx, rpcCallNameLatestFinalizedBlock, start, errInvalidHead)
return head, errInvalidHead
}

m.recordRPCRequest(ctx, rpcCallNameLatestFinalizedBlock, start, nil)
m.OnNewFinalizedHead(ctx, lifeCycleCh, head)
return head, nil
}

func (m *RPCClientBase[HEAD]) recordRPCRequest(ctx context.Context, callName string, startedAt time.Time, err error) {
if m.rpcMetrics == nil {
return
}

m.rpcMetrics.RecordRequest(ctx, m.rpcURL, m.isSendOnly, callName, time.Since(startedAt), err)
}

func (m *RPCClientBase[HEAD]) OnNewHead(ctx context.Context, requestCh <-chan struct{}, head HEAD) {
if !head.IsValid() {
return
Expand Down
Loading
Loading