fix(firewall): admit tBTC peers on eligible stake, not legacy delegation - #4288
Draft
lrsaturnino wants to merge 4 commits into
Draft
fix(firewall): admit tBTC peers on eligible stake, not legacy delegation#4288lrsaturnino wants to merge 4 commits into
lrsaturnino wants to merge 4 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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
force-pushed
the
fix/firewall-post-tip092-operator-admission
branch
from
September 5, 2026 05:01
1f44dbf to
d234de2
Compare
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
force-pushed
the
fix/firewall-post-tip092-operator-admission
branch
from
September 5, 2026 05:15
d234de2 to
85c0d98
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
TokenStakingdelegation. TIP-092 removed every function capable of creating one, and no code path in the deployed implementation writes theownerfield 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 inconnected_peers_countsitting 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) > 0instead. 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 theminimumAuthorizationfloor for free. It replaces theRolesOfread 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.
eligibleStakecan 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
RolesOfbeacon.gois unchanged apart from a comment, and the asymmetry is intentional.TokenStaking.authorizedStakeshort-circuits to zero for every application except one hard-coded constant, and the random beacon is not that application, soRandomBeacon.eligibleStakeis 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_URLand 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 inbeacon.gois 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
IsRecognizedpreviously 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
integrationbuild 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_countduring 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.