Skip to content

Commit 9aaf5d0

Browse files
committed
lokahi: serve superroot_atTimestamp on each chain's route, as op-supernode does
Every route of the Go op-supernode answers superroot_atTimestamp, because op-node registers the superroot namespace on its RPC unconditionally (op-node/node/node.go, registerAPIs): the root answers over the whole chain set, and each /<chainID> route answers op-node's single-chain implementation (op-node/node/superroot_api.go). kona-node has no such namespace, so a lokahi chain route built from kona's method set alone refused the call with "Method not found". The consumers that dial a chain route for it are real. The devstack's per-chain proposers are pointed at the chain CL's own URL (op-devstack/sysgo/singlechain_runtime.go, SuperRootRpcs = l2CL.UserRPC()), and the anchor of a newly added game type is read the same way (op-devstack/sysgo/add_game_type.go). In the first full lokahi acceptance run (CircleCI job 5508932) this was the 19-test failure bucket: those proposers retried superroot_atTimestamp against kona's per-chain module to end of log while the challenger and the super proposer, dialling the same socket's root, were answered. The route's answer is composed exactly as the root composes its pre-verification branch, specialised to one chain: the same QueryChain reads, the same Aggregate arithmetic, the same from_handoff composition — so the wire shape and the commitment are bit-identical to what the root states for a set of one, which is also what op-node serves (it consults no verifier; a single-chain node has none). supernode_syncStatus stays off the chain routes, as it is off op-node's RPC. kona registers a chain's route while the chain composes, so the route's extra methods are deposited before composition and hold a OnceLock handle filled once the chain exists — the same binding-before-composition the root's query API uses; a call in that window is answered "still starting". A deposited method that collides with one kona serves fails the chain's launch loudly rather than shadowing either. The two_chains integration test now asserts each chain's route answers superroot_atTimestamp for exactly its own chain and refuses supernode_syncStatus. It failed before this change with the same error the acceptance run recorded, and passes after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEDeDWcHt2X1mZYhvHmnZ5
1 parent e6c4738 commit 9aaf5d0

4 files changed

Lines changed: 228 additions & 14 deletions

File tree

rust/lokahi/src/query/mod.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,100 @@ impl QueryHandle {
194194
}
195195
}
196196

