Skip to content

fix(relayer): correct Solana ALT chunk count; bound EventManager retention - #3726

Open
droplet-rl wants to merge 3 commits into
masterfrom
droplet/relayer-alt-eventmanager
Open

fix(relayer): correct Solana ALT chunk count; bound EventManager retention#3726
droplet-rl wants to merge 3 commits into
masterfrom
droplet/relayer-alt-eventmanager

Conversation

@droplet-rl

Copy link
Copy Markdown
Contributor

Two independent robustness fixes in relayer utils:

Solana ALT off-by-one (getAddressLookupTableInstructions). nInstructions = Math.floor(len / 32) + 1 always appended one extra chunk; when lutAddresses.length is an exact multiple of 32 (including 0) the final slice(offset, offset) was empty, producing an ExtendLookupTable instruction with zero addresses, which the on-chain address-lookup-table program rejects — failing Solana relayer-refund-leaf execution whenever the leaf's account count lands on a multiple of 32. Changed to Math.ceil(len / 32).

EventManager unbounded growth (EventManager). events and blockHashes accumulated one entry per observed log/block for the whole process lifetime with no eviction, and remove() (only triggered by reorg 'removed' notifications) deleted from events but left the blockHashes[blockHash] bucket behind. remove() now also drops the stale bucket, and a re-org-safe retention window (MAX_EVENT_RETENTION_BLOCKS behind the highest observed block) prunes old records as the head advances, so memory stays bounded. The retention window is chosen to comfortably exceed any monitored chain's re-org depth so pruning can never drop an event still eligible for a 'removed' notification or a late quorum vote.

Build (tsc --build), prettier and eslint pass.

…ntion

- getAddressLookupTableInstructions used Math.floor(len/32)+1, always appending one extra chunk;
  when the account count was an exact multiple of 32 (including 0) the final chunk was an empty
  slice, producing an ExtendLookupTable instruction with zero addresses that the on-chain ALT
  program rejects, failing relayer-refund-leaf execution. Use Math.ceil.

- EventManager.events/blockHashes grew for the process lifetime with no eviction (remove() only
  fired on reorg 'removed' notifications and even then leaked the blockHashes bucket key). Fix
  remove() to drop the stale blockHash bucket, and prune event/blockHash records older than a
  re-org-safe retention window as the head advances so memory stays bounded.

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

@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: cc6ed82a11

ℹ️ 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/utils/EventUtils.ts Outdated
Comment on lines +213 to +214
const bucketBlockNumber = eventKeys.map((eventKey) => this.events[eventKey]?.blockNumber).find(isDefined);
if (!isDefined(bucketBlockNumber) || bucketBlockNumber < cutoff) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve late quorum votes across the retention cutoff

When one provider continues reporting events more than 5,000 blocks ahead while another provider's stream is stalled or buffered, this eviction deletes the first provider's vote for every older event. When the delayed stream resumes, each old event is recorded as only its first vote and can never reach quorum because the leading provider will not report it again; both EventListener.onEvents and the SVM listener therefore permanently omit those deposits/fills until a restart and backfill. Retain incomplete-quorum records independently or otherwise reconcile late events rather than evicting them solely by the highest observed block.

Useful? React with 👍 / 👎.

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.

Valid — fixed in 694a20e.

Anchoring eviction on the highest observed block was wrong: a provider lagging more than MAX_EVENT_RETENTION_BLOCKS would find the leading provider's vote already gone, record a fresh single-vote entry, and never reach quorum.

Eviction is now anchored on the slowest provider: EventManager tracks each provider's highest observed block and only evicts a record once every provider has advanced a full retention window past it, so a late vote can always land on an existing record.

I did not take the "retain incomplete-quorum records independently" option — those are exactly the unbounded set (a provider reporting events the others never will has no terminating condition), so exempting them reinstates the leak this PR set out to fix.

To keep the memory bound real, the anchor is clamped to MAX_PROVIDER_LAG_BLOCKS (5k) behind the head, capping retention at 10k blocks. A provider stalled beyond that stops holding retention open — unavoidable for any bounded-memory scheme — but it is now logger.warn'd (rate-limited to one warning per 5k blocks of head progression) instead of silently dropping votes.

