Skip to content

fix(attestor,releaser): Recover from nonce gaps, stuck transactions and refused log ranges - #589

Open
ebma wants to merge 11 commits into
mainfrom
fix/daemon-recovery
Open

ebma wants to merge 11 commits into
mainfrom
fix/daemon-recovery

Conversation

@ebma

@ebma ebma commented Oct 27, 2026

Copy link
Copy Markdown
Member

What changes

Attestor

  • Nonce gaps. When an approval waits behind a nonce that nothing will fill (a mined approval whose block was reorged away, or a foreign transaction that left the pool), the follow-up moves the approval down to the empty nonce, or leaves the nonce to the next approval when the migration no longer needs it.
  • Checkpoints must carry both signer fields (attestor address and attestor generation) or neither. One with only one of them is refused with a message pointing at the runbook.
  • Stuck own approval. The "own approval not mined" page repeats 10 minutes after the first page, then at doubling intervals up to every 4 hours, for as long as the transaction stays unmined.
  • Genesis override. A PENDULUM_GENESIS_HASH other than Pendulum's is refused when BASE_CHAIN_ID is 8453.
  • Attestor standing. Membership and attestor generation are read again on every finalized head. The daemon exits when either differs from startup, so the next start asks for the checkpoint to be rewound.

Attestor and releaser

  • A failed alert delivery is retried after 2 s and after 10 s more. A 4xx answer other than 408 and 429 is not retried.

Releaser

  • Log ranges. A ReleasePending log range the endpoint refuses as too large is read again at half the size, down to one block. It doubles again after ten full-size reads, never above MAX_BLOCK_RANGE.
  • Stuck release transactions. A release not mined within FEE_BUMP_AFTER_SECONDS stays in flight. It is re-sent at the same nonce with both fees raised by FEE_BUMP_PERCENT, at most FEE_BUMP_MAX times and never above MAX_FEE_PER_GAS_GWEI, and then pages "release transaction not mined". The settings and defaults match the attestor's and are documented in releaser/README.md.
  • Throttling. Unexpected release failures are throttled per release by BLOCKED_ALERT_INTERVAL_MS, like blocked releases.
  • Startup checks. Startup refuses a POLL_INTERVAL_MS below one second and a READ_BATCH_SIZE below 1.
  • Tests now pin when a pending release may be dropped, the interval throttle of blocked pages and the vault binding of the state file.

Testing

  • npm test in attestor/ (94 pass) and releaser/ (34 pass).
  • An end-to-end rehearsal, together with the open harness, contract and CI changes:
    • Phase 3 on Chopsticks and Anvil, with the monitor: 9/9.
    • Phases 5b, 5 and 6 on a local Pendulum network with Base Sepolia: 10/10, 15/15 and 13/13.
    • Against Base Sepolia's 200-block eth_getLogs limit, the releaser shrank its range from 10,000 by itself and completed every cycle.

ebma added 11 commits October 27, 2026 12:00
An own approval stays at its nonce until it is mined. When the account's
mined nonce stays below it and the node holds nothing in between (a mined
approval whose block was reorged away, or a skipped foreign transaction
that later left the pool), nothing will ever fill the empty nonce, and
every later approval queues behind it until the process restarts.

The follow-up now detects that case and moves the approval down to the
empty nonce, or, when the migration no longer needs it, leaves the nonce
to the next approval. The attempt left behind merely reverts if it is
ever mined.
A checkpoint records the attestor address and its vault attestor
generation together. One without either still loads as written before
the fields existed and is bound to the current key on startup, but one
with only one of them was edited, and binding it would skip the rewind
the identity check asks for. Refuse such a checkpoint with a message
pointing at the runbook.
The "own approval not mined" page went out once per transaction. While
an attestor waits on such a transaction it processes nothing else, so no
other alert follows, and a page whose delivery failed left it silent.

Page again 10 minutes after the first page, then at doubling intervals
up to every 4 hours, for as long as the transaction stays unmined. Repeat
pages say "still not mined (page N)".
A page whose webhook delivery failed (relay down, rate limited, timed
out) was logged and dropped. Try a failed delivery again after 2 s and
after 10 s more. A 4xx answer other than 408 and 429 is not retried:
the relay refused the request itself, which repeating cannot change.
PENDULUM_GENESIS_HASH lets rehearsals run against a local test chain.
Nothing tied the override to a test Base chain, so an environment carried
over from a rehearsal could pin a test chain's genesis while pointing at
the mainnet vault. Refuse any genesis other than Pendulum's when
BASE_CHAIN_ID is 8453.
… running

Membership and attestor generation were read once, at startup. A key
removed and re-added while the daemon ran kept making progress although
its earlier approvals no longer counted, and only the next restart
refused the checkpoint.

Read both again on every finalized head (two calls) and exit when either
differs from startup; the next start then asks for the checkpoint to be
rewound. The startup check uses the same reads.
The ReleasePending scan split its blocks into ranges of MAX_BLOCK_RANGE
(default 10,000) and failed the whole cycle when the endpoint refused
that size. Base Sepolia's public endpoint caps eth_getLogs at 200 blocks,
so every cycle failed and paged until the setting was lowered by hand.

A range the endpoint refuses for its size is now read again at half the
size, down to a single block. After ten full-size reads in a row succeed
the size doubles again, never above MAX_BLOCK_RANGE, which stays the
maximum. Other failures propagate as before. MAX_BLOCK_RANGE must now be
a whole number of at least 1, and each cycle's log line shows the
current size.
Cover the rule that only a consumption read at the finality boundary
drops an entry: a release consumed only at the latest state, or a
NonceAlreadyConsumed from a latest-state simulation, keeps the entry;
a boundary read drops it. Also cover the interval throttle of blocked
pages and the vault binding of the state file.
An unexpected release failure paged on every cycle for every affected
entry, so one persistent condition produced a critical page per entry
per minute on the webhook the attestors share. Throttle it per release
like a blocked one: the same page about a release goes out again only
after BLOCKED_ALERT_INTERVAL_MS, and a different page about it at once.
A release transaction not mined within viem's 180 s receipt wait paged
"unexpected release failure", and the next cycle signed another release
at the next nonce, queued behind the stuck one, until fees fell again.

Sign each release at the pending nonce with the capped fee estimate and
wait FEE_BUMP_AFTER_SECONDS for it. One not mined by then stays in flight:
no other release is sent behind it, and from cycle to cycle it is re-sent
as the identical call at the same nonce with both fees raised, at most
FEE_BUMP_MAX times and never above MAX_FEE_PER_GAS_GWEI, then it pages
"release transaction not mined", throttled like the other release pages.
Once its nonce is used by another transaction, or nothing below it can be
mined, the release is simulated and sent anew. The settings and defaults
match the attestor's.
POLL_INTERVAL_MS=0 turned the cycle loop into a busy loop against the
RPC, and READ_BATCH_SIZE=0 failed every cycle that had anything pending.
Require a poll interval of at least one second and a whole read batch
size of at least 1 at startup.
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