Skip to content

feat(relayer): optionally defer fills sourced from late-arriving origin blocks - #3750

Merged
pxrl merged 11 commits into
masterfrom
droplet/defer-late-origin-blocks
Aug 26, 2026
Merged

feat(relayer): optionally defer fills sourced from late-arriving origin blocks#3750
pxrl merged 11 commits into
masterfrom
droplet/defer-late-origin-blocks

Conversation

@droplet-rl

Copy link
Copy Markdown
Contributor

On chains with fixed slot times, a block delivered late within its own slot was published after that slot's attestation deadline and is materially more likely to be replaced at the same height by the next proposer. The existing deposit confirmation gate compares block heights, so a same-height replacement is invisible to it.

  • SpokePoolClient (listener mixin) records, per block, the interval between the block's own timestamp and its arrival. Bounded to the most recent BLOCK_ARRIVAL_HISTORY blocks.
  • Relayer.originBlockUnsettled() withholds a deposit whose origin block arrived at or beyond the configured threshold until a subsequent block has been built on top of it.
  • Per-origin-chain control via RELAYER_MAX_ORIGIN_BLOCK_LATENESS_<chainId>, disabled by default.

Blocks not observed live have no recorded arrival time and are treated as settled, so backfilled and restarted state is unaffected.

Threshold selection is deliberately left to config: a useful value depends on the operator's own event-delivery latency, which has to be measured per deployment rather than assumed. Off by default for that reason.

🤖 Generated with Claude Code

…in blocks

A block delivered late within its own slot was published after that slot's
attestation deadline, so it is materially more likely to be replaced at the
same height by the next proposer. The existing deposit confirmation gate
compares block heights and cannot observe a same-height replacement.

Record per-block arrival lateness in the listener-backed SpokePoolClient and
withhold deposits sourced from a late-arriving block until a subsequent block
has been built on top of it. Controlled per origin chain by
RELAYER_MAX_ORIGIN_BLOCK_LATENESS_<chainId>, disabled by default.

Co-Authored-By: Claude <noreply@anthropic.com>

@pxrl pxrl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Test is failing - needs a fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 392f5bf4bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/clients/SpokePoolClient.ts Outdated
Comment thread src/common/Constants.ts Outdated
Comment thread src/relayer/RelayerConfig.ts Outdated
Comment thread src/relayer/RelayerConfig.ts Outdated
Comment thread src/clients/SpokePoolClient.ts Outdated
Comment thread src/relayer/Relayer.ts Outdated
originBlockUnsettled() indexed this.config.maxOriginBlockLateness directly.
Relayer tests build their config as a partial object literal cast to
RelayerConfig, so the field is undefined there and every deposit evaluation
threw. Use optional chaining, matching the existing minFillTime lookup.

Add test coverage for the feature, which had none -- that is why the
uninitialised-config path shipped broken. The final case pins the regression
by omitting maxOriginBlockLateness entirely.

Also correct the LATE_BLOCK_MIN_CONFIRMATIONS comment: confirmations are
counted as blocks built on top (as at Relayer.ts:440), so a value of 2 means
two blocks on top, not the one the comment claimed.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Fixed in b9f1145.

originBlockUnsettled() indexed config.maxOriginBlockLateness directly, but the Relayer tests cast a partial object literal to RelayerConfig, so it was undefined and every deposit evaluation threw. Now optional-chained, matching the minFillTime lookup above it. Repro'd the three failing files locally (Relayer.BasicFill, Relayer.SlowFill, Relayer.TokenShortfall) — all pass.

Added test/Relayer.LateOriginBlock.ts; the feature had no coverage, which is why this shipped. The last case omits the config field entirely and does fail without the fix.

One thing to flag: LATE_BLOCK_MIN_CONFIRMATIONS = 2 counts blocks built on top (same convention as the MDC check at Relayer.ts:440), so it requires two, not the one its comment claimed. I corrected the comment rather than the value — say the word if you'd rather it were 1.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@pxrl

pxrl commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

@droplet review all comments.

Record block arrival before the misordered-block check. A same-height
replacement is the exact scenario this gate exists to catch, but it takes the
misordered branch, so the replacement's own arrival was never recorded and a
late replacement of a punctual block looked settled. Lateness is now recorded
for every observed block and never lowered for a height, so a second block at
the same height can only make that height more suspect.

Reject malformed and negative RELAYER_MAX_ORIGIN_BLOCK_LATENESS values, which
previously parsed to NaN and silently disabled the check while appearing
configured. Reject it on SVM chains too: the SVM listener reports arrival time
in place of the slot timestamp, so lateness always reads as ~0 there and a
threshold would never fire.

Don't early-exit in simulation mode, matching the confirmation gate above, so
a dry run still evaluates late deposits end to end.

