Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,18 @@ allow = [
# r-efi (LGPL-2.1-or-later) is only compiled for UEFI targets, which are
# outside our target set. It is allowed above for completeness.

# smoldot (the embedded light client behind subxt's `light-client` feature)
# is GPL-3.0-or-later with the Classpath exception. Compatible with this
# repo's GPL-3.0-only provider node — the exception only grants additional
# linking permissions — but scoped to these two crates rather than allowing
# the expression workspace-wide.
[[licenses.exceptions]]
allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"]
crate = "smoldot"

[[licenses.exceptions]]
allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"]
crate = "smoldot-light"

[licenses.private]
ignore = true
2 changes: 1 addition & 1 deletion provider-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ serde = { workspace = true, features = ["std"] }
serde_json = { workspace = true }
sp-core = { workspace = true, features = ["std"] }
sp-runtime = { workspace = true, features = ["std"] }
subxt = { workspace = true }
subxt = { workspace = true, features = ["light-client"] }
subxt-signer = { workspace = true }
base64 = { workspace = true }
thiserror = { workspace = true }
Expand Down
160 changes: 151 additions & 9 deletions provider-node/src/chain_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,127 @@
//! borrow the live client and nobody else carries reconnect logic.

use crate::Error;
use std::path::PathBuf;
use subxt::lightclient::LightClient;
use subxt::{OnlineClient, PolkadotConfig};

/// How the provider node talks to the chain.
///
/// A `Light` (embedded smoldot) variant is planned as a follow-up; keeping
/// construction behind this enum means adding it only touches this module.
#[derive(Clone, Debug)]
pub enum ChainTransport {
/// External RPC node reached over WebSocket.
Rpc {
/// `ws://` / `wss://` URL of the parachain RPC endpoint.
url: String,
},
/// Embedded smoldot light client following the relay chain and deriving
/// parachain finality from it — no operated RPC infrastructure needed.
Light {
/// Relay-chain spec (with reachable boot nodes).
relay_spec: SpecSource,
/// Parachain spec (with boot nodes serving the light-client
/// request-response protocols).
para_spec: SpecSource,
},
}

/// Where a chain spec for the light client comes from.
#[derive(Clone, Debug)]
pub enum SpecSource {
/// A chain-spec JSON file shipped with the deployment. This is the
/// trust-preserving option: the spec (genesis + boot nodes) is vetted
/// ahead of time rather than trusted from a node at runtime.
File(PathBuf),
/// Fetch the spec from a running node's RPC at startup. Convenient for
/// local development (zombienet regenerates genesis every run), but it
/// reintroduces trust in that node — dev use only.
FetchFromRpc(String),
}

impl SpecSource {
async fn load(&self, what: &str) -> Result<String, Error> {
match self {
SpecSource::File(path) => std::fs::read_to_string(path).map_err(|e| {
Error::Internal(format!(
"Failed to read {what} chain spec {}: {e}",
path.display()
))
}),
SpecSource::FetchFromRpc(url) => {
tracing::warn!(
"Fetching the {what} chain spec from {url}: this trusts that node and \
defeats the light client's verification purpose — use spec files in \
production"
);
let spec = subxt::utils::fetch_chainspec_from_rpc_node(url)
.await
.map_err(|e| {
Error::Internal(format!(
"Failed to fetch {what} chain spec from {url}: {e}"
))
})?;
Ok(spec.get().to_string())
}
}
}
}

/// A live chain connection, cheap to clone (`OnlineClient` is `Arc`-backed).
#[derive(Clone)]
pub struct ChainHandle {
/// The subxt client for storage reads, event decoding, and tx submission.
pub api: OnlineClient<PolkadotConfig>,
/// Keeps the embedded smoldot instance alive for the handle's lifetime;
/// dropping the last clone tears the light client down. `None` on the
/// RPC transport.
_light: Option<LightClient>,
}

impl ChainHandle {
/// Handle over an existing client with no embedded light client to keep
/// alive: the RPC transport, and tests driving a mock connection.
pub(crate) fn from_api(api: OnlineClient<PolkadotConfig>) -> Self {
Self { api, _light: None }
}
}

/// Receiver side of the connection watch channel. `None` until the first
/// successful connect.
pub type ChainWatch = tokio::sync::watch::Receiver<Option<ChainHandle>>;

/// Build a fresh connection for the given transport.
///
/// For `Light` this boots a new embedded smoldot instance (relay + para);
/// the watchdog's rebuild path therefore recovers even from a wedged smoldot
/// background task, not just a dropped subscription.
pub async fn connect(transport: &ChainTransport) -> Result<ChainHandle, Error> {
match transport {
ChainTransport::Rpc { url } => {
let api = OnlineClient::<PolkadotConfig>::from_url(url)
.await
.map_err(|e| Error::Internal(format!("Failed to connect to chain: {e}")))?;
Ok(ChainHandle { api })
Ok(ChainHandle::from_api(api))
}
ChainTransport::Light {
relay_spec,
para_spec,
} => {
let relay = relay_spec.load("relay").await?;
let para = para_spec.load("parachain").await?;

let (light, _relay_rpc) = LightClient::relay_chain(relay.as_str())
.map_err(|e| Error::Internal(format!("Failed to start light client: {e}")))?;
let para_rpc = light.parachain(para.as_str()).map_err(|e| {
Error::Internal(format!("Failed to add parachain to light client: {e}"))
})?;
let api = OnlineClient::<PolkadotConfig>::from_rpc_client(para_rpc)
.await
.map_err(|e| Error::Internal(format!("Failed to connect via light client: {e}")))?;

tracing::info!("Embedded light client started (relay + parachain)");
Ok(ChainHandle {
api,
_light: Some(light),
})
}
}
}
Expand All @@ -65,13 +152,12 @@ mod tests {
#[tokio::test]
async fn rpc_connect_to_unreachable_chain_errors() {
// Port 1 on loopback refuses immediately.
let result = connect(&ChainTransport::Rpc {
let err = connect(&ChainTransport::Rpc {
url: "ws://127.0.0.1:1".to_string(),
})
.await;
let Err(err) = result else {
panic!("connect must fail against a closed port");
};
.await
.map(|_| ())
.expect_err("connect must fail against a closed port");
assert!(err.to_string().contains("Failed to connect to chain"));
}

Expand All @@ -81,4 +167,60 @@ mod tests {
let err = current_api(&rx).expect_err("no connection published yet");
assert!(err.to_string().contains("not established"));
}

#[tokio::test]
async fn light_with_missing_spec_file_errors() {
let err = connect(&ChainTransport::Light {
relay_spec: SpecSource::File(PathBuf::from("/nonexistent/relay.json")),
para_spec: SpecSource::File(PathBuf::from("/nonexistent/para.json")),
})
.await
.map(|_| ())
.expect_err("connect must fail when the relay spec file is missing");
assert!(
err.to_string().contains("Failed to read relay chain spec"),
"unexpected error: {err}"
);
}

#[tokio::test]
async fn light_with_invalid_spec_errors() {
// The file loads (covering the File happy path) but smoldot rejects
// the contents as a chain spec.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("relay.json");
std::fs::write(&path, "{\"not\": \"a chain spec\"}").unwrap();

let err = connect(&ChainTransport::Light {
relay_spec: SpecSource::File(path),
para_spec: SpecSource::File(PathBuf::from("/nonexistent/para.json")),
})
.await
.map(|_| ())
.expect_err("connect must fail on an invalid relay spec");
// The para spec is never reached: either the relay spec parse fails,
// or (if smoldot were lenient) the missing para file errors next.
assert!(
err.to_string().contains("Failed to start light client")
|| err
.to_string()
.contains("Failed to read parachain chain spec"),
"unexpected error: {err}"
);
}

#[tokio::test]
async fn light_spec_fetch_from_unreachable_node_errors() {
let err = connect(&ChainTransport::Light {
relay_spec: SpecSource::FetchFromRpc("ws://127.0.0.1:1".to_string()),
para_spec: SpecSource::File(PathBuf::from("/nonexistent/para.json")),
})
.await
.map(|_| ())
.expect_err("connect must fail when the spec fetch node is unreachable");
assert!(
err.to_string().contains("Failed to fetch relay chain spec"),
"unexpected error: {err}"
);
}
}
12 changes: 9 additions & 3 deletions provider-node/src/chain_state_coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ impl ChainStateCoordinator {
/// backend resubscriptions), so this is several times the block time;
/// a genuinely stalled stream otherwise hangs forever with no error.
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
/// The first block gets a longer budget: a fresh embedded light client
/// has to discover peers and warp-sync before anything is finalized,
/// and rebuilding mid-sync would throw that progress away.
const FIRST_BLOCK_TIMEOUT: Duration = Duration::from_secs(300);

let api = handle.api.clone();
let mut blocks = api
Expand Down Expand Up @@ -339,18 +343,20 @@ impl ChainStateCoordinator {
.load(std::sync::atomic::Ordering::Relaxed),
});

let mut stall_timeout = FIRST_BLOCK_TIMEOUT;
loop {
let next = match tokio::time::timeout(STALL_TIMEOUT, blocks.next()).await {
let next = match tokio::time::timeout(stall_timeout, blocks.next()).await {
Ok(Some(next)) => next,
Ok(None) => break,
Err(_) => {
tracing::warn!(
"chain-state coordinator: no finalized block for {}s; rebuilding connection",
STALL_TIMEOUT.as_secs()
stall_timeout.as_secs()
);
break;
}
};
stall_timeout = STALL_TIMEOUT;
let block = match next {
Ok(block) => block,
Err(e) => {
Expand Down Expand Up @@ -1309,7 +1315,7 @@ mod tests {
// `follow` bootstraps, processes the block (decoding the
// ProviderRegistered event and refreshing state), and returns.
coordinator
.follow(ChainHandle { api })
.follow(ChainHandle::from_api(api))
.await
.expect("follow runs to stream end");

Expand Down
Loading
Loading