Skip to content

fix(firewall): admit tBTC peers on eligible stake, not legacy delegation - #4288

Draft
lrsaturnino wants to merge 4 commits into
mainfrom
fix/firewall-post-tip092-operator-admission
Draft

fix(firewall): admit tBTC peers on eligible stake, not legacy delegation#4288
lrsaturnino wants to merge 4 commits into
mainfrom
fix/firewall-post-tip092-operator-admission

Conversation

@lrsaturnino

@lrsaturnino lrsaturnino commented Sep 5, 2026

Copy link
Copy Markdown
Member

Closes #4287.

Problem

The tBTC branch of the peer-admission firewall decides whether to accept a peer by asking whether that peer's staking provider holds a legacy TokenStaking delegation. TIP-092 removed every function capable of creating one, and no code path in the deployed implementation writes the owner field the check reads, so the qualifying set is frozen in both directions. Providers onboarded before the freeze are admitted permanently; providers onboarded after it can never be admitted, however they are authorized.

The effect is that a correctly authorized operator cannot exchange a single message with any peer, while every on-chain check reports it healthy — isOperatorInPool, isOperatorUpToDate, allowlist weight and sortition weight all look correct. The failure is visible only in client logs and in connected_peers_count sitting at zero while the connect and disconnect counters climb together.

This is live on mainnet, and it affects every future operator onboarding and key rotation, not one operator.

Solution

Decide the tBTC branch on WalletRegistry.eligibleStake(stakingProvider) > 0 instead. That is the value the registry already uses for sortition weight, it tracks the current authorization source rather than a frozen historical field, and it carries the minimumAuthorization floor for free. It replaces the RolesOf read rather than adding to it, so the call count is unchanged, and the binding already exists — no regeneration.

This is a liveness fix, not a tightening. Measured over the union of all operator addresses ever registered on either registry, the combined admitted set is unchanged at 282: one operator gains admission (the one currently locked out) and one dormant address loses it (no allowlist weight, no beacon registration, in neither pool). Nineteen of the twenty providers with non-zero eligible stake are already admitted via the beacon branch regardless of this predicate. Please don't read it as narrowing the boundary — it doesn't.

eligibleStake can only be raised by the owner of the authorization source the registry reads, so an arbitrary address that self-registers an operator still reads zero and is still rejected. Registering an operator is permissionless on both registries and never was the boundary.

The error path stays fail-closed, returning (false, err). Collapsing a transient RPC failure into "not recognized" would enter the firewall's one-hour negative cache as a durable result and lock out a healthy peer for that hour.

The beacon branch deliberately keeps RolesOf

beacon.go is unchanged apart from a comment, and the asymmetry is intentional. TokenStaking.authorizedStake short-circuits to zero for every application except one hard-coded constant, and the random beacon is not that application, so RandomBeacon.eligibleStake is zero for every staking provider that has ever registered a beacon operator. Applying this predicate there would recognize nobody. Since the watchtower re-runs the same check against every connected peer every ten minutes and disconnects on failure, a symmetric change would take the fleet apart inside a single round.

An integration test asserts exactly that premise against pinned mainnet state, so the reasoning is checkable rather than merely asserted. Note that it needs an archive endpoint via ETHEREUM_MAINNET_RPC_URL and skips without one — CI does not currently supply that, so it will not run there until the endpoint is wired in. Until then the comment in beacon.go is what stops a future refactor harmonizing the asymmetry away, and the test is a manual check rather than an automated guard.

Second commit: a stale bootstrap seed

Separately, one entry in the embedded mainnet peer list points at a host that is no longer operated, for an operator identity that has since been replaced. The list is fed into DHT bootstrap as a routing seed, so every default-configured node dials that address on start and uses it to populate its routing table. The entry is removed, leaving nineteen seeds.

It is kept as its own commit so it can be reverted independently of the predicate change. Discovery is unaffected — the list is a seed set rather than a peer requirement, and any node can override it entirely with network.peers.

Tests

Unit tests cover the predicate through a narrow two-method seam over the registry reads, added because IsRecognized previously had no way to be exercised without a chain behind it: an authorized provider with no legacy delegation is admitted; a pending decrease sitting exactly on the minimum authorization stays admitted; an unregistered operator and an unauthorized one are rejected; a lookup failure surfaces as an error rather than a silent non-recognition, and is not cached as one; a nil amount is treated as no stake rather than panicking.