Three tests added in test/EventManager.ts; the late-vote one fails against the previous head-anchored cutoff (verified by reverting the anchor locally) and passes now.

Pruning was anchored on the highest observed block, so a provider lagging more than
MAX_EVENT_RETENTION_BLOCKS behind the head would have its late quorum votes dropped: the
leading provider's vote was already evicted, the late vote recorded a fresh single-vote
record, and the event could never reach quorum — silently omitting deposits/fills from
both EventListener.onEvents and the SVM listener until a restart and backfill.

Track each provider's highest observed block and anchor eviction on the slowest of them,
so an event is only evicted once every provider has advanced a full retention window past
it. The anchor is clamped to MAX_PROVIDER_LAG_BLOCKS behind the head so a stalled or dead
provider can't hold retention open indefinitely; that case is now logged (rate-limited)
rather than silently dropping votes.

Tests cover eviction past the retention window, a lagging provider's late vote still
reaching quorum, and the memory bound holding when a provider stalls for good.

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

Copy link
Copy Markdown
Contributor Author

@codex Addressed the P2 in 694a20e — please re-review.

Preserve late quorum votes across the retention cutoff — valid, fixed.

Anchoring eviction on the highest observed block meant a provider lagging more than MAX_EVENT_RETENTION_BLOCKS would find the leading provider's vote already evicted, record a fresh single-vote entry, and never reach quorum — silently omitting deposits/fills from both EventListener.onEvents and the SVM listener until a restart and backfill.

EventManager now tracks each provider's highest observed block and anchors eviction on the slowest of them, so a record is only dropped once every provider has advanced a full retention window past it and no live provider's vote can still arrive.

I did not take the "retain incomplete-quorum records independently" option: those are exactly the unbounded set (a provider reporting events the others never will has no terminating condition), so exempting them reinstates the leak this PR set out to fix. Instead the anchor is clamped to MAX_PROVIDER_LAG_BLOCKS (5k) behind the head, capping retention at 10k blocks. A provider stalled beyond that no longer holds retention open — unavoidable under any bounded-memory scheme — but it's now logger.warn'd (rate-limited to one warning per 5k blocks of head progression) rather than dropping votes silently.

Three tests added to test/EventManager.ts: eviction past the retention window, a lagging provider's late vote still reaching quorum, and the memory bound holding when a provider stalls for good. The late-vote test fails against the previous head-anchored cutoff (verified by reverting the anchor locally) and passes now.

yarn typecheck, eslint, prettier and hardhat test test/EventManager.ts (8 passing) all clean. No changes to the Solana ALT fix.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@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: 694a20e83a

ℹ️ 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/utils/EventUtils.ts Outdated
if (!(blockNumber > (this.providerBlockNumbers[provider] ?? 0))) {
return; // This provider hasn't advanced, so the cutoff can't have moved.
}
this.providerBlockNumbers[provider] = blockNumber;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track progress per event subscription

When quorum is greater than one and a provider's FundsDeposited subscription stalls while its FilledRelay subscription continues, this single provider watermark advances from the healthy subscription. EventListener.ts:115-134 creates a separate watchEvent stream for every event descriptor but identifies all of them only by provider.name, so the cutoff can evict another provider's deposit vote before the delayed deposit callback arrives; that callback then starts a fresh one-vote record and the deposit is silently omitted, with no stalled-provider warning because the provider appears current. Fresh evidence after 694a20e is this per-event subscription split, so progress must be tracked per subscription or derived from a signal covering all watched logs.

Useful? React with 👍 / 👎.

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.

Confirmed and fixed in 6a1f7ef.

onEvents() does open one watchEvent stream per (address, event) pair while keying progress on provider.name alone, so a provider whose FilledRelay stream kept flowing advanced the watermark used to evict pending FundsDeposited votes — and, as you note, it never appeared stalled, so the warning never fired either.

Watermarks are now keyed per subscription (${address}-${event}), which is exactly the granularity of a viem watchEvent stream, and eviction only sweeps events belonging to the subscription that advanced. Progress on one stream can no longer evict another's pending votes, and the stalled-provider warning is likewise scoped per subscription.

Note this also removes a false-positive source: a legitimately quiet subscription (e.g. RelayedRootBundle) no longer looks stalled just because a busy one raced ahead. Its own records are held until its own providers advance, and since no new events arrive on a quiet stream that retention is naturally bounded.

Test Tracks provider progress per subscription covers it; it fails against 694a20e.

Comment thread src/utils/EventUtils.ts Outdated
Comment on lines +233 to +236
const bucketBlockNumber = eventKeys.map((eventKey) => this.events[eventKey]?.blockNumber).find(isDefined);
if (!isDefined(bucketBlockNumber) || bucketBlockNumber < cutoff) {
eventKeys.forEach((eventKey) => delete this.events[eventKey]);
delete this.blockHashes[blockHash];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Key SVM retention buckets by slot

In the SVM listener, logFromEvent assigns blockHash: "" to every log (RelayerSpokePoolListenerSVM.ts:38-62), so every Solana event lands in the same bucket. Once that bucket's oldest event crosses the cutoff, this branch treats the whole bucket as stale and deletes all its event keys, including events from the newest 5,000 slots that are still awaiting another provider's vote; a later vote then recreates a one-vote entry and never reaches quorum, silently dropping FundsDeposited or FilledRelay. Bucket SVM events by slot or evaluate each event independently instead of assuming every key in a block-hash bucket shares the sampled block number.

Useful? React with 👍 / 👎.

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.

Correct, and this was the more serious of the two — fixed in 6a1f7ef.

UNUSED_FIELDS in RelayerSpokePoolListenerSVM.ts does set blockHash: "", so every Solana event landed in one bucket; sampling the oldest still-present member and deleting the whole bucket would have wiped events from the newest slots that were still awaiting a second vote.

Two changes:

  1. Eviction is now per event, keyed on each event's own blockNumber, instead of per bucket with a sampled block number. Bucket compaction happens afterwards in a single pass over blockHashes (filtering against a Set of evicted keys) so it stays linear rather than quadratic on a large bucket.
  2. Logs with an empty blockHash are no longer bucketed at all. The bucket exists solely to service re-org removals, which are keyed on blockHash and never fire for SVM — so an empty key was pure downside: it accumulated every event and would have dropped all of them on a single remove().

Test Evicts events individually when logs share a blockHash bucket covers it; it fails against 694a20e.

Unrelated pre-existing issue I noticed while checking this, not touched here: logFromEvent also pins logIndex: 0, so getEventKey() collides for two same-named events in one Solana transaction (FundsDeposited--<signature>-0). Worth a separate fix — happy to open an issue if useful.

…r event

Two further defects in the retention bound, both silently costing quorum:

- Progress was tracked per provider, but EventListener.onEvents() opens a separate
  watchEvent stream per (address, event) pair. A provider whose FilledRelay stream kept
  flowing therefore advanced the watermark used to evict pending FundsDeposited votes, even
  though its deposit stream was stalled — and it never looked stalled, so no warning fired.
  Watermarks are now keyed per subscription, so one stream's progress can't evict another's
  pending votes.

- Eviction operated on blockHash buckets, sampling one event's block number for the whole
  bucket. SVM logs carry no blockHash (UNUSED_FIELDS sets it to ""), so every Solana event
  shared a single bucket: once its oldest member aged out, the entire bucket was deleted,
  including events from the newest slots still awaiting a second vote. Eviction is now
  per event, keyed on each event's own block number, and logs without a blockHash are no
  longer bucketed at all — the bucket exists only to service re-org removals, which are
  keyed on blockHash and never fire for SVM.

Tests cover both: cross-subscription isolation of provider progress, and individual
eviction when every log shares a bucket. Both fail against 694a20e.

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

Copy link
Copy Markdown
Contributor Author

@codex Both findings were real; fixed in 6a1f7ef. Please re-review.

P1 — Key SVM retention buckets by slot. UNUSED_FIELDS sets blockHash: "", so every Solana event shared one bucket, and evicting a bucket on its oldest member's block number would have wiped events from the newest slots still awaiting a second vote. Eviction is now per event, keyed on each event's own blockNumber; bucket compaction runs afterwards in one linear pass rather than splicing per key. Logs with an empty blockHash are no longer bucketed at all — the bucket only services re-org removals, which are keyed on blockHash and never fire for SVM, so an empty key was pure downside.

P2 — Track progress per event subscription. EventListener.onEvents() opens one watchEvent stream per (address, event) pair but keyed progress on provider.name, so a healthy FilledRelay stream advanced the watermark that evicted pending FundsDeposited votes — with no stalled-provider warning, since the provider looked current. Watermarks are now keyed per subscription, eviction only sweeps the subscription that advanced, and the stalled warning is scoped the same way. Side benefit: a legitimately quiet subscription no longer looks stalled because a busy one raced ahead.

Two tests added, one per finding; both fail against 694a20e and pass now (verified by checking the previous EventUtils.ts back out and re-running). Full test/EventManager.ts (10 passing) and test/Relayer.IndexedSpokePoolClient.ts (6 passing) are green, as are yarn typecheck, eslint and prettier.

One adjacent pre-existing bug I did not touch, flagged on the inline thread: logFromEvent also pins logIndex: 0, so getEventKey() collides for two same-named events in a single Solana transaction. Out of scope here — say the word and I'll open an issue or a follow-up PR.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 694a20e83a

ℹ️ 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".

@droplet-rl

Copy link
Copy Markdown
Contributor Author

This pass is against 694a20e83a, which is no longer the head of this PR — the current head is 6a1f7ef47d. So it doesn't cover the latest diff, and no changes are warranted in response to it.

Worth flagging for anyone reading the thread: 694a20e83a is the same commit Codex reviewed in #pullrequestreview-4942270929, where it raised a P1 (SVM events sharing one blockHash: "" bucket, so evicting the bucket wiped recent events still awaiting a second vote) and a P2 (per-provider watermarks vs. per-(address, event) viem subscriptions). Both were real — I reproduced each with a failing test before fixing them. So "no major issues" on that identical tree contradicts its own earlier verdict rather than clearing anything; either way it says nothing about 6a1f7ef.

Not re-tagging the bot, since I already requested a re-review of 6a1f7ef on the previous comment and this came back pointed at the old SHA. If you want its opinion on the current head, a @codex review comment should re-trigger it.

Standing verification for 6a1f7ef is unchanged and came from the tests, not the bot: test/EventManager.ts 10 passing (including one regression test per finding, both confirmed failing against 694a20e), test/Relayer.IndexedSpokePoolClient.ts 6 passing, plus yarn typecheck, eslint and prettier clean.

One open item from that commit, unaddressed by design: logFromEvent pins logIndex: 0, so getEventKey() collides for two same-named events in a single Solana transaction. Pre-existing and out of scope here — happy to open an issue or follow-up PR on request.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@chatgpt-codex-connector

Copy link
Copy Markdown

Outcome

  • Confirmed the checkout is already at the current PR head, 6a1f7ef47d0aeb2b39eb88031deac1850cf02e97.
  • The trigger explicitly states that no changes are warranted, so I made no code changes, commit, or follow-up pull request.
  • The pre-existing yarn.lock modification was left untouched because it was not part of the requested work.

Checks

  • git rev-parse HEAD
  • git status --short --branch
  • git diff --stat
  • git diff -- yarn.lock | sed -n '1,80p'

View task →

@droplet-rl

Copy link
Copy Markdown
Contributor Author

Acknowledged — nothing further needed here.

One clarification on the yarn.lock note: that modification is local to the reviewer's sandbox (an install regenerating the lockfile), not something on this branch. The PR at 6a1f7ef touches exactly three files:

  • src/utils/AddressUtils.ts (+4/-1)
  • src/utils/EventUtils.ts (+124/-1)
  • test/EventManager.ts (+120/-3)

No lockfile change is in the diff, so there's nothing to revert or exclude. This is ready for human review.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

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.

1 participant