Skip to content

Commit 123a84a

Browse files
authored
feat(relayer): persist eth scan progress (#344)
* feat(relayer): persist Ethereum scan progress via checkpoints The relayer only persisted its Ethereum scan offset as a side effect of processing a deposit, so block ranges with no deposits never advanced the persisted offset — on restart the poller re-scanned from the last block that happened to contain a deposit. The poller now emits a scan-progress checkpoint after every fully scanned slice (including empty ones), carried on the existing handler as a DepositEvent{Checkpoint: true}. The Ethereum source turns it into a checkpoint relayer.Event that rides the same in-order channel behind that slice's deposits; the processor persists the offset from it and does no transfer work. Because the checkpoint is delivered in order after the slice's deposits — and each deposit is durably recorded via CreateTransfer (idempotent) before submit — the offset never advances past an unprocessed deposit, so nothing is missed or double-processed. Carrying the signal on the existing handler keeps the EthereumBridgeClient interface (and its mock) unchanged. * fix(relayer): advance scan block only after checkpoint is accepted Move currentBlock/setLastScannedBlock past the checkpoint handler call so a failed checkpoint re-scans and re-checkpoints the same range on the next tick instead of skipping its watermark, matching the documented intent. * fix(e2e): retry P2 transfers on CONTRACT_NOT_FOUND contention
1 parent b20e2dc commit 123a84a

8 files changed

Lines changed: 135 additions & 14 deletions

File tree

pkg/ethereum/client.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,16 @@ func chunkRange(start, end, maxRange uint64) []blockRange {
234234
// config.MaxBlockRange blocks so requests stay under the provider's per-call cap.
235235
// On a slice failure, currentBlock advances only through the last successful
236236
// slice and the failing range is retried on the next tick.
237-
func (c *Client) WatchDepositEvents(ctx context.Context, fromBlock uint64, handler func(*DepositEvent) error) error {
237+
// After each fully scanned slice — including slices with no deposits — the handler
238+
// is also invoked with a checkpoint event (DepositEvent{Checkpoint: true}) carrying
239+
// the last scanned block, so the caller can persist scan progress and avoid
240+
// re-scanning from the start on restart. The checkpoint rides the same in-order
241+
// handler path as deposits, so it is only delivered after that slice's deposits.
242+
func (c *Client) WatchDepositEvents(
243+
ctx context.Context,
244+
fromBlock uint64,
245+
handler func(*DepositEvent) error,
246+
) error {
238247
c.logger.Info("Starting deposit event poller",
239248
zap.Uint64("from_block", fromBlock),
240249
zap.Uint64("max_block_range", c.config.MaxBlockRange))
@@ -284,6 +293,15 @@ func (c *Client) WatchDepositEvents(ctx context.Context, fromBlock uint64, handl
284293
if err := c.scanDepositRange(ctx, r, handler); err != nil {
285294
return
286295
}
296+
// Emit a scan-progress checkpoint after this slice's deposits were
297+
// handed to the handler (in order), so a restart resumes near here
298+
// instead of re-scanning from the start. Advance currentBlock only
299+
// once the checkpoint is accepted, so on failure the same range is
300+
// re-scanned and re-checkpointed on the next tick rather than having
301+
// its watermark silently skipped.
302+
if err := handler(&DepositEvent{BlockNumber: r.end, Checkpoint: true}); err != nil {
303+
return
304+
}
287305
currentBlock = r.end
288306
c.setLastScannedBlock(currentBlock)
289307
}

pkg/ethereum/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ type DepositEvent struct {
2121
BlockNumber uint64
2222
TxHash common.Hash
2323
LogIndex uint
24+
25+
// Checkpoint marks a scan-progress signal rather than a real deposit: the poller
26+
// emits one (with only BlockNumber set) after each fully scanned block range —
27+
// including ranges with no deposits — so the consumer can persist scan progress.
28+
// It rides the same in-order handler path as deposits, so it is only observed
29+
// after every deposit in the range it covers.
30+
Checkpoint bool
2431
}
2532

2633
// WithdrawalEvent represents a withdrawal from Canton event on Ethereum

pkg/relayer/engine/processor.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ func (p *Processor) Start(ctx context.Context, startOffset string) error {
104104
if !ok {
105105
return nil
106106
}
107+
if event.Checkpoint {
108+
// Scan-progress watermark, not a transfer: advance the persisted
109+
// offset only. It arrives in order after the events it covers, so
110+
// everything up to it has already been processed by this loop.
111+
p.persistOffset(ctx, event)
112+
continue
113+
}
107114
if err := p.processEvent(ctx, event); err != nil {
108115
p.logger.Error("Failed to process event",
109116
zap.String("event_id", event.ID),

pkg/relayer/engine/processor_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,47 @@ func TestProcessor_Start_ProcessesEventSuccess(t *testing.T) {
8080
}
8181
}
8282

83+
func TestProcessor_Start_CheckpointPersistsOffsetWithoutProcessing(t *testing.T) {
84+
ctx := context.Background()
85+
source := relayermocks.NewSource(t)
86+
destination := relayermocks.NewDestination(t)
87+
store := relayermocks.NewBridgeStore(t)
88+
89+
eventCh := make(chan *relayer.Event, 1)
90+
errCh := make(chan error)
91+
92+
// A scan-progress checkpoint (no transfer payload).
93+
eventCh <- &relayer.Event{
94+
SourceChain: relayer.ChainEthereum,
95+
SourceBlockNumber: 150,
96+
Checkpoint: true,
97+
}
98+
close(eventCh)
99+
100+
source.EXPECT().GetChainID().Return(relayer.ChainEthereum).Maybe()
101+
destination.EXPECT().GetChainID().Return(relayer.ChainCanton).Maybe()
102+
source.EXPECT().StreamEvents(ctx, "100").Return((<-chan *relayer.Event)(eventCh), (<-chan error)(errCh)).Once()
103+
source.EXPECT().ExtractOffset(mock.AnythingOfType("*relayer.Event")).Return("150").Once()
104+
// No CreateTransfer / SubmitTransfer / UpdateTransferStatus expected — a
105+
// checkpoint must only advance the offset. The strict mocks fail the test if any
106+
// transfer-processing call is made.
107+
108+
var persistedChain, persistedOffset string
109+
processor := engine.NewProcessor(source, destination, store, engine.NewNopMetrics(), zap.NewNop(), "processor_test", relayer.DirectionEthereumToCanton).
110+
WithOffsetUpdate(func(_ context.Context, chainID string, offset string) error {
111+
persistedChain = chainID
112+
persistedOffset = offset
113+
return nil
114+
})
115+
116+
if err := processor.Start(ctx, "100"); err != nil {
117+
t.Fatalf("Start() failed: %v", err)
118+
}
119+
if persistedChain != relayer.ChainEthereum || persistedOffset != "150" {
120+
t.Fatalf("expected checkpoint to persist ethereum offset 150, got chain=%s offset=%s", persistedChain, persistedOffset)
121+
}
122+
}
123+
83124
func TestProcessor_Start_DuplicateTransferPersistsOffsetOnce(t *testing.T) {
84125
ctx := context.Background()
85126
source := relayermocks.NewSource(t)

pkg/relayer/engine/source.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,30 @@ func (s *ethereumSource) StreamEvents(ctx context.Context, offset string) (<-cha
127127
fromBlock = n
128128
}
129129

130+
emit := func(ev *relayer.Event) error {
131+
select {
132+
case outCh <- ev:
133+
return nil
134+
case <-ctx.Done():
135+
return ctx.Err()
136+
}
137+
}
138+
130139
err := s.client.WatchDepositEvents(ctx, fromBlock, func(event *ethereum.DepositEvent) error {
140+
// A checkpoint carries scan progress, not a deposit. It rides the same
141+
// ordered channel behind that slice's deposits, so the processor persists
142+
// the block only once those deposits are processed.
143+
if event.Checkpoint {
144+
return emit(&relayer.Event{
145+
SourceChain: relayer.ChainEthereum,
146+
SourceBlockNumber: event.BlockNumber,
147+
Checkpoint: true,
148+
})
149+
}
150+
131151
s.metrics.IncEventsDetected(relayer.ChainEthereum, EventTypeDeposit)
132152

133-
relayerEvent := &relayer.Event{
153+
return emit(&relayer.Event{
134154
ID: fmt.Sprintf("%s-%d", event.TxHash.Hex(), event.LogIndex),
135155
TransactionID: event.TxHash.Hex(),
136156
SourceChain: relayer.ChainEthereum,
@@ -142,14 +162,7 @@ func (s *ethereumSource) StreamEvents(ctx context.Context, offset string) (<-cha
142162
Recipient: fmt.Sprintf("%x", event.CantonRecipient),
143163
Nonce: event.Nonce.Int64(),
144164
SourceBlockNumber: event.BlockNumber,
145-
}
146-
147-
select {
148-
case outCh <- relayerEvent:
149-
return nil
150-
case <-ctx.Done():
151-
return ctx.Err()
152-
}
165+
})
153166
})
154167

155168
if err != nil && ctx.Err() == nil {

pkg/relayer/engine/source_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,30 @@ func TestEthereumSource_StreamEvents_MapsDepositEvent(t *testing.T) {
175175
t.Fatal("timed out waiting for mapped ethereum event")
176176
}
177177
}
178+
179+
func TestEthereumSource_StreamEvents_EmitsScanCheckpoint(t *testing.T) {
180+
ctx := context.Background()
181+
ethClient := relayermocks.NewEthereumBridgeClient(t)
182+
183+
// The poller reports scan progress via a checkpoint event even with no deposits.
184+
ethClient.EXPECT().
185+
WatchDepositEvents(ctx, uint64(12), mock.Anything).
186+
RunAndReturn(func(_ context.Context, _ uint64, handler func(*ethereum.DepositEvent) error) error {
187+
return handler(&ethereum.DepositEvent{BlockNumber: 200, Checkpoint: true})
188+
})
189+
190+
source := engine.NewEthereumSource(ethClient, relayer.ChainEthereum, engine.NewNopMetrics())
191+
eventCh, _ := source.StreamEvents(ctx, "12")
192+
193+
select {
194+
case event, ok := <-eventCh:
195+
if !ok {
196+
t.Fatal("expected a checkpoint event, channel closed")
197+
}
198+
if !event.Checkpoint || event.SourceBlockNumber != 200 {
199+
t.Fatalf("expected checkpoint at block 200, got %+v", event)
200+
}
201+
case <-time.After(2 * time.Second):
202+
t.Fatal("timed out waiting for scan checkpoint event")
203+
}
204+
}

pkg/relayer/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,10 @@ type Event struct {
7676
Recipient string
7777
Nonce int64
7878
SourceBlockNumber uint64
79+
80+
// Checkpoint marks a progress watermark rather than a real transfer: the source
81+
// has scanned through SourceBlockNumber and emitted every event up to it. The
82+
// processor persists the offset and skips transfer processing. Emitted in-order
83+
// after a slice's events so persisting it cannot skip an unprocessed event.
84+
Checkpoint bool
7985
}

tests/e2e/devstack/shim/canton2.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,10 @@ func (c *Canton2Shim) GetHoldings(ctx context.Context, ownerParty, tokenSymbol s
243243
//
244244
// Parallel e2e tests share the same USDCx issuer party and contend on its
245245
// Holding set. Canton may reject with LOCAL_VERDICT_LOCKED_CONTRACTS (another
246-
// transfer holds the lock right now) or LOCAL_VERDICT_INACTIVE_CONTRACTS (the
247-
// Holding we selected was archived between GetHoldings and submit). Both are
248-
// transient — refetch holdings on the next attempt and try again.
246+
// transfer holds the lock right now), LOCAL_VERDICT_INACTIVE_CONTRACTS, or
247+
// CONTRACT_NOT_FOUND (the Holding we selected was archived between GetHoldings
248+
// and submit). All are transient — refetch holdings on the next attempt and
249+
// try again.
249250
func (c *Canton2Shim) TransferToken(ctx context.Context, senderParty, recipientParty, tokenSymbol, amount string) error {
250251
const maxAttempts = 5
251252
var lastErr error
@@ -270,7 +271,8 @@ func (c *Canton2Shim) TransferToken(ctx context.Context, senderParty, recipientP
270271
func isHoldingContention(err error) bool {
271272
msg := err.Error()
272273
return strings.Contains(msg, "LOCAL_VERDICT_LOCKED_CONTRACTS") ||
273-
strings.Contains(msg, "LOCAL_VERDICT_INACTIVE_CONTRACTS")
274+
strings.Contains(msg, "LOCAL_VERDICT_INACTIVE_CONTRACTS") ||
275+
strings.Contains(msg, "CONTRACT_NOT_FOUND")
274276
}
275277

276278
// GetFingerprintMapping is not supported on Participant 2.

0 commit comments

Comments
 (0)