Skip to content

Commit 9d54e0b

Browse files
add configurable historical balance health check for finalized block … (#352)
* add configurable historical balance health check for finalized block support * added fix for build issues * Make CheckFinalizedStateAvailability public for multinode polling, Accept probeAddress parameter, fall back to EVM config if empty. * Address review feedback for finalized state check * added default to fallback.toml * fix: remove HistoricalBalanceCheckEnabled flag, let multinode control polling * chore: bump chainlink-framework/multinode to PR commit version * chore: regenerate CONFIG.md with finalized state check fields * fix: validate FinalizedStateUnavailable config on startup * Update pkg/client/rpc_client.go Co-authored-by: amit-momin <108959691+amit-momin@users.noreply.github.com> * chore: bump chainlink-framework/multinode to merged version --------- Co-authored-by: amit-momin <108959691+amit-momin@users.noreply.github.com>
1 parent 5151ea0 commit 9d54e0b

15 files changed

Lines changed: 314 additions & 31 deletions

CONFIG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,8 @@ SyncThreshold = 5 # Default
932932
LeaseDuration = '0s' # Default
933933
NodeIsSyncingEnabled = false # Default
934934
FinalizedBlockPollInterval = '5s' # Default
935+
HistoricalBalanceCheckAddress = '0x0000000000000000000000000000000000000000' # Default
936+
FinalizedStateCheckFailureThreshold = 0 # Default
935937
EnforceRepeatableRead = true # Default
936938
DeathDeclarationDelay = '1m' # Default
937939
NewHeadsPollInterval = '0s' # Default
@@ -1010,6 +1012,25 @@ reported based on latest block and finality depth.
10101012

10111013
Set to 0 to disable.
10121014

1015+
### HistoricalBalanceCheckAddress
1016+
```toml
1017+
HistoricalBalanceCheckAddress = '0x0000000000000000000000000000000000000000' # Default
1018+
```
1019+
HistoricalBalanceCheckAddress is the probe account used by the historical balance health check.
1020+
The check executes `eth_getBalance` for this address at the latest finalized block.
1021+
Finalized block selection follows chain finality settings:
1022+
- `FinalityTagEnabled = true`: use `finalized` tag
1023+
- `FinalityTagEnabled = false`: use `latest - FinalityDepth`
1024+
The check is only active when `FinalizedStateCheckFailureThreshold > 0`.
1025+
1026+
### FinalizedStateCheckFailureThreshold
1027+
```toml
1028+
FinalizedStateCheckFailureThreshold = 0 # Default
1029+
```
1030+
FinalizedStateCheckFailureThreshold is the number of consecutive failures of the finalized state availability check
1031+
before the node is marked as FinalizedStateNotAvailable.
1032+
Set to 0 to disable the check.
1033+
10131034
### EnforceRepeatableRead
10141035
```toml
10151036
EnforceRepeatableRead = true # Default
@@ -1075,6 +1096,7 @@ Fatal = '(: |^)fatal' # Example
10751096
ServiceUnavailable = '(: |^)service unavailable' # Example
10761097
TooManyResults = '(: |^)too many results' # Example
10771098
MissingBlocks = '(: |^)invalid block range' # Example
1099+
FinalizedStateUnavailable = '(missing trie node|state not available|historical state unavailable)' # Example
10781100
```
10791101
Errors enable the node to provide custom regex patterns to match against error messages from RPCs.
10801102

@@ -1174,6 +1196,12 @@ MissingBlocks = '(: |^)invalid block range' # Example
11741196
```
11751197
MissingBlocks is a regex pattern to match an eth_getLogs error indicating the rpc server is permanently missing some blocks in the requested block range
11761198

1199+
### FinalizedStateUnavailable
1200+
```toml
1201+
FinalizedStateUnavailable = '(missing trie node|state not available|historical state unavailable)' # Example
1202+
```
1203+
FinalizedStateUnavailable is a regex pattern to match errors indicating the RPC cannot serve historical state at the finalized block (e.g., pruned/non-archive node)
1204+
11771205
## OCR
11781206
```toml
11791207
[OCR]

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ require (
3333
github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20251022073203-7d8ae8cf67c1
3434
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20250818175541-3389ac08a563
3535
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260326122810-b657beadfb57
36-
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20251020150604-8ab84f7bad1a
37-
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260326180413-c69f27e37a13
36+
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260310180305-3ee91a6d9ae9
37+
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260401162955-be2bc6b5264b
3838
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396
3939
github.com/smartcontractkit/chainlink-protos/svr v1.1.1-0.20260203131522-bb8bc5c423b3
4040
github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20250815105909-75499abc4335

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -662,10 +662,10 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202508181755
662662
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20250818175541-3389ac08a563/go.mod h1:jP5mrOLFEYZZkl7EiCHRRIMSSHCQsYypm1OZSus//iI=
663663
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260326122810-b657beadfb57 h1:sCrr1Oy/JZstf/Oi2cRuU4mDN1BRUKfXP2CKByCMADg=
664664
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260326122810-b657beadfb57/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
665-
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20251020150604-8ab84f7bad1a h1:pr0VFI7AWlDVJBEkcvzXWd97V8w8QMNjRdfPVa/IQLk=
666-
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20251020150604-8ab84f7bad1a/go.mod h1:jo+cUqNcHwN8IF7SInQNXDZ8qzBsyMpnLdYbDswviFc=
667-
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260326180413-c69f27e37a13 h1:3KLLkTCIAy9CvT35Ey0k6pcWX/u+qsm3Y/58TI5VSAg=
668-
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260326180413-c69f27e37a13/go.mod h1:Y7h84PqCe/Vimf2h1Nc6tMiOJStDbtM33fEUeaaF5xk=
665+
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260310180305-3ee91a6d9ae9 h1:GK+2aFpW/Z5ZnMGCa9NU6o7LKHQ/9xJVZx2yMAMudnc=
666+
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260310180305-3ee91a6d9ae9/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
667+
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260401162955-be2bc6b5264b h1:CNfoAw4HzvaeGwFHavdqU09DCGqVBzwe4dUr82qVZMs=
668+
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260401162955-be2bc6b5264b/go.mod h1:n865LsUxibw9oJM0pH74EBiejJ/x/AgIGHaD99D3SDY=
669669
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396 h1:03tbcwjyIEjvHba1IWOj1sfThwebm2XNzyFHSuZtlWc=
670670
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
671671
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=

pkg/client/helpers_test.go

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type TestClientErrors struct {
3939
serviceUnavailable string
4040
tooManyResults string
4141
missingBlocks string
42+
finalizedStateUnavailable string
4243
}
4344

4445
func NewTestClientErrors() TestClientErrors {
@@ -83,23 +84,26 @@ func (c *TestClientErrors) L2FeeTooHigh() string { return c.l2FeeTooH
8384
func (c *TestClientErrors) L2Full() string { return c.l2Full }
8485
func (c *TestClientErrors) TransactionAlreadyMined() string { return c.transactionAlreadyMined }
8586
func (c *TestClientErrors) Fatal() string { return c.fatal }
86-
func (c *TestClientErrors) ServiceUnavailable() string { return c.serviceUnavailable }
87-
func (c *TestClientErrors) TooManyResults() string { return c.tooManyResults }
88-
func (c *TestClientErrors) MissingBlocks() string { return c.missingBlocks }
87+
func (c *TestClientErrors) ServiceUnavailable() string { return c.serviceUnavailable }
88+
func (c *TestClientErrors) TooManyResults() string { return c.tooManyResults }
89+
func (c *TestClientErrors) MissingBlocks() string { return c.missingBlocks }
90+
func (c *TestClientErrors) FinalizedStateUnavailable() string { return c.finalizedStateUnavailable }
8991

9092
type TestNodePoolConfig struct {
91-
NodePollFailureThreshold uint32
92-
NodePollInterval time.Duration
93-
NodeSelectionMode string
94-
NodeSyncThreshold uint32
95-
NodeLeaseDuration time.Duration
96-
NodeIsSyncingEnabledVal bool
97-
NodeFinalizedBlockPollInterval time.Duration
98-
NodeErrors config.ClientErrors
99-
EnforceRepeatableReadVal bool
100-
NodeDeathDeclarationDelay time.Duration
101-
NodeNewHeadsPollInterval time.Duration
102-
ExternalRequestMaxResponseSizeVal uint32
93+
NodePollFailureThreshold uint32
94+
NodePollInterval time.Duration
95+
NodeSelectionMode string
96+
NodeSyncThreshold uint32
97+
NodeLeaseDuration time.Duration
98+
NodeIsSyncingEnabledVal bool
99+
NodeFinalizedBlockPollInterval time.Duration
100+
HistoricalBalanceCheckAddressVal string
101+
NodeErrors config.ClientErrors
102+
EnforceRepeatableReadVal bool
103+
NodeDeathDeclarationDelay time.Duration
104+
NodeNewHeadsPollInterval time.Duration
105+
ExternalRequestMaxResponseSizeVal uint32
106+
FinalizedStateCheckFailureThresholdVal uint32
103107
}
104108

105109
func (tc TestNodePoolConfig) PollFailureThreshold() uint32 { return tc.NodePollFailureThreshold }
@@ -118,6 +122,10 @@ func (tc TestNodePoolConfig) FinalizedBlockPollInterval() time.Duration {
118122
return tc.NodeFinalizedBlockPollInterval
119123
}
120124

125+
func (tc TestNodePoolConfig) HistoricalBalanceCheckAddress() string {
126+
return tc.HistoricalBalanceCheckAddressVal
127+
}
128+
121129
func (tc TestNodePoolConfig) NewHeadsPollInterval() time.Duration {
122130
return tc.NodeNewHeadsPollInterval
123131
}
@@ -142,6 +150,10 @@ func (tc TestNodePoolConfig) ExternalRequestMaxResponseSize() uint32 {
142150
return tc.ExternalRequestMaxResponseSizeVal
143151
}
144152

153+
func (tc TestNodePoolConfig) FinalizedStateCheckFailureThreshold() uint32 {
154+
return tc.FinalizedStateCheckFailureThresholdVal
155+
}
156+
145157
func NewChainClientWithTestNode(
146158
t *testing.T,
147159
nodeCfg multinode.NodeConfig,

pkg/client/rpc_client.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"math/big"
1010
"net/http"
1111
"net/url"
12+
"regexp"
1213
"strconv"
1314
"sync/atomic"
1415
"time"
@@ -105,6 +106,7 @@ type RPCClient struct {
105106
finalityTagEnabled bool
106107
finalityDepth uint32
107108
safeDepth uint32
109+
historicalBalanceCheckAddress common.Address
108110
externalRequestMaxResponseSize uint32
109111

110112
beholderMetrics *rpcClientMetrics
@@ -144,6 +146,7 @@ func NewRPCClient(
144146
finalityTagEnabled: supportsFinalityTags,
145147
finalityDepth: finalityDepth,
146148
safeDepth: safeDepth,
149+
historicalBalanceCheckAddress: common.HexToAddress(cfg.HistoricalBalanceCheckAddress()),
147150
externalRequestMaxResponseSize: externalRequestMaxResponseSize,
148151
}
149152
r.cfg = cfg
@@ -193,6 +196,51 @@ func (r *RPCClient) ClientVersion(ctx context.Context) (version string, err erro
193196
return version, nil
194197
}
195198

199+
// CheckFinalizedStateAvailability verifies if the RPC can serve historical state at the finalized block.
200+
// This is used to detect non-archive nodes that cannot serve state queries for older blocks.
201+
// Returns multinode.ErrFinalizedStateUnavailable if the error matches the FinalizedStateUnavailable pattern.
202+
// The decision to call this method is made by multinode based on its configuration.
203+
func (r *RPCClient) CheckFinalizedStateAvailability(ctx context.Context) error {
204+
var blockNumber *big.Int
205+
if r.finalityTagEnabled {
206+
blockNumber = big.NewInt(rpc.FinalizedBlockNumber.Int64())
207+
} else {
208+
latestBlock, err := r.BlockNumber(ctx)
209+
if err != nil {
210+
return fmt.Errorf("fetching latest block number failed: %w", err)
211+
}
212+
latest := int64(latestBlock)
213+
finalizedHeight := max(int64(0), latest-int64(r.finalityDepth))
214+
blockNumber = big.NewInt(finalizedHeight)
215+
}
216+
_, err := r.BalanceAt(ctx, r.historicalBalanceCheckAddress, blockNumber)
217+
if err != nil {
218+
if r.isFinalizedStateUnavailableError(err) {
219+
return fmt.Errorf("%w: %w", multinode.ErrFinalizedStateUnavailable, err)
220+
}
221+
return fmt.Errorf("fetching balance for address %s at block %s failed: %w", r.historicalBalanceCheckAddress.String(), blockNumber.String(), err)
222+
}
223+
return nil
224+
}
225+
226+
// isFinalizedStateUnavailableError checks if the error matches the FinalizedStateUnavailable regex pattern.
227+
func (r *RPCClient) isFinalizedStateUnavailableError(err error) bool {
228+
if err == nil {
229+
return false
230+
}
231+
pattern := r.clientErrors.FinalizedStateUnavailable()
232+
if pattern == "" {
233+
r.rpcLog.Critical("FinalizedStateUnavailable regex pattern is empty; finalized state availability check is effectively disabled")
234+
return false
235+
}
236+
re, compileErr := regexp.Compile(pattern)
237+
if compileErr != nil {
238+
r.rpcLog.Criticalw("FinalizedStateUnavailable regex pattern is invalid; finalized state availability check is effectively disabled", "pattern", pattern, "err", compileErr)
239+
return false
240+
}
241+
return re.MatchString(err.Error())
242+
}
243+
196244
func (r *RPCClient) Dial(callerCtx context.Context) error {
197245
ctx, cancel, _ := r.AcquireQueryCtx(callerCtx, r.rpcTimeout)
198246
defer cancel()

pkg/client/rpc_client_internal_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/ethereum/go-ethereum/common"
1314
"github.com/ethereum/go-ethereum/common/hexutil"
1415
ethtypes "github.com/ethereum/go-ethereum/core/types"
1516
"github.com/ethereum/go-ethereum/rpc"
@@ -436,3 +437,132 @@ func NewTestRPCClient(t *testing.T, opts RPCClientOpts) *RPCClient {
436437
func ptr[T any](v T) *T {
437438
return &v
438439
}
440+
441+
func TestRPCClient_CheckFinalizedStateAvailability(t *testing.T) {
442+
t.Parallel()
443+
chainID := big.NewInt(1337)
444+
probeAddress := "0x0000000000000000000000000000000000000001"
445+
expectedAddress := common.HexToAddress(probeAddress).String()
446+
447+
t.Run("uses finalized tag when enabled", func(t *testing.T) {
448+
t.Parallel()
449+
wsURL := testutils.NewWSServer(t, chainID, func(method string, params gjson.Result) (resp testutils.JSONRPCResponse) {
450+
switch method {
451+
case "eth_getBalance":
452+
require.Equal(t, expectedAddress, params.Array()[0].String())
453+
require.Equal(t, "finalized", params.Array()[1].String())
454+
resp.Result = `"0x0"`
455+
default:
456+
require.Fail(t, "unexpected method: "+method)
457+
}
458+
return
459+
}).WSURL()
460+
461+
rpcClient := NewDialedTestRPCClient(t, RPCClientOpts{
462+
HTTP: wsURL,
463+
Cfg: &TestNodePoolConfig{
464+
NodeFinalizedBlockPollInterval: 1 * time.Second,
465+
HistoricalBalanceCheckAddressVal: probeAddress,
466+
},
467+
FinalityTagsEnabled: true,
468+
ChainID: chainID,
469+
})
470+
471+
err := rpcClient.CheckFinalizedStateAvailability(t.Context())
472+
require.NoError(t, err)
473+
})
474+
475+
t.Run("uses latest-finalityDepth when finality tags disabled", func(t *testing.T) {
476+
t.Parallel()
477+
wsURL := testutils.NewWSServer(t, chainID, func(method string, params gjson.Result) (resp testutils.JSONRPCResponse) {
478+
switch method {
479+
case "eth_blockNumber":
480+
resp.Result = `"0x14"` // 20
481+
case "eth_getBalance":
482+
require.Equal(t, expectedAddress, params.Array()[0].String())
483+
require.Equal(t, "0x10", params.Array()[1].String()) // 20 - 4
484+
resp.Result = `"0x0"`
485+
default:
486+
require.Fail(t, "unexpected method: "+method)
487+
}
488+
return
489+
}).WSURL()
490+
491+
rpcClient := NewDialedTestRPCClient(t, RPCClientOpts{
492+
HTTP: wsURL,
493+
Cfg: &TestNodePoolConfig{
494+
NodeFinalizedBlockPollInterval: 1 * time.Second,
495+
HistoricalBalanceCheckAddressVal: probeAddress,
496+
},
497+
FinalityTagsEnabled: false,
498+
FinalityDepth: 4,
499+
ChainID: chainID,
500+
})
501+
502+
err := rpcClient.CheckFinalizedStateAvailability(t.Context())
503+
require.NoError(t, err)
504+
})
505+
506+
t.Run("returns ErrFinalizedStateUnavailable when error matches regex", func(t *testing.T) {
507+
t.Parallel()
508+
wsURL := testutils.NewWSServer(t, chainID, func(method string, _ gjson.Result) (resp testutils.JSONRPCResponse) {
509+
switch method {
510+
case "eth_getBalance":
511+
resp.Error.Message = "missing trie node"
512+
default:
513+
require.Fail(t, "unexpected method: "+method)
514+
}
515+
return
516+
}).WSURL()
517+
518+
clientErrors := NewTestClientErrors()
519+
clientErrors.finalizedStateUnavailable = "missing trie node"
520+
521+
rpcClient := NewDialedTestRPCClient(t, RPCClientOpts{
522+
HTTP: wsURL,
523+
Cfg: &TestNodePoolConfig{
524+
NodeFinalizedBlockPollInterval: 1 * time.Second,
525+
HistoricalBalanceCheckAddressVal: probeAddress,
526+
NodeErrors: &clientErrors,
527+
},
528+
FinalityTagsEnabled: true,
529+
ChainID: chainID,
530+
})
531+
532+
err := rpcClient.CheckFinalizedStateAvailability(t.Context())
533+
require.Error(t, err)
534+
require.ErrorIs(t, err, multinode.ErrFinalizedStateUnavailable)
535+
})
536+
537+
t.Run("returns generic error when error does not match regex", func(t *testing.T) {
538+
t.Parallel()
539+
wsURL := testutils.NewWSServer(t, chainID, func(method string, _ gjson.Result) (resp testutils.JSONRPCResponse) {
540+
switch method {
541+
case "eth_getBalance":
542+
resp.Error.Message = "connection reset"
543+
default:
544+
require.Fail(t, "unexpected method: "+method)
545+
}
546+
return
547+
}).WSURL()
548+
549+
clientErrors := NewTestClientErrors()
550+
clientErrors.finalizedStateUnavailable = "missing trie node"
551+
552+
rpcClient := NewDialedTestRPCClient(t, RPCClientOpts{
553+
HTTP: wsURL,
554+
Cfg: &TestNodePoolConfig{
555+
NodeFinalizedBlockPollInterval: 1 * time.Second,
556+
HistoricalBalanceCheckAddressVal: probeAddress,
557+
NodeErrors: &clientErrors,
558+
},
559+
FinalityTagsEnabled: true,
560+
ChainID: chainID,
561+
})
562+
563+
err := rpcClient.CheckFinalizedStateAvailability(t.Context())
564+
require.Error(t, err)
565+
require.ErrorContains(t, err, "fetching balance")
566+
require.NotErrorIs(t, err, multinode.ErrFinalizedStateUnavailable)
567+
})
568+
}

pkg/config/chain_scoped_client_errors.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,6 @@ func (c *clientErrorsConfig) ServiceUnavailable() string {
5050
}
5151
func (c *clientErrorsConfig) TooManyResults() string { return derefOrDefault(c.c.TooManyResults) }
5252
func (c *clientErrorsConfig) MissingBlocks() string { return derefOrDefault(c.c.MissingBlocks) }
53+
func (c *clientErrorsConfig) FinalizedStateUnavailable() string {
54+
return derefOrDefault(c.c.FinalizedStateUnavailable)
55+
}

0 commit comments

Comments
 (0)