Document the config surface in the relayer README.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Reviewed all 6 Codex comments — I'd missed them earlier because I only pulled the review-scoped endpoint for your review, which was empty. Replied on each thread; 5 were real and are fixed in the new commit.

  • Same-height replacement not recorded (P1) — valid. A replacement takes the misordered branch, so its own arrival was never recorded. Now recorded before the ordering check, and never lowered for a height. The old behaviour was already conservative when the original was the late block; the hole was a late replacement of a punctual one.
  • SVM lateness always ~0 (P1) — valid, and the nastiest of the six. The SVM listener passes getCurrentTime() as the slot timestamp, so a Solana threshold would look configured and never fire. Rejected at startup. EVM and TVM use the real block timestamp.
  • NaN / negative threshold (P2) — valid. Number("oops") silently disabled the check. Now asserted finite and non-negative, matching the deposit-confirmation check in the same file.
  • Missing docs (P1) — valid per AGENTS.md. Documented in the relayer README, including the RELAYER_EXTERNAL_LISTENER dependency.
  • Dry-run early exit (P2) — valid. Now mirrors the confirmation gate's simulation exception.
  • LATE_BLOCK_MIN_CONFIRMATIONS = 2 (P2) — same thing I flagged to you. I've kept 2 and fixed the comment: an operator opting in is trading latency for finality safety, and the successor's own attestations haven't landed. Say the word and it's a one-line change to 1 — tests are written against the constant.

Tests grew to 15; the two new same-height cases both fail without the fix. 66 passing across the affected files, lint and typecheck clean.

One thing to be aware of: .env.example and src/monitor/.env.sample show as deleted in my working tree and were already deleted when the session started. Not mine, so I left them unstaged out of both commits.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@pxrl

pxrl commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

@codex review

Comment thread src/relayer/README.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33d1e17400

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/clients/SpokePoolClient.ts Outdated
The listener posted a block update after querying its events, so the query
duration was counted as block-arrival lateness. On EVM that inflated the
startup head; on TRON, where the head is polled and the range query runs on
every pass, it inflated every block.

Stamping the observation time when the block is seen fixes the interval, but
not the underlying problem: for a block discovered by polling, the interval
between its timestamp and the poll measures poll phase, not how late the block
was published. A punctual block found by a poll looks arbitrarily late.

So carry an optional observedAt on the block update and record lateness only
when it is present. Only the EVM live subscription sets it. The startup scrape
and the TRON head poll do not, and their blocks are treated as settled, which
is the documented behaviour for anything not observed live.

Correct the README, which claimed restarted state was unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Codex came back with one P2 and it was a good one — fixed, plus a worse instance of the same bug it didn't spot.

What it found: the EVM listener reads the head block, scrapes historical events, then posts the block update. So backfill duration was counted as block-arrival lateness, and a slow startup scrape would falsely mark the head as late.

What it missed: TRON has the same shape but inside the poll loop (RelayerSpokePoolListenerTVM.ts:133-169) — read head, run range query, post. So on TRON the query duration inflated every block, not just the startup head.

Why I didn't fix it the way Codex suggested. Stamping the observation time before the scrape corrects the interval, but the deeper problem is that a polled block has no meaningful lateness at all: the interval measures poll phase, not publication lateness. A punctual block polled 8s into a 12s slot reads as 8s late. Recording that would false-positive at a high rate — worse than not measuring.

So the block update now carries an optional observedAt and lateness is recorded only when present. Only the EVM live watchBlocks subscription sets it; the startup scrape and TRON poll omit it and fall through to the existing "not observed live → settled" path. Net effect: the gate only ever fires on chains where we're actually pushed blocks, which is what it was always meant to mean.

Also corrected the README — my earlier claim that restarted state was unaffected was wrong.

23 tests in the feature file, 63 passing across affected files, lint and typecheck clean. Two new cases: polled updates record nothing, and lateness comes from the observation rather than message receipt.

Still open for you: LATE_BLOCK_MIN_CONFIRMATIONS — I've kept it at 2, one line to change if you want 1.

Worth noting one residual: watchBlocks uses emitOnBegin, so the first block after a subscription starts is effectively a poll and can read as late. It's one block per listener start and errs toward deferring, so I left it.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

droplet-rl and others added 2 commits August 26, 2026 07:49
The hold on a late-arriving origin block has to outlast the re-org
notification, not just the replacement. A deposit is only backed out once the
listener reports the removal, and that report trails the replacement by
appreciably more than one block time, so two confirmations can release a
deposit while it is still pending removal.

Co-Authored-By: Claude <noreply@anthropic.com>
- Assert at startup that a configured lateness threshold has the external
  listener enabled. The listener is the only source of block arrival times, so
  the threshold was previously inert without it, documented but unenforced.
