Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions config.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
|catchupThreshold|How many blocks behind the chain head an event stream or listener must be on startup, to enter catchup mode|`int`|`500`
|checkpointBlockGap|The number of blocks at the head of the chain that should be considered unstable (could be dropped from the canonical chain after a re-org). Unless events with a full set of confirmations are detected, the restart checkpoint will this many blocks behind the chain head.|`int`|`50`
|filterPollingInterval|The interval between polling calls to a filter, when checking for newly arrived events|[`time.Duration`](https://pkg.go.dev/time#Duration)|`1s`
|filterPollingMode|How events are polled for in the steady state event loop, once caught up with the head of the chain. 'server' establishes a node-side filter with eth_newFilter and polls it with eth_getFilterChanges. 'client' uses stateless eth_getLogs range queries with the connector tracking its own poll position, avoiding node-side filter state entirely|server,client|`server`

## connector.net

Expand Down
15 changes: 15 additions & 0 deletions internal/ethereum/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const (
EventsCheckpointBlockGap = "events.checkpointBlockGap"
EventsBlockTimestamps = "events.blockTimestamps"
EventsFilterPollingInterval = "events.filterPollingInterval"
EventsFilterPollingMode = "events.filterPollingMode"
RetryInitDelay = "queryLoopRetry.initialDelay"
RetryMaxDelay = "queryLoopRetry.maxDelay"
RetryFactor = "queryLoopRetry.factor"
Expand All @@ -57,6 +58,19 @@ const (
UseGetBlockReceipts = "useGetBlockReceipts"
)

// filterPollingMode determines how the steady state loop of an event stream polls for new events,
// once it has caught up with the head of the chain.
type filterPollingMode string

const (
// FilterPollingModeServer uses a node-side filter, established with eth_newFilter and polled
// with eth_getFilterChanges, so the node tracks which logs are new since the last poll
FilterPollingModeServer filterPollingMode = "server"
// FilterPollingModeClient uses stateless eth_getLogs range queries, with the connector tracking
// its own in-memory poll position - avoiding node-side filter state entirely
FilterPollingModeClient filterPollingMode = "client"
)

const (
DefaultListenerPort = 5102
DefaultGasEstimationFactor = 1.5
Expand Down Expand Up @@ -84,6 +98,7 @@ func InitConfig(conf config.Section) {
conf.AddKnownKey(ConfigGasEstimationFactor, DefaultGasEstimationFactor)
conf.AddKnownKey(EventsBlockTimestamps, true)
conf.AddKnownKey(EventsFilterPollingInterval, "1s")
conf.AddKnownKey(EventsFilterPollingMode, string(FilterPollingModeServer))
conf.AddKnownKey(EventsCatchupPageSize, DefaultCatchupPageSize)
conf.AddKnownKey(EventsCatchupThreshold, DefaultEventsCatchupThreshold)
conf.AddKnownKey(EventsCatchupDownscaleRegex, DefaultEventsCatchupDownscaleRegex)
Expand Down
10 changes: 10 additions & 0 deletions internal/ethereum/ethereum.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type ethConnector struct {
eventBlockTimestamps bool
blockListener ethblocklistener.BlockListener
eventFilterPollingInterval time.Duration
eventFilterPollingMode filterPollingMode
traceTXForRevertReason bool
chainID string

Expand Down Expand Up @@ -117,6 +118,14 @@ func NewEthereumConnectorWithRPC(ctx context.Context, conf config.Section, rpc e
return nil, i18n.NewError(ctx, msgs.MsgInvalidChainTrackingMode, chainTrackingMode)
}

eventFilterPollingMode := filterPollingMode(conf.GetString(EventsFilterPollingMode))
if eventFilterPollingMode == "" {
eventFilterPollingMode = FilterPollingModeServer
}
if eventFilterPollingMode != FilterPollingModeServer && eventFilterPollingMode != FilterPollingModeClient {
return nil, i18n.NewError(ctx, msgs.MsgInvalidFilterPollingMode, eventFilterPollingMode)
}

c := &ethConnector{
rpc: rpc,
eventStreams: make(map[fftypes.UUID]*eventStream),
Expand All @@ -125,6 +134,7 @@ func NewEthereumConnectorWithRPC(ctx context.Context, conf config.Section, rpc e
checkpointBlockGap: conf.GetInt64(EventsCheckpointBlockGap),
eventBlockTimestamps: conf.GetBool(EventsBlockTimestamps),
eventFilterPollingInterval: conf.GetDuration(EventsFilterPollingInterval),
eventFilterPollingMode: eventFilterPollingMode,
traceTXForRevertReason: conf.GetBool(TraceTXForRevertReason),
chainTrackingMode: chainTrackingMode,
retry: retryutil.RetryWrapper{Retry: &retry.Retry{}},
Expand Down
5 changes: 5 additions & 0 deletions internal/ethereum/ethereum_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ func TestConnectorInit(t *testing.T) {

conf.Set(RPCRoutingMode, ethrpc.RoutingModeAuto)
conf.Set(ChainTrackingMode, "")
conf.Set(EventsFilterPollingMode, "wrong")
_, err = NewEthereumConnector(context.Background(), conf)
assert.Regexp(t, "FF23078.*wrong", err)

conf.Set(EventsFilterPollingMode, string(FilterPollingModeClient))
conf.Set(WebSocketsEnabled, true)
conf.Set(EventsCatchupThreshold, 1)
conf.Set(EventsCatchupPageSize, 500)
Expand Down
2 changes: 1 addition & 1 deletion internal/ethereum/event_listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func (l *listener) ensureHWM(ctx context.Context) error {
return err
}
// HWM is the configured fromBlock
l.hwmBlock = int64(firstBlock) //nolint:gosec // convert to int64 to match the type of hwmBlock, we should change the type of hwmBlock to uint64
l.hwmBlock = blockNumberToInt64(firstBlock)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Given this was getting proliferated, I've condensed to a single place and a single behavior if we ever ended up with the (invalid) case of a block in the >maxint64 range.

}
return nil
}
Expand Down
28 changes: 23 additions & 5 deletions internal/ethereum/event_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -273,7 +274,7 @@ func (es *eventStream) leadGroupCatchup() bool {
}

// Check if we're ready to exit catchup mode
headGap := (int64(chainHeadBlock) - fromBlock) //nolint:gosec // convert to int64 to match the type of headGap
headGap := (blockNumberToInt64(chainHeadBlock) - fromBlock)
if headGap < es.c.catchupThreshold {
log.L(es.ctx).Infof("Stream head is up to date with chain fromBlock=%d chainHead=%d headGap=%d", fromBlock, chainHeadBlock, headGap)
return false
Expand Down Expand Up @@ -338,7 +339,7 @@ func (es *eventStream) leadGroupSteadyState() bool {
// High water mark is a point safely behind the head of the chain in this case,
// where re-orgs are not expected.
bh, _ := es.c.blockListener.GetHighestBlock(es.ctx) /* note we know we're initialized here and will not block */
hwmBlock := int64(bh) - es.c.checkpointBlockGap //nolint:gosec // convert to int64 to match the type of hwmBlock
hwmBlock := blockNumberToInt64(bh) - es.c.checkpointBlockGap
if hwmBlock < 0 {
hwmBlock = 0
}
Expand All @@ -361,7 +362,7 @@ func (es *eventStream) leadGroupSteadyState() bool {

// Check we're not outside of the steady state window, and need to fall back to catchup mode
chainHeadBlock, _ := es.c.blockListener.GetHighestBlock(es.ctx) /* note we know we're initialized here and will not block */
blockGapEstimate := (int64(chainHeadBlock) - fromBlock) //nolint:gosec // convert to int64 to match the type of blockGapEstimate
blockGapEstimate := (blockNumberToInt64(chainHeadBlock) - fromBlock)
if blockGapEstimate > es.c.catchupThreshold {
log.L(es.ctx).Warnf("Block gap estimate reached %d (above threshold of %d) - reverting to catchup mode", blockGapEstimate, es.c.catchupThreshold)
return false
Expand Down Expand Up @@ -429,6 +430,17 @@ func (es *eventStream) leadGroupSteadyState() bool {
}
}

// blockNumberToInt64 converts a block number from the node into the int64 type we use for all
// block range arithmetic, with a bounds check to avoid wraparound. A block number large enough
// to overflow an int64 cannot occur on a real chain and cannot be handled, so a panic is
// acceptable in that case.
func blockNumberToInt64(blockNumber uint64) int64 {
if blockNumber > math.MaxInt64 {
panic(fmt.Sprintf("block number %d too large", blockNumber))
}
return int64(blockNumber)
}

func (es *eventStream) preStartProcessing() {
ctx := es.ctx
chainHead, ok := es.c.blockListener.GetHighestBlock(ctx)
Expand All @@ -439,7 +451,7 @@ func (es *eventStream) preStartProcessing() {
// The lead group never advances past checkpointBlockGap behind the chain head, as those blocks
// are re-org unstable. We establish our head position on the same basis, so that a listener
// held in catchup clamps against a safe ceiling from the moment it is established.
safeHead := int64(chainHead) - es.c.checkpointBlockGap //nolint:gosec // convert to int64 to match the type of headBlock
safeHead := blockNumberToInt64(chainHead) - es.c.checkpointBlockGap
if safeHead < 0 {
safeHead = 0
}
Expand Down Expand Up @@ -489,7 +501,13 @@ func (es *eventStream) streamLoop() {

// We then transition to our steady state, filtering from the front of the chain.
// But we might fall behind and need to go back to the catchup mode.
if es.leadGroupSteadyState() {
var exiting bool
if es.c.eventFilterPollingMode == FilterPollingModeClient {
exiting = es.leadGroupSteadyStateGetLogs()
} else {
exiting = es.leadGroupSteadyState()
}
Comment on lines +522 to +527

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deliberately a hard split here, to protect the existing code path from churn.

if exiting {
return
}
}
Expand Down
217 changes: 217 additions & 0 deletions internal/ethereum/event_stream_getlogs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright © 2026 Kaleido, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ethereum

import (
"bytes"
"context"
"time"

"github.com/hyperledger-firefly/common/pkg/log"
"github.com/hyperledger-firefly/evmconnect/pkg/ethrpc"
"github.com/hyperledger-firefly/signer/pkg/ethtypes"
)

// getLogsPollState is the in-memory client-side filtering position for the getLogs steady state
// (events.filterPollingMode: getLogs). As well as the next block to poll, we keep a sparse record
// of the (number, hash) of blocks we have already polled that are still within the block listener's
// monitored (re-org unstable) window, so that when a re-org happens behind our poll position we can
// find the earliest block that diverged and rewind to exactly there - rather than re-delivering the
// whole unstable window.
type getLogsPollState struct {
fromBlock int64 // the next block to poll
polledChain []*ethrpc.BlockInfoJSONRPC // sparse ascending (number, hash) records of polled blocks in the unstable window
}

// reset (re-)establishes the poll position, discarding any recorded hash continuity
func (ps *getLogsPollState) reset(fromBlock int64) {
ps.fromBlock = fromBlock
ps.polledChain = nil
}

// checkReorgRewind compares the hashes recorded when we polled blocks, against the block listener's
// current canonical chain view. On a mismatch the chain has re-organized behind our poll position,
// so we rewind to the earliest diverging block to re-poll from there. Re-deliveries that result
// from a rewind are de-duplicated in FFTM against its checkpoint.
func (ps *getLogsPollState) checkReorgRewind(ctx context.Context, headChain []*ethrpc.BlockInfoJSONRPC) {
if len(headChain) == 0 || len(ps.polledChain) == 0 {
return
}
// Prune records that have aged out below the base of the monitored window - those blocks are
// now considered stable, and we have nothing to compare them against
baseBlock := blockNumberToInt64(headChain[0].Number.Uint64())
firstInWindow := 0
for firstInWindow < len(ps.polledChain) && blockNumberToInt64(ps.polledChain[firstInWindow].Number.Uint64()) < baseBlock {
firstInWindow++
}
ps.polledChain = ps.polledChain[firstInWindow:]
// Find the earliest block we polled whose hash is no longer canonical
for i, polled := range ps.polledChain {
polledNumber := blockNumberToInt64(polled.Number.Uint64())
canonicalHash := blockHashInHeadChain(headChain, polledNumber)
if canonicalHash == nil {
continue // above the top of the current window - nothing to compare against
}
if !bytes.Equal(canonicalHash, polled.Hash) {
log.L(ctx).Infof("Re-org detected at block %d (polled hash %s, now %s) - rewinding poll position from %d to %d", polledNumber, polled.Hash, canonicalHash, ps.fromBlock, polledNumber)
ps.fromBlock = polledNumber
ps.polledChain = ps.polledChain[:i] // records at/after the divergence are no longer valid
return
}
}
}

// advance moves the poll position forwards after successfully processing blocks up to toBlock,
// recording the canonical hashes we hold for the polled range so a re-org behind the new position
// can be detected by checkReorgRewind on a later cycle.
//
// Note the hashes come from the headChain snapshot taken before the eth_getLogs query - if the
// chain re-organizes in between, the recorded hash and the queried logs can disagree, but the next
// cycle's continuity check then mismatches the new canonical view and rewinds us to re-poll.
func (ps *getLogsPollState) advance(headChain []*ethrpc.BlockInfoJSONRPC, toBlock int64) {
for _, bi := range headChain {
if n := blockNumberToInt64(bi.Number.Uint64()); n >= ps.fromBlock && n <= toBlock {
ps.polledChain = append(ps.polledChain, bi)
}
}
ps.fromBlock = toBlock + 1
}

// blockHashInHeadChain returns the hash of the given block number in the supplied canonical chain
// snapshot, or nil if that block number is not within the snapshot
func blockHashInHeadChain(headChain []*ethrpc.BlockInfoJSONRPC, blockNumber int64) ethtypes.HexBytes0xPrefix {
for _, bi := range headChain {
if blockNumberToInt64(bi.Number.Uint64()) == blockNumber {
return bi.Hash
}
}
return nil
}

// leadGroupSteadyStateGetLogs is the alternative steady state to leadGroupSteadyState, selected with
// events.filterPollingMode: getLogs. Instead of establishing a node-side filter, we track our own
// in-memory poll position and page forwards with stateless eth_getLogs range queries.
//
// The listener HWM (scan position used for the restart checkpoint) trails checkpointBlockGap behind
// the chain head exactly as in filter mode - but is additionally clamped so it never passes the
// in-memory poll position, as blocks beyond that have not been queried yet.
//
// Because a re-org behind the poll position would otherwise go unnoticed until restart (a node-side
// filter re-notifies logs on the new branch, a forwards poll position does not), we record the
// hashes of the blocks we poll and check them each cycle against the block listener's canonical
// chain view - see getLogsPollState.
func (es *eventStream) leadGroupSteadyStateGetLogs() bool {
var ag *aggregatedListener
lastUpdate := -1
failCount := 0
poll := &getLogsPollState{fromBlock: -1}
for {
if es.c.retry.DoFailureDelay(es.ctx, failCount) {
log.L(es.ctx).Debugf("Stream loop exiting")
return true
}

// Build the aggregated listener list if it has changed
listenerChanged := es.buildReuseLeadGroupListener(&lastUpdate, &ag)

caughtUpToHead := true

// No need to poll for events, if we don't have any listeners
if len(ag.signatureSet) > 0 {

chainHeadBlock, ok := es.c.blockListener.GetHighestBlock(es.ctx) /* note we know we're initialized here and will not block */
if !ok {
log.L(es.ctx).Debugf("Stream loop exiting (closed checking block height)")
return true
}
chainHead := blockNumberToInt64(chainHeadBlock)

// (Re-)establish the poll position from the earliest listener HWM if we need to,
// just as filter mode (re-)establishes the fromBlock of its filter
if poll.fromBlock < 0 || listenerChanged {
fromBlock := int64(-1)
for _, l := range ag.listeners {
if lHWM := l.getHWMBlock(); fromBlock < 0 || lHWM < fromBlock {
fromBlock = lHWM
}
}
poll.reset(fromBlock)
}

// Check we're not outside of the steady state window, and need to fall back to catchup mode
if (chainHead - poll.fromBlock) > es.c.catchupThreshold {
log.L(es.ctx).Warnf("Block gap reached %d (above threshold of %d) - reverting to catchup mode", chainHead-poll.fromBlock, es.c.catchupThreshold)
return false
}

// Check the blocks we already polled are still canonical, rewinding our position if not
headChain := es.c.blockListener.SnapshotMonitoredHeadChain()
poll.checkReorgRewind(es.ctx, headChain)

// Poll the next page of blocks, if there are any we haven't polled yet
toBlock := chainHead
if maxToBlock := poll.fromBlock + es.c.catchupPageSize - 1; toBlock > maxToBlock {
toBlock = maxToBlock
caughtUpToHead = false // page again immediately, rather than waiting the polling interval
}
if toBlock >= poll.fromBlock {
events, err := es.getBlockRangeEvents(es.ctx, ag, poll.fromBlock, toBlock)
if err != nil {
log.L(es.ctx).Errorf("Failed to query block range fromBlock=%d toBlock=%d headBlock=%d: %s", poll.fromBlock, toBlock, chainHead, err)
failCount++
continue
}

// High water mark is a point safely behind the head of the chain where re-orgs are
// not expected, but must never pass the poll position (blocks not yet queried)
hwmBlock := chainHead - es.c.checkpointBlockGap
if hwmBlock < 0 {
hwmBlock = 0
}
if hwmBlock > toBlock+1 {
hwmBlock = toBlock + 1
}

// Dispatch the events
if es.dispatchSetHWMCheckExit(ag, events, hwmBlock) {
log.L(es.ctx).Debugf("Stream loop exiting")
return true
}

// Update the head block to be the hwm block
es.headBlock.Store(hwmBlock)

// Advance our poll position, recording the hashes of the blocks we polled so we
// can detect a re-org behind us on a later cycle
poll.advance(headChain, toBlock)
}
}

// Reset failure count if we reach here
failCount = 0

// Sleep for the polling interval, unless we are paging through a backlog
if caughtUpToHead {
select {
case <-time.After(es.c.eventFilterPollingInterval):
case <-es.ctx.Done():
log.L(es.ctx).Debugf("Stream loop stopping")
return true
}
}
}
}
Loading