Skip to content

Commit 5d1c2ed

Browse files
authored
Disable staleness watchdog for frozen Moonbeam chain (#27)
Moonbeam stopped producing blocks at 16,796,699 (0x1004c1b). Because the chain will never produce a new block, the WSConn staleness watchdog was closing every connection within 120s and the NodeProxy was spawning a replacement — both pointing at the same healthy provider, which never got a newHeads frame to satisfy the freshness check. The repeated :DOWN messages and the new connections' open_loop handshakes swamped the Moonbeam NodeProxy mailbox (observed at >500k pending messages on as1), which in turn made every eth_getCode from FleetValidation time out at 25s and crash the EdgeV2 ticket handler. Make the staleness opt-in per chain: - Chains.Moonbeam.frozen?/0 and final_block_number/0 declare the chain's permanent head. - RemoteChain.frozen?/1 and final_block_number/1 default to false/nil for any chain that doesn't opt in, so Base, Diode, Oasis and Anvil are unaffected. - RemoteChain.WSConn.stale_at?/3 and the :ping handler short-circuit to false for frozen chains. The subscription_id guard is preserved so a real disconnect still tears the WSConn down. - RemoteChain.ChainList.block_current?/2 accepts the final block on a frozen chain regardless of timestamp age, so endpoint re-probes on the 5-minute TTL don't reject the provider as 'stopped advancing'. Regression tests cover the new opt-in behaviour, the staleness short-circuit, and the block_current? branch for frozen chains.
1 parent 0fdb380 commit 5d1c2ed

7 files changed

Lines changed: 214 additions & 13 deletions

File tree

lib/chains/chains.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ end
132132
defmodule Chains.Moonbeam do
133133
alias DiodeClient.{Base16, Hash}
134134

135+
@doc """
136+
Last block Moonbeam produced before the chain halted. Treated as the
137+
permanent head by `RemoteChain` so the staleness watchdog does not keep
138+
evicting healthy connections to a chain that will never produce another
139+
block. Update this if/when the chain resumes or hard-forks.
140+
"""
141+
@final_block_number 16_796_699
142+
135143
def chain_id(), do: 1284
136144
def expected_block_intervall(), do: 6
137145
def epoch(n), do: Chains.epoch(__MODULE__, n)
@@ -142,6 +150,17 @@ defmodule Chains.Moonbeam do
142150
def epoch_duration(), do: 2_592_000
143151
def chain_prefix(), do: "glmr"
144152

153+
@doc """
154+
Whether this chain has stopped producing blocks permanently. When true,
155+
the WSConn staleness watchdog is disabled (the chain will never satisfy
156+
it) and the provider's `latest` block must match `final_block_number/0`
157+
for endpoint health checks.
158+
"""
159+
def frozen?, do: true
160+
161+
@doc "The final block number of this chain. Only meaningful when `frozen?/0` is true."
162+
def final_block_number, do: @final_block_number
163+
145164
def additional_endpoints() do
146165
~w(
147166
https://moonbeam.api.onfinality.io/public

lib/remote_chain/chain_list.ex

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,30 @@ defmodule RemoteChain.ChainList do
213213
skew is bounded separately by `@max_future_skew_seconds` because the
214214
staleness predicate only checks how stale a `lastblock_at` is — never how
215215
far in the future it sits.
216+
217+
Frozen chains (see `RemoteChain.frozen?/1`) have a fixed head; any
218+
provider that returns a block matching `RemoteChain.final_block_number/1`
219+
is considered current regardless of timestamp age, because age and
220+
staleness are not meaningful when the chain will never produce a newer
221+
block.
216222
"""
217-
def block_current?(chain, %{"timestamp" => timestamp}) when is_binary(timestamp) do
223+
def block_current?(chain, block) when is_map(block) do
224+
cond do
225+
RemoteChain.frozen?(chain) ->
226+
final = RemoteChain.final_block_number(chain)
227+
is_integer(final) and current_by_block_number?(block, final)
228+
229+
is_binary(block["timestamp"]) ->
230+
block_current_by_timestamp(chain, block["timestamp"])
231+
232+
true ->
233+
false
234+
end
235+
end
236+
237+
def block_current?(_chain, _block), do: false
238+
239+
defp block_current_by_timestamp(chain, timestamp) do
218240
block_ts = Base16.decode_int(timestamp)
219241
now = System.os_time(:second)
220242
age = now - block_ts
@@ -223,7 +245,11 @@ defmodule RemoteChain.ChainList do
223245
not RemoteChain.WSConn.stale_at?(block_age_to_lastblock_at(now, age), chain)
224246
end
225247

226-
def block_current?(_chain, _block), do: false
248+
defp current_by_block_number?(%{"number" => number}, final) when is_binary(number) do
249+
Base16.decode_int(number) == final
250+
end
251+
252+
defp current_by_block_number?(_block, _final), do: false
227253

228254
@doc false
229255
def timestamp_current?(max_age_seconds, block_timestamp)

lib/remote_chain/remote_chain.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,33 @@ defmodule RemoteChain do
9696
chainimpl(chain) != Chains.Moonbeam
9797
end
9898

99+
@doc """
100+
Whether the chain has stopped producing blocks permanently.
101+
102+
Opt-in per chain via `Chain.frozen?/0`; defaults to `false` for chains
103+
that do not declare the function. Used to disable the WSConn staleness
104+
watchdog and the endpoint freshness check, both of which would otherwise
105+
reject healthy connections to a chain whose head is fixed.
106+
"""
107+
def frozen?(chain) do
108+
impl = chainimpl(chain)
109+
Code.ensure_loaded?(impl) and function_exported?(impl, :frozen?, 0) and impl.frozen?()
110+
end
111+
112+
@doc """
113+
The final block number of a frozen chain, or `nil` for chains that are
114+
still producing blocks (or have not declared `Chain.final_block_number/0`).
115+
"""
116+
def final_block_number(chain) do
117+
impl = chainimpl(chain)
118+
119+
cond do
120+
not Code.ensure_loaded?(impl) -> nil
121+
not function_exported?(impl, :final_block_number, 0) -> nil
122+
true -> impl.final_block_number()
123+
end
124+
end
125+
99126
@doc false
100127
def execution_reverted_rpc_error do
101128
%{"code" => -32000, "message" => "execution reverted"}

lib/remote_chain/ws_conn.ex

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,25 @@ defmodule RemoteChain.WSConn do
4848
overrides the multiplier — used by `NodeProxy` for its eviction pass (two
4949
ping cycles, `@stale_eviction_intervals`).
5050
51+
Frozen chains (see `RemoteChain.frozen?/1`) never satisfy this predicate:
52+
the chain is not producing blocks, so `lastblock_at` stays at connect
53+
time and would otherwise trigger constant evictions of healthy
54+
connections. Provider health is still enforced by TCP/WS disconnects
55+
and the subscription handshake (`handle_info(:ping, ...)`).
56+
5157
This is the single threshold shared by `stale?/2` (pid-based), the
5258
`:ping` close handler, `NodeProxy`'s consensus and eviction logic, and
5359
`ChainList.block_current?/2`.
5460
"""
5561
def stale_at?(lastblock_at, chain, intervals \\ @stale_threshold_intervals) do
56-
case lastblock_at do
57-
nil ->
62+
cond do
63+
RemoteChain.frozen?(chain) ->
64+
false
65+
66+
is_nil(lastblock_at) ->
5867
false
5968

60-
%DateTime{} ->
69+
true ->
6170
age = DateTime.diff(DateTime.utc_now(), lastblock_at, :second)
6271
age > chain.expected_block_intervall() * intervals
6372
end
@@ -276,16 +285,24 @@ defmodule RemoteChain.WSConn do
276285
raise "No subscription id received, aborting connection with #{ws_url}"
277286
end
278287

279-
if stale_at?(state.lastblock_at, chain) do
280-
{:message_queue_len, len} = Process.info(self(), :message_queue_len)
288+
# Frozen chains: the staleness predicate can never be satisfied, so
289+
# skip it. The subscription_id check above still guards against a
290+
# WSConn that lost its `eth_subscribe("newHeads")` confirmation.
291+
cond do
292+
RemoteChain.frozen?(chain) ->
293+
{:ok, state}
281294

282-
Logger.warning(
283-
"WSConn #{inspect({self(), len})} block timeout #{chain} (#{ws_url}). Restarting..."
284-
)
295+
stale_at?(state.lastblock_at, chain) ->
296+
{:message_queue_len, len} = Process.info(self(), :message_queue_len)
285297

286-
{:close, state}
287-
else
288-
{:ok, state}
298+
Logger.warning(
299+
"WSConn #{inspect({self(), len})} block timeout #{chain} (#{ws_url}). Restarting..."
300+
)
301+
302+
{:close, state}
303+
304+
true ->
305+
{:ok, state}
289306
end
290307
end
291308

test/remote_chain/chain_list_test.exs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,50 @@ defmodule RemoteChain.ChainListTest do
181181
end
182182
end
183183

184+
describe "block_current?/2 for frozen chains" do
185+
# Regression: Moonbeam stopped producing blocks at 16_796_699. A
186+
# healthy provider's `eth_getBlockByNumber("latest")` returns that
187+
# block with an ancient timestamp; the age-based check rejects it,
188+
# which causes the endpoint probe to fail on every TTL refresh.
189+
test "accepts the final block on a frozen chain regardless of timestamp" do
190+
final = RemoteChain.final_block_number(Chains.Moonbeam)
191+
ancient = System.os_time(:second) - 365 * 24 * 3600
192+
193+
assert RemoteChain.ChainList.block_current?(Chains.Moonbeam, %{
194+
"number" => "0x" <> Integer.to_string(final, 16),
195+
"timestamp" => hex_timestamp(ancient)
196+
})
197+
end
198+
199+
test "rejects a block whose number does not match the final block on a frozen chain" do
200+
# Provider reports a block newer than the freeze → must be wrong
201+
# (or the chain has resumed and the final_block_number is stale).
202+
final = RemoteChain.final_block_number(Chains.Moonbeam)
203+
wrong = "0x" <> Integer.to_string(final + 1, 16)
204+
205+
refute RemoteChain.ChainList.block_current?(Chains.Moonbeam, %{
206+
"number" => wrong,
207+
"timestamp" => hex_timestamp(System.os_time(:second))
208+
})
209+
end
210+
211+
test "rejects a frozen-chain block without a decodable number" do
212+
refute RemoteChain.ChainList.block_current?(Chains.Moonbeam, %{
213+
"timestamp" => hex_timestamp(System.os_time(:second))
214+
})
215+
end
216+
217+
test "still applies the timestamp check to non-frozen chains" do
218+
# Sanity check: a 24h-old block on Anvil (15s cadence) is rejected.
219+
stale = System.os_time(:second) - 24 * 3600
220+
221+
refute RemoteChain.ChainList.block_current?(Chains.Anvil, %{
222+
"number" => "0x1",
223+
"timestamp" => hex_timestamp(stale)
224+
})
225+
end
226+
end
227+
184228
describe "timestamp_current?/2" do
185229
test "accepts blocks within max age, rejects older ones" do
186230
now = System.os_time(:second)

test/remote_chain/remote_chain_test.exs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,36 @@ defmodule RemoteChainTest do
7373
assert RemoteChain.rpc_endpoints(Chains.Diode) == ["http://local-override.example:3834"]
7474
end
7575
end
76+
77+
describe "frozen?/1" do
78+
test "returns true for chains that opt in via frozen?/0 (Moonbeam)" do
79+
assert RemoteChain.frozen?(Chains.Moonbeam)
80+
end
81+
82+
test "returns false for chains that do not declare frozen?/0" do
83+
refute RemoteChain.frozen?(Chains.Diode)
84+
refute RemoteChain.frozen?(Chains.OasisSapphire)
85+
refute RemoteChain.frozen?(Chains.Base)
86+
end
87+
88+
test "accepts chain_id and chain prefix dispatch" do
89+
assert RemoteChain.frozen?(Chains.Moonbeam.chain_id())
90+
assert RemoteChain.frozen?("glmr")
91+
end
92+
end
93+
94+
describe "final_block_number/1" do
95+
test "returns the chain's declared final block number (Moonbeam)" do
96+
assert is_integer(RemoteChain.final_block_number(Chains.Moonbeam))
97+
98+
assert RemoteChain.final_block_number(Chains.Moonbeam) ==
99+
Chains.Moonbeam.final_block_number()
100+
end
101+
102+
test "returns nil for chains that have not declared final_block_number/0" do
103+
assert RemoteChain.final_block_number(Chains.Diode) == nil
104+
assert RemoteChain.final_block_number(Chains.OasisSapphire) == nil
105+
assert RemoteChain.final_block_number(Chains.Base) == nil
106+
end
107+
end
76108
end

test/remote_chain/ws_conn_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,40 @@ defmodule RemoteChain.WSConnTest do
8989
assert WSConn.stale_threshold_intervals() == 10
9090
end
9191
end
92+
93+
describe "stale_at?/3 for frozen chains" do
94+
# Regression: Moonbeam stopped producing blocks at 16_796_699. Without a
95+
# short-circuit, every WSConn's `lastblock_at` is "ancient" relative to
96+
# the staleness window, and the watchdog closes/restarts them
97+
# continuously, swamping the NodeProxy mailbox and starving RPCs.
98+
test "returns false for a frozen chain regardless of how old lastblock_at is" do
99+
ancient = DateTime.utc_now() |> DateTime.add(-365 * 24 * 3600, :second)
100+
refute WSConn.stale_at?(ancient, Chains.Moonbeam)
101+
refute WSConn.stale_at?(ancient, Chains.Moonbeam, 1)
102+
end
103+
104+
test "returns false for a frozen chain even with intervals=0" do
105+
ancient = DateTime.utc_now() |> DateTime.add(-3600, :second)
106+
refute WSConn.stale_at?(ancient, Chains.Moonbeam, 0)
107+
end
108+
109+
test "returns false for a frozen chain when lastblock_at is nil" do
110+
refute WSConn.stale_at?(nil, Chains.Moonbeam)
111+
end
112+
113+
test "still returns true for non-frozen chains with old lastblock_at" do
114+
# Sanity check: the short-circuit only affects frozen chains. A 1-day
115+
# old timestamp on Anvil (15s cadence) is still stale.
116+
ancient = DateTime.utc_now() |> DateTime.add(-24 * 3600, :second)
117+
assert WSConn.stale_at?(ancient, Chains.Anvil)
118+
end
119+
end
120+
121+
describe "stale?/2 for frozen chains" do
122+
test "returns false for a frozen chain with a never-updated lastblock_at" do
123+
ancient = DateTime.utc_now() |> DateTime.add(-3600, :second)
124+
{:ok, pid} = WSConnStateStub.start(%WSConn{lastblock_at: ancient})
125+
refute WSConn.stale?(pid, Chains.Moonbeam)
126+
end
127+
end
92128
end

0 commit comments

Comments
 (0)