- Stop shadowing the outer blockNumber in the retention sweep.
- Correct the README: the first block pushed after a (re)connect can carry an
  inflated interval and be held, so restarts are not wholly unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Two follow-up commits, in response to the review.

LATE_BLOCK_MIN_CONFIRMATIONS 2 → 3. The review is right that the fixed cap is the weak point, but the binding quantity is the notification latency rather than the lateness magnitude. A deposit is only backed out once the listener reports the removal, and that report trails the replacement by more than one block time often enough that two confirmations can release a deposit while its removal is still in flight. Three covers the observed range. The tests reference the constant symbolically, so no test changes were needed for the bump.

Not taking the receivedAt reformulation, for two reasons:

  1. The scaling term only exceeds the confirmation floor once lateness passes roughly three block times. Measured lateness on blocks that were actually replaced sits well inside that, so on the cases we can observe the two formulations are behaviourally identical — the floor does all the work either way.
  2. A plain overwrite of the (timestamp, receivedAt) pair reintroduces the same-height-replacement gap that #33d1e17 closed: δ recomputes against the replacement's timestamp, falls under the threshold, and the height is treated as settled while the removal is still pending. receivedAt is monotonic, but the threshold test reads δ, which isn't — so the monotonicity fix doesn't disappear, it becomes a sticky "was this height ever late" flag. Worth doing if the scaling ever earns its keep, but it isn't a free deletion.

Also folded in the standing items: the externalListener assert (matching the SVM one — previously documented but unenforced), the shadowed blockNumber in the retention sweep, and the README's restart claim, which overstated things: the first block pushed after a (re)connect can still record an inflated interval and be held.

I left the sweep unconditional. Gating it on map size looked like a cheap win but changes eviction from height-distance to size-based, which breaks the retention test's contract for no real gain — the map is bounded by the sweep itself.

Rename the measured quantity: it is the interval between a block's timestamp
and the listener observing it, which conflates the proposer publishing late
with propagation, so naming it after either alone is misleading. "Arrival
delay" stays neutral about the cause and matches the surrounding
recordBlockArrival / BLOCK_ARRIVAL_HISTORY vocabulary.

Retains "late block" where it names the phenomenon rather than the
measurement, that being the established term for a block published after its
slot's attestation deadline.

The operator-facing threshold becomes
RELAYER_MAX_ORIGIN_BLOCK_ARRIVAL_DELAY_<chainId>. No migration needed: the
feature is unreleased and off by default.

Co-Authored-By: Claude <noreply@anthropic.com>
Comment thread src/relayer/RelayerConfig.ts Outdated
Drop the assertion that a configured arrival-delay threshold has the external
listener enabled. Refusing to start is disproportionate for a setting that is
merely inert without the listener, and it makes the threshold awkward to carry
in a shared config across deployments that don't all run one.

Back to documenting it as inert. The test now pins that a threshold parses and
is retained with the listener disabled.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Dropped the external-listener assertion in fc7866b, replied on the thread.

You're right that it was disproportionate — the threshold is merely inert without the listener, not broken, and hard-failing startup makes it awkward to carry in a shared config across deployments that don't all run one. Back to documenting it as inert; the test now pins that a threshold parses and is retained with the listener disabled.

I kept the two adjacent asserts in that block — malformed/negative values, and SVM where there's no slot timestamp to compare against. Those are genuinely broken input rather than a no-op, but say the word if you want either of those gone too.