197+
/// The handle a chain's own route holds on query state that does not exist when the route's
198+
/// methods are declared: `superroot_atTimestamp` for that one chain.
199+
///
200+
/// This is the Go stack's addressing, mirrored. op-node registers the `superroot` namespace on
201+
/// its RPC unconditionally (`op-node/node/node.go`, `registerAPIs`), so every route of the Go
202+
/// op-supernode answers `superroot_atTimestamp`: the root serves the whole set's super root, and
203+
/// each chain's route serves that chain's own — op-node's single-chain implementation
204+
/// (`op-node/node/superroot_api.go`), which is what dispute infrastructure pointed at one chain
205+
/// reads. kona-node has no such namespace, so a lokahi route built from kona's methods alone
206+
/// refused the call with "Method not found" — and the consumers that dial a chain route for it
207+
/// are real: the devstack's per-chain proposers (`op-devstack/sysgo/singlechain_runtime.go` sets
208+
/// `SuperRootRpcs` to the chain CL's own URL) and the anchor read when a game type is added
209+
/// (`op-devstack/sysgo/add_game_type.go`).
210+
///
211+
/// A [`OnceLock`] behind the module for the same reason [`QueryHandle`] is one behind the root's:
212+
/// kona registers the chain's route while the chain composes, so the module must be deposited
213+
/// before composition — before the queues it answers from exist. A call landing in that window is
214+
/// answered with [`QueryError::Starting`] rather than with an unregistered method.
215+
///
216+
/// The set-wide `supernode_syncStatus` is deliberately *not* served here: op-node's RPC has no
217+
/// `supernode` namespace, so a chain route that answered it would be a surface the Go supernode
218+
/// does not have.
219+
#[derive(Debug, Clone, Default)]
220+
pub(crate) struct ChainRouteQueries(Arc<OnceLock<QueryChain>>);
221+
222+
impl ChainRouteQueries {
223+
/// Publishes the composed chain's reads to the route's RPC methods.
224+
pub(crate) fn compose(&self, chain: QueryChain) {
225+
if self.0.set(chain).is_err() {
226+
// Unreachable: each chain composes once. Reported rather than panicked on, because a
227+
// route that is already answering correctly should not be stopped by it.
228+
warn!(target: "lokahi", "A chain route's query API was composed twice; keeping the first");
229+
}
230+
}
231+
232+
/// Returns the composed chain, or says the supernode is still starting.
233+
fn state(&self) -> Result<&QueryChain, QueryError> {
234+
self.0.get().ok_or(QueryError::Starting)
235+
}
236+
237+
/// Builds the RPC module serving the chain route's `superroot` namespace from this handle.
238+
pub(crate) fn into_rpc_module(
239+
self,
240+
) -> Result<RpcModule<()>, jsonrpsee::core::RegisterMethodError> {
241+
let mut module = RpcModule::new(());
242+
module.merge(SuperrootQueryApiServer::into_rpc(ChainRouteQuery { handle: self }))?;
243+
Ok(module)
244+
}
245+
}
246+
247+
/// The server behind one chain route's `superroot` namespace.
248+
#[derive(Debug)]
249+
struct ChainRouteQuery {
250+
/// The chain this route answers for.
251+
handle: ChainRouteQueries,
252+
}
253+
254+
impl ChainRouteQuery {
255+
/// Answers `superroot_atTimestamp` for the one chain, as op-node answers it.
256+
///
257+
/// op-node's `superrootAPI.atTimestamp` consults no verifier — a single-chain node has none —
258+
/// so its answer is always the optimistic single-chain super root: the chain's output at the
259+
/// timestamp, paired with the L1 block its safe-head history says made it safe, or an
260+
/// omit-chain response when the chain has not derived that far. That is exactly the root's
261+
/// handoff branch specialised to one chain, so it is composed from the same pieces: the same
262+
/// [`QueryChain`] reads, the same [`Aggregate`] arithmetic (a minimum over one), and the same
263+
/// [`WireSuperRootData::from_handoff`] composition, which keeps the wire shape and the
264+
/// commitment bit-identical to what the root would state for a set of one.
265+
async fn at_timestamp(&self, timestamp: u64) -> Result<WireSuperRootAtTimestamp, QueryError> {
266+
let chain = self.handle.state()?;
267+
let mut statuses = BTreeMap::new();
268+
statuses.insert(chain.chain_id(), chain.sync_status().await?);
269+
let aggregate = Aggregate::of(statuses);
270+
271+
let mut optimistic = BTreeMap::new();
272+
if let Some(output) = chain.optimistic_at(timestamp).await? {
273+
optimistic.insert(chain.chain_id(), output);
274+
}
275+
276+
let data = WireSuperRootData::from_handoff(timestamp, &aggregate, &optimistic);
277+
Ok(WireSuperRootAtTimestamp::new(&aggregate, &optimistic, aggregate.current_l1, data))
278+
}
279+
}
280+
281+
#[async_trait::async_trait]
282+
impl SuperrootQueryApiServer for ChainRouteQuery {
283+
async fn superroot_at_timestamp(
284+
&self,
285+
timestamp: WireU64,
286+
) -> RpcResult<WireSuperRootAtTimestamp> {
287+
Ok(self.at_timestamp(timestamp.0).await?)
288+
}
289+
}
290+
197291
/// The server behind both namespaces.
198292
#[derive(Debug, Clone)]
199293
struct QueryRpc {

rust/lokahi/src/rpc.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
//! Each chain's method set is built by kona, inside [`RollupNode::compose`], from the channels
1717
//! that chain's actors read. lokahi cannot rebuild it and does not try: it hands kona a launcher
1818
//! ([`SharedRpcServerLauncher`]) which takes the finished [`RpcModule`] and registers it as a
19-
//! route instead of binding it to a socket. So the per-chain method set stays kona's, byte for
20-
//! byte, including the HTTP middleware a standalone kona-node serves it behind.
19+
//! route instead of binding it to a socket. So kona's methods reach the route as kona built them,
20+
//! including the HTTP middleware a standalone kona-node serves them behind. What a route serves
21+
//! *beyond* kona's set — Go op-supernode's chain routes answer `superroot_atTimestamp`, because
22+
//! each virtual op-node registers that namespace itself — is deposited per chain through
23+
//! [`SupernodeRpc::add_chain_methods`] and merged at registration.
2124
//!
2225
//! [`RollupNode::compose`]: kona_node_service::RollupNode::compose
2326
@@ -89,6 +92,14 @@ type Handler = Arc<
8992
struct Routes {
9093
/// Route by chain id's decimal string: the path segment a caller writes.
9194
chains: RwLock<HashMap<String, Option<Handler>>>,
95+
/// Supernode-owned methods merged into a chain's route when it registers, by the same key.
96+
///
97+
/// This is how a chain's route comes to serve more than kona's method set, the way the Go
98+
/// op-supernode's routes do: op-node registers a `superroot` namespace of its own, so every
99+
/// virtual node op-supernode serves under `/<chainID>` answers `superroot_atTimestamp`.
100+
/// kona builds its module set from its actors and lokahi does not reopen it; what lokahi
101+
/// serves per chain beyond it is deposited here and merged at registration.
102+
extras: RwLock<HashMap<String, Methods>>,
92103
/// The handler for `/`: the supernode's own namespaces.
93104
root: RwLock<Option<Handler>>,
94105
/// The stop handle every service built from this table holds.
@@ -234,6 +245,7 @@ impl SupernodeRpc {
234245
let (stop_handle, server_handle) = stop_channel();
235246
let routes = Arc::new(Routes {
236247
chains: RwLock::new(chain_ids.into_iter().map(|id| (id.to_string(), None)).collect()),
248+
extras: RwLock::new(HashMap::new()),
237249
root: RwLock::new(None),
238250
stop_handle,
239251
_server_handle: server_handle,
@@ -268,6 +280,25 @@ impl SupernodeRpc {
268280
pub(crate) fn launcher(&self, chain_id: u64) -> SharedRpcServerLauncher {
269281
Arc::new(ChainLauncher { routes: Arc::clone(&self.routes), chain_id: chain_id.to_string() })
270282
}
283+
284+
/// Adds supernode-owned methods to chain `chain_id`'s route, merged when the route registers.
285+
///
286+
/// Must be called before the chain is composed, because kona registers the route *while*
287+
/// composing: the supernode deposits each chain's extra methods first and only then composes
288+
/// it, so a route never registers before its extras are here. The methods therefore exist
289+
/// before the state they answer from — the same ordering the root's query API lives with —
290+
/// and hold a handle that is filled once the chain exists. A method that collides with one
291+
/// kona already serves fails the chain's launch loudly rather than serving one of the two
292+
/// quietly — if kona grows one of these methods natively, the right move is to stop supplying
293+
/// it here, not to shadow it.
294+
pub(crate) fn add_chain_methods(&self, chain_id: u64, methods: impl Into<Methods>) {
295+
let _ = self
296+
.routes
297+
.extras
298+
.write()
299+
.expect("the extras lock is never held across a panic")
300+
.insert(chain_id.to_string(), methods.into());
301+
}
271302
}
272303

273304
impl Drop for SupernodeRpc {
@@ -411,7 +442,26 @@ struct ChainLauncher {
411442
impl RpcServerLauncher for ChainLauncher {
412443
type Handle = Route;
413444

414-
async fn launch(&self, modules: RpcModule<()>) -> Result<Self::Handle, std::io::Error> {
445+
async fn launch(&self, mut modules: RpcModule<()>) -> Result<Self::Handle, std::io::Error> {
446+
// The supernode's own additions to this chain's route, deposited before the actors
447+
// started. Merged into kona's set rather than replacing anything in it; a collision is a
448+
// real conflict — two implementations of one method on one route — and fails the launch
449+
// with the method's name rather than picking one silently.
450+
let extra = self
451+
.routes
452+
.extras
453+
.read()
454+
.expect("the extras lock is never held across a panic")
455+
.get(&self.chain_id)
456+
.cloned();
457+
if let Some(extra) = extra {
458+
modules.merge(extra).map_err(|err| {
459+
std::io::Error::other(format!(
460+
"chain {} route: a supernode-supplied method collides with kona's: {err}",
461+
self.chain_id
462+
))
463+
})?;
464+
}
415465
self.routes.register(&self.chain_id, modules.into());
416466
Ok(Route::new(Arc::clone(&self.routes), self.chain_id.clone()))
417467
}

rust/lokahi/src/supernode.rs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::{
44
admin,
55
config::{L1Settings, ResolvedChain, ResolvedConfig, SequencerSettings},
66
interop::{ChainInterop, HostedChain, InteropActor, InteropTestHandle},
7-
query::{QueryChain, QueryHandle},
7+
query::{ChainRouteQueries, QueryChain, QueryHandle},
88
rpc::SupernodeRpc,
99
};
1010
use alloy_primitives::{Address, B256};
@@ -250,6 +250,24 @@ impl Supernode {
250250
let datadir = chain.settings.datadir.clone();
251251
let rollup_config = chain.rollup_config.clone();
252252
let interop_state = chain.interop.clone();
253+
254+
// The chain's own route serves `superroot_atTimestamp` for that one chain, over the
255+
// same reads as the set-wide answer at the root. This mirrors the Go op-supernode's
256+
// routes, whose virtual op-nodes each register a `superroot` namespace of their own;
257+
// the devstack's per-chain proposers and game-anchor reads dial the chain route for
258+
// it. Deposited before composing, because kona registers the route *while* the chain
259+
// composes; like the root's query API, the methods exist first and are handed their
260+
// chain below, once it exists.
261+
let route_queries = ChainRouteQueries::default();
262+
if let Some(rpc) = rpc.as_ref() {
263+
rpc.add_chain_methods(
264+
chain_id,
265+
route_queries.clone().into_rpc_module().with_context(|| {
266+
format!("failed to build the route query API of chain {chain_id}")
267+
})?,
268+
);
269+
}
270+
253271
let ComposedChain {
254272
actors: chain_actors,
255273
l1_watcher_ports,
@@ -268,16 +286,24 @@ impl Supernode {
268286
// local-safe head itself and reports a timestamp behind it as unavailable history —
269287
// which is what it is. Interop turns that recording on, and every consumer of these
270288
// methods runs against an interop cluster.
271-
query_chains.push(QueryChain::new(
272-
chain_id,
273-
Arc::new(rollup_config.clone()),
274-
QueuedEngineRpcClient::new(controller_rpc_request_tx.clone()),
275-
l1_query_tx,
276-
interop_state.as_ref().map_or_else(
277-
|| Arc::new(DisabledDatabase) as SharedSafeDb,
278-
|state| state.safe_db.clone(),
279-
),
280-
));
289+
let shared_rollup_config = Arc::new(rollup_config.clone());
290+
let safe_db = interop_state.as_ref().map_or_else(
291+
|| Arc::new(DisabledDatabase) as SharedSafeDb,
292+
|state| state.safe_db.clone(),
293+
);
294+
let query_chain = || {
295+
QueryChain::new(
296+
chain_id,
297+
Arc::clone(&shared_rollup_config),
298+
QueuedEngineRpcClient::new(controller_rpc_request_tx.clone()),
299+
l1_query_tx.clone(),
300+
safe_db.clone(),
301+
)
302+
};
303+
query_chains.push(query_chain());
304+
305+
// Past this point the chain exists, so its route's query API can answer for it.
306+
route_queries.compose(query_chain());
281307

282308
// A promoter exists exactly when the chain was composed with an externally fed
283309
// cross-safe head, which is exactly when interop is on. Taking it here is what makes

rust/lokahi/tests/two_chains.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,50 @@ async fn two_chains_answer_on_one_socket_under_their_own_routes() {
120120
"an absent super root omits `data` rather than sending null: {superroot}"
121121
);
122122
assert_eq!(superroot["chain_ids"], json!([CHAIN_A.to_string(), CHAIN_B.to_string()]));
123+
124+
// Each chain's route answers `superroot_atTimestamp` for that one chain. This is the Go
125+
// stack's addressing: op-node registers the `superroot` namespace on its RPC unconditionally
126+
// (`op-node/node/node.go`, `registerAPIs`), so every route of the Go op-supernode serves the
127+
// method — the root over the whole set, each chain's route over that chain alone. Real
128+
// consumers dial the chain route for it: the devstack's per-chain proposers
129+
// (`op-devstack/sysgo/singlechain_runtime.go`, `SuperRootRpcs = l2CL.UserRPC()`) and the
130+
// anchor read when a game type is added (`op-devstack/sysgo/add_game_type.go`). A route built
131+
// from kona's method set alone refuses the call with "Method not found", which is how those
132+
// consumers failed against lokahi while the root answered.
133+
for chain_id in [CHAIN_A, CHAIN_B] {
134+
let client = HttpClientBuilder::default()
135+
.build(node.chain_url(chain_id))
136+
.expect("build a chain rpc client");
137+
let superroot = client
138+
.request::<Value, _>("superroot_atTimestamp", rpc_params!["0x7fffffff"])
139+
.await
140+
.unwrap_or_else(|err| {
141+
panic!("the route of chain {chain_id} did not answer superroot_atTimestamp: {err}")
142+
});
143+
assert_eq!(
144+
superroot["chain_ids"],
145+
json!([chain_id.to_string()]),
146+
"the route of chain {chain_id} must answer for exactly that chain: {superroot}"
147+
);
148+
assert_eq!(
149+
superroot["optimistic_at_timestamp"],
150+
json!({}),
151+
"the chain has not derived that timestamp: {superroot}"
152+
);
153+
assert!(
154+
superroot.get("data").is_none(),
155+
"an absent super root omits `data` on a chain route too: {superroot}"
156+
);
157+
158+
// The set-wide method stays off the chain route, as it is off op-node's RPC: what the
159+
// whole chain set has derived is the root's answer, and a chain route that answered it
160+
// would be a surface the Go supernode does not have.
161+
let err = client.request::<Value, _>("supernode_syncStatus", rpc_params![]).await;
162+
assert!(
163+
err.is_err(),
164+
"supernode_syncStatus must not answer on chain {chain_id}'s route: it is a root method"
165+
);
166+
}
123167
}
124168

125169
/// One chain's execution layer being unreachable is that chain's problem.

0 commit comments

Comments
 (0)