Integration tests behind the integration build tag pin the chain-state cases: that the beacon's eligible stake is zero for every known provider, that a provider authorized after legacy staking froze is now admitted, that a pending decrease at the floor holds, and that deprecated operators keep beacon admission and so are unaffected in production.

One test asserts current behaviour rather than desired behaviour: an identity carrying a legacy delegation but zero eligible stake remains admitted through the beacon branch after this change. That gap is real and is not closed here — see below.

What this does not do

It does not close the security half of the incident behind #4287. An identity holding a pre-TIP-092 delegation retains admission through the beacon branch regardless of its current authorization, and this change does not affect that. It is tracked separately; the test above documents it so it isn't quietly assumed closed.

Rollout

The fix takes effect on the responding peers, not on the affected operator, so partial rollout gives partial connectivity — acceptable and monotonic. Upgrade the embedded bootstrap peers first, since the affected operator is not in the peer list and has to reach the fleet by dialling them.

Expected effect on the admitted set is net zero. Watch fleet-wide connected_peers_count during the window: any drop toward twenty means the beacon branch has broken, and the change should be reverted. Reverting reintroduces nothing — it restores the current lockout, and the legacy-delegation admission gap described above is unaffected either way.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The tBTC branch of the peer-admission firewall asked whether a peer's
staking provider held a legacy token staking delegation. Every function
that could create one has since been removed from the deployed staking
contract, and no code path writes the owner field it reads, so the
qualifying set is frozen in both directions. Providers onboarded before
the freeze are admitted forever; providers onboarded after it can never
be, no matter how they are authorized. A correctly authorized operator
therefore cannot exchange a single message with any peer, while every
on-chain check reports it healthy.

Decide the tBTC branch on eligible stake instead. That is the value the
wallet registry already uses for sortition weight, it tracks the current
authorization rather than a historical artifact, and it can only be
raised by the owner of the authorization source. Registering an operator
stays permissionless on both registries and so grants nothing on its
own. The read replaces the delegation read rather than joining it, so
recognition still costs the same two calls.

The random beacon deliberately keeps the delegation predicate. Token
staking reports no authorized stake for it, so its eligible stake is
zero for every provider that has ever registered a beacon operator;
giving it this predicate would recognize nobody and the watchtower would
disconnect the fleet within a single round. A comment there records why.
An integration test asserts the same premise against pinned mainnet
state, but it needs an archive endpoint and is skipped without one, so
the comment rather than the test is what guards the asymmetry against a
future refactor.

The two reads recognition performs move behind a narrow interface. The
predicate had no seam at all, which is why none of its behaviour was
under test before.
The embedded mainnet peer list is fed into DHT bootstrap as a routing
seed, so every default-configured node dials each entry on start and
uses it to populate its routing table. One entry points at a host that
is no longer operated, for an operator identity that has since been
replaced, which means the whole fleet keeps seeding its routing table
from an address nobody controls any more.

Remove that entry, leaving nineteen seeds. Discovery is unaffected: the
list is a seed set, not a peer requirement, and any node may override it
entirely via network.peers. Kept as its own commit so it can be reverted
without touching anything else.
The pending-decrease integration check filtered providers down to those
whose eligible stake equals the minimum authorization, then asserted that
same value was positive - unreachable by construction, so the case it
claimed to guard was never exercised.

Assert the invariant that actually carries the guarantee instead: the
predicate admits on eligible stake above zero, so a zero minimum
authorization would silently reject every provider sitting on the floor.

Also make the staking-provider lookup error wrappable, matching the
eligible-stake error beside it.
@lrsaturnino
lrsaturnino force-pushed the fix/firewall-post-tip092-operator-admission branch from 1f44dbf to d234de2 Compare September 5, 2026 05:01
The staking-provider lookup wraps its chain error so the cause stays
reachable through errors.Is, but the test covering that path only
asserted an error came back at all, so unwrapping it would have gone
unnoticed. The sibling eligible-stake test already pins its own wrap;
only this path was exposed.

Note the firewall does not depend on the wrapping to tell a transport
fault from a genuine non-recognition - it keys on its ErrNotRecognized
sentinel - so these assertions are the only thing holding either wrap
in place.
@lrsaturnino
lrsaturnino force-pushed the fix/firewall-post-tip092-operator-admission branch from d234de2 to 85c0d98 Compare September 5, 2026 05:15
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.

Peer firewall blocks any operator onboarded after TIP-092 from joining the network

1 participant