Note I was a few commits behind when this came in (your comment referenced maxDelay, which I didn't have), so I fast-forwarded onto 098a2512 first — the rename and the three-confirmation change are intact. 18 tests in the feature file, 69 passing across affected files, lint and typecheck clean.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

The arrival-delay gate withholds a deposit for LATE_BLOCK_MIN_CONFIRMATIONS
blocks on the assumption that the removal will be reported within that window.
Nothing measured whether it actually was, so the floor could be outrun by
drifting notification latency without any signal.

On removal of a deposit whose origin block was observed live, report the
arrival delay, the confirmations accrued, the confirmations still to spare, and
the interval from the block's own timestamp to the report. A margin trending to
zero, or a removal landing after the gate released, indicates the floor needs
re-sizing.

Records the block timestamp alongside the arrival delay to express that
interval in seconds. Both fields track the most suspect observation of a
height: the largest delay against the earliest timestamp.

Co-Authored-By: Claude <noreply@anthropic.com>
…orting a removal

The removal log characterised every live-observed block as late-arriving and
framed the confirmations remaining as gate margin. This layer has no access to
the relayer's configured threshold, so it cannot know whether the gate was ever
engaged. The framing inverted in the case the instrumentation exists to catch: a
punctual block filled at its MDC tier and then re-orged is a real invalid fill,
and the log would have reported it as having confirmations to spare.

Report measured facts only -- arrivalDelay, confirmations accrued, and the
interval from the block's timestamp to the report -- and fold them into the
existing removal warn rather than emitting a second line. Establishing whether
the gate applied is left to whoever knows the configured threshold.

Drops confirmationsToSpare: the field name carries the same unfounded claim as
the message did, and it is where an alert rule would have read the false
reassurance.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Fixed in 26bbe8b4. The flag is right, and the second consequence is the one that matters: a punctual block filled at its MDC tier and then re-orged is a real invalid fill, and the log would have reported it as having confirmations to spare — reassurance during exactly the incident class the instrumentation exists to catch.

The log now reports measured facts only, folded into the existing removal warn:

arrivalDelay   how long after its own timestamp the block reached us
confirmations  accrued on the origin block at the time of the report
noticeDelay    seconds from the block's timestamp to the report

Absent entirely for blocks not observed live, so their absence is itself meaningful. Establishing whether the gate applied is left to whoever knows the configured threshold.

One step further than proposed: I also dropped confirmationsToSpare rather than only neutralising the message. The field name carries the same unfounded claim, and a dashboard or alert rule keying on it would read the false reassurance regardless of how the message is worded. confirmations plus the constant recovers it for anyone who has first established that the gate applied.

Agreed on logging all removals rather than gating — that distribution is what produced the arrival-delay figures in the first place. Also took the noise point: one warn per removal now, fields adjacent to the deposit they describe.

Tests re-pointed from message text to fields, including negative assertions that the log does not say "late-arriving" or "to spare", plus a case covering a removal for a block that was never observed live. 20/20 locally.

pxrl
pxrl previously approved these changes Aug 26, 2026
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Thanks — nothing to action, no inline comments on the approval.

Approved commit 26bbe8b4 is the current head, all six checks pass, and the PR is mergeable. Leaving the merge to you.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

The gate has been opt-in, which leaves the default posture exposed to the
failure it addresses. Default mainnet to 5 seconds; chains without an entry stay
disabled, since the arrival delay is only a re-org signal where slot times are
fixed. An explicitly configured 0 still disables the check.

5s sits well above the median mainnet arrival delay but inside the tail that
replaced blocks occupy, so it favours protection over fill latency. That is the
intended trade: a deferral costs a fill, an invalid fill costs the amount
fronted. Holding is bounded by LATE_BLOCK_MIN_CONFIRMATIONS, so a deposit
deferred in error is filled a few blocks later rather than dropped.

Note this turns the gate on by default wherever the external listener runs; it
remains inert without one. Re-tune from the arrival delays the removal logs
report once there is deployment data.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

2d4457fc makes this on-by-default for mainnet at 5 seconds, via DEFAULT_MAX_ORIGIN_BLOCK_ARRIVAL_DELAY in Constants.ts. Chains without an entry stay disabled; an explicitly configured 0 still disables.

This is a behaviour change worth a reviewer's attention: the gate was opt-in and is now active wherever the external listener runs, third-party operators included. It remains inert without a listener.

Sizing, on mainnet measurements: 5s sits at roughly the 90th percentile of arrival delays, so it withholds on the order of 10% of mainnet-origin deposits until their origin block has LATE_BLOCK_MIN_CONFIRMATIONS on top — a few blocks, after which they fill normally. Nothing is dropped. Against that, it covers close to 90% of the arrival-delay range in which blocks that were subsequently replaced have actually been observed.

That is deliberately tighter than the point that maximises protection per deposit withheld, which sits nearer 7s. The rationale is that fill latency is the recoverable cost and an invalid fill is not, and that the current competitive baseline is the wrong reference if comparable mitigations become common. Loosening is a config change, not a code change.

The figures come from indexer ingestion timestamps, which run slightly later than the listener's own observation, so the effective threshold in production will be marginally tighter than these percentiles imply. The arrivalDelay reported with each removal is the signal to re-tune from once there is deployment data.

Tests: 22/22, covering the chain default, no-default chains, and explicit-zero override.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

Thanks — nothing to action, no inline comments again.

Confirming the approval covers the on-by-default change: it's against 2d4457fc, which is the current head, and it landed two minutes after that commit was flagged here as a behaviour change. So mainnet-origin deposits will be withheld at a 5s arrival delay wherever the external listener runs, third-party operators included, on the understanding that's intended.

All six checks pass, PR is mergeable. Leaving the merge to you.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@pxrl
pxrl merged commit 3e13466 into master Aug 26, 2026
7 checks passed
@pxrl
pxrl deleted the droplet/defer-late-origin-blocks branch August 26, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants