Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ nonce on demand (keyed operator tool).

Optional: `CARTESI_SEQUENCER_HTTP_ADDR` (default `127.0.0.1:3000`, `run`), `CARTESI_SEQUENCER_DATA_DIR` (default `sequencer-data` — SQLite file is `sequencer.db` inside; created if missing), `CARTESI_SEQUENCER_PREEMPTIVE_MARGIN_BLOCKS` (default `300`), `CARTESI_SEQUENCER_SECONDS_PER_BLOCK` (default `12`), `CARTESI_SEQUENCER_L1_READ_STALE_AFTER_BLOCKS` (default `600`), `CARTESI_SEQUENCER_LONG_BLOCK_RANGE_ERROR_CODES` (default `-32005,-32600,-32602,-32616`), `CARTESI_SEQUENCER_AUTH_PRIVATE_KEY_FILE` (alternative to `CARTESI_SEQUENCER_AUTH_PRIVATE_KEY`; first line of the file is the key), `CARTESI_SEQUENCER_BATCH_SUBMITTER_IDLE_POLL_INTERVAL_MS`, `CARTESI_SEQUENCER_BATCH_SUBMITTER_CONFIRMATION_DEPTH`.

By default the blockchain endpoint must be `https://` unless its host is loopback (`localhost`, `127.0.0.0/8`, `::1`) — a guard against accidentally sending L1 traffic to a public RPC in the clear. Set `CARTESI_SEQUENCER_ALLOW_INSECURE_RPC=true` (or `--allow-insecure-rpc`) to permit plaintext `http://` to a non-loopback host on a **trusted private network** — e.g. a Docker Compose / Kubernetes service name (`http://anvil:8545`), `host.docker.internal`, or a private-VPC IP.

The flag is **per-invocation, not pinned into the DB**: set it on every subcommand that dials L1 (`setup`, `run`, and `flush-mempool`). Setting it only on `setup` and then omitting it on `run` is refused at boot with `remote RPC must use https` — that is by design (each keyed/read path re-validates the endpoint), not a bug. In a container deployment, put it in the shared environment for all sequencer commands. Example (Docker Compose):

```yaml
environment:
CARTESI_SEQUENCER_BLOCKCHAIN_HTTP_ENDPOINT: "http://anvil:8545"
CARTESI_SEQUENCER_ALLOW_INSECURE_RPC: "true"
```

Process exit codes follow the R4 orchestrator contract: `0` clean shutdown, `10` restart (expect a recovery boot), `20` transient refusal (retry with backoff), `30` terminal (operator required — e.g. setup not complete, identity mismatch, canonical divergence), `1`/`101` unclassified/panic.

Fixed protocol identity (EIP-712):
Expand Down
1 change: 1 addition & 0 deletions sequencer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ alloy-sol-types = "1.4.1"
alloy = { version = "1.0", features = ["contract", "network", "reqwest", "rpc-types", "sol-types", "node-bindings", "signer-local", "signers"] }
alloy-network-primitives = "1.7"
alloy-transport = "1.0"
url = "2.5"
thiserror = "1"
ssz = { package = "ethereum_ssz", version = "0.10" }
ssz_derive = { package = "ethereum_ssz_derive", version = "0.10" }
Expand Down
133 changes: 103 additions & 30 deletions sequencer/src/l1/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use alloy::{
transports::http::{Http, reqwest, reqwest::Url},
};
use alloy_transport::layers::RetryBackoffLayer;
use url::Host;

// Public Ethereum providers (Infura, Alchemy) commonly take 30–60s on heavy
// `eth_getLogs` queries under load. The partition-retry helper in
Expand All @@ -23,17 +24,30 @@ const MAX_RATE_LIMIT_RETRIES: u32 = 5;
const INITIAL_BACKOFF_MS: u64 = 200;
const COMPUTE_UNITS_PER_SEC: u64 = 500;

fn create_client(url: &str) -> Result<RpcClient, String> {
fn create_client(url: &str, allow_insecure: bool) -> Result<RpcClient, String> {
let url = Url::parse(url).map_err(|e| format!("invalid RPC URL: {e}"))?;

// Reject non-HTTPS for remote hosts to prevent accidental plaintext RPC.
// `url::Url::host_str` returns bracket-wrapped IPv6 literals (e.g. "[::1]").
if url.scheme() != "https" && !is_loopback_host(url.host_str().unwrap_or("")) {
// Reject non-HTTPS for remote hosts to prevent accidental plaintext RPC to a
// public endpoint (eavesdropping / MITM of signed L1 traffic). Loopback is
// always allowed; any other host requires `allow_insecure` — the explicit
// operator opt-in for trusted private networks (a Docker/K8s service name,
// `host.docker.internal`, a private-VPC IP) where anvil-style plaintext is
// normal and safe. The opt-out is loud, never silent.
let remote_plaintext = url.scheme() != "https" && !is_loopback_host(url.host());
if remote_plaintext && !allow_insecure {
return Err(format!(
"remote RPC must use https, got {}://",
"remote RPC must use https, got {}:// (set \
CARTESI_SEQUENCER_ALLOW_INSECURE_RPC=true / --allow-insecure-rpc if \
this host is on a trusted private network, e.g. a Docker/K8s service)",
url.scheme()
));
}
if remote_plaintext {
tracing::warn!(
url = %url,
"insecure plaintext RPC to a non-loopback host — explicitly allowed via allow-insecure-rpc"
);
}

let http_client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
Expand All @@ -54,24 +68,39 @@ fn create_client(url: &str) -> Result<RpcClient, String> {
.transport(transport, is_local))
}

/// Check whether a URL host string refers to a loopback address.
/// Whether a parsed URL host refers to a loopback address.
///
/// `url::Url::host_str` wraps IPv6 literals in brackets (e.g. `[::1]`), which
/// this helper normalizes alongside the IPv4 and DNS forms.
fn is_loopback_host(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
/// Uses the already-parsed [`url::Host`] rather than the raw host string, so IP
/// literals are classified by [`std::net::Ipv4Addr::is_loopback`] /
/// [`std::net::Ipv6Addr::is_loopback`] (covering the whole `127.0.0.0/8` block
/// and canonical `::1`, with no bracket-stripping). `localhost` is the reserved
/// loopback name (RFC 6761) — a convention no library computes offline, so it
/// stays the one explicit string.
fn is_loopback_host(host: Option<Host<&str>>) -> bool {
match host {
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
Some(Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
None => false,
}
}

/// Create a read-only provider with retry and timeout.
pub fn create_provider(url: &str) -> Result<DynProvider, String> {
let client = create_client(url)?;
/// Create a read-only provider with retry and timeout. `allow_insecure` opts
/// into plaintext RPC against a non-loopback host — see [`create_client`].
pub fn create_provider(url: &str, allow_insecure: bool) -> Result<DynProvider, String> {
let client = create_client(url, allow_insecure)?;
let provider = ProviderBuilder::new().connect_client(client);
Ok(provider.erased())
}

/// Create a provider with a wallet signer, retry, and timeout.
pub fn create_signer_provider(url: &str, private_key: &str) -> Result<DynProvider, String> {
let client = create_client(url)?;
/// Create a provider with a wallet signer, retry, and timeout. `allow_insecure`
/// opts into plaintext RPC against a non-loopback host — see [`create_client`].
pub fn create_signer_provider(
url: &str,
private_key: &str,
allow_insecure: bool,
) -> Result<DynProvider, String> {
let client = create_client(url, allow_insecure)?;
let signer =
PrivateKeySigner::from_str(private_key).map_err(|_| "invalid private key".to_string())?;
let provider = ProviderBuilder::new().wallet(signer).connect_client(client);
Expand Down Expand Up @@ -107,9 +136,10 @@ pub async fn create_verified_signer_provider(
url: &str,
private_key: &str,
expected_chain_id: u64,
allow_insecure: bool,
) -> Result<DynProvider, VerifiedSignerProviderError> {
let provider =
create_signer_provider(url, private_key).map_err(VerifiedSignerProviderError::Create)?;
let provider = create_signer_provider(url, private_key, allow_insecure)
.map_err(VerifiedSignerProviderError::Create)?;
let rpc_chain_id = provider
.get_chain_id()
.await
Expand All @@ -131,7 +161,7 @@ mod tests {

#[test]
fn create_client_rejects_http_for_remote_host() {
let err = create_client("http://mainnet.infura.io/v3/abc123")
let err = create_client("http://mainnet.infura.io/v3/abc123", false)
.expect_err("http:// for remote host must be rejected");
assert!(
err.contains("https"),
Expand All @@ -141,22 +171,64 @@ mod tests {

#[test]
fn create_client_accepts_http_for_127_0_0_1() {
create_client("http://127.0.0.1:8545").expect("loopback http:// must be accepted");
create_client("http://127.0.0.1:8545", false).expect("loopback http:// must be accepted");
}

#[test]
fn create_client_accepts_http_for_localhost() {
create_client("http://localhost:8545").expect("localhost http:// must be accepted");
create_client("http://localhost:8545", false).expect("localhost http:// must be accepted");
}

#[test]
fn create_client_accepts_http_for_ipv6_loopback() {
create_client("http://[::1]:8545").expect("IPv6 loopback http:// must be accepted");
create_client("http://[::1]:8545", false).expect("IPv6 loopback http:// must be accepted");
}

#[test]
fn create_client_accepts_http_for_127_0_0_0_8_block() {
// The whole 127.0.0.0/8 range is loopback — `is_loopback()` covers it,
// whereas the old literal `== "127.0.0.1"` match rejected these.
create_client("http://127.0.0.2:8545", false).expect("127.0.0.2 is loopback");
create_client("http://127.1.2.3:8545", false).expect("127.1.2.3 is loopback");
}

#[test]
fn create_client_accepts_https_for_remote_host() {
create_client("https://mainnet.infura.io/v3/abc123").expect("https:// must be accepted");
create_client("https://mainnet.infura.io/v3/abc123", false)
.expect("https:// must be accepted");
}

// ── Option A: explicit opt-in for plaintext to a trusted private host ──

#[test]
fn create_client_rejects_http_for_docker_service_name_by_default() {
// A Docker Compose / K8s service name is not loopback; default-secure
// rejects it, and the error points the operator at the opt-in.
let err = create_client("http://anvil:8545", false)
.expect_err("http:// to a bare service name must be rejected by default");
assert!(
err.contains("https") && err.contains("ALLOW_INSECURE_RPC"),
"error should explain https requirement and the opt-in, got: {err}"
);
}

#[test]
fn create_client_allows_insecure_rpc_when_opted_in() {
// The explicit operator opt-in permits plaintext to a non-loopback host
// (Docker service name, private IP, host.docker.internal).
create_client("http://anvil:8545", true)
.expect("service name http:// allowed when opted in");
create_client("http://10.0.1.5:8545", true)
.expect("private IP http:// allowed when opted in");
create_client("http://host.docker.internal:8545", true)
.expect("host.docker.internal http:// allowed when opted in");
}

#[test]
fn create_client_still_allows_loopback_without_the_opt_in() {
// The opt-in is not required for loopback — that stays a zero-config
// safe default even with allow_insecure = false.
create_client("http://127.0.0.1:8545", false).expect("loopback needs no opt-in");
}

// ── H3 regression: private-key parse error must not echo bytes ─
Expand All @@ -169,7 +241,7 @@ mod tests {
// match — so a future change that re-adds interpolation is caught.
let bad_key =
"0xZZZZ_zzzz_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff_ffff";
let err = create_signer_provider("http://127.0.0.1:8545", bad_key)
let err = create_signer_provider("http://127.0.0.1:8545", bad_key, false)
.expect_err("malformed hex key must be rejected");
assert_eq!(
err, "invalid private key",
Expand All @@ -187,15 +259,15 @@ mod tests {
// Odd-length hex would trigger a different error variant. Same
// invariant: fixed error message, no key bytes leaked.
let bad_key = "0xabc";
let err = create_signer_provider("http://127.0.0.1:8545", bad_key)
let err = create_signer_provider("http://127.0.0.1:8545", bad_key, false)
.expect_err("odd-length hex key must be rejected");
assert_eq!(err, "invalid private key");
}

#[test]
fn create_signer_provider_accepts_valid_key() {
let good_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
create_signer_provider("http://127.0.0.1:8545", good_key)
create_signer_provider("http://127.0.0.1:8545", good_key, false)
.expect("valid key must be accepted");
}

Expand Down Expand Up @@ -226,15 +298,16 @@ mod tests {
let chain_id = anvil.chain_id();

// Matching chain id → a usable signing provider.
create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id)
create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id, false)
.await
.expect("matching chain id yields a verified signer provider");

// Wrong pinned chain id → terminal ChainIdMismatch, before any signing,
// surfacing the served id vs the pinned one.
let err = create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id + 1)
.await
.expect_err("a mismatched pinned chain id must be rejected before signing");
let err =
create_verified_signer_provider(&anvil.endpoint(), ANVIL_KEY_0, chain_id + 1, false)
.await
.expect_err("a mismatched pinned chain id must be rejected before signing");
assert!(
matches!(
err,
Expand Down
27 changes: 21 additions & 6 deletions sequencer/src/l1/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ use sequencer_core::protocol::ProtocolTiming;
#[derive(Debug, Clone)]
pub struct InputReaderConfig {
pub rpc_url: String,
/// Opt into plaintext (`http://`) RPC against a non-loopback host — a
/// trusted private network (Docker/K8s service, private-VPC IP). Off by
/// default: the provider layer refuses remote plaintext otherwise. See
/// [`crate::l1::provider`].
pub allow_insecure_rpc: bool,
pub app_address: Address,
pub poll_interval: Duration,
/// Error codes that trigger `get_logs` retries with a shorter block range.
Expand Down Expand Up @@ -86,8 +91,9 @@ impl InputReader {
batch_submitter: Address,
timing: ProtocolTiming,
) -> Result<Self, InputReaderError> {
let provider = crate::l1::provider::create_provider(&config.rpc_url)
.map_err(InputReaderError::Bootstrap)?;
let provider =
crate::l1::provider::create_provider(&config.rpc_url, config.allow_insecure_rpc)
.map_err(InputReaderError::Bootstrap)?;
let application = Application::new(config.app_address, &provider);
let data_availability = application
.getDataAvailability()
Expand Down Expand Up @@ -170,8 +176,11 @@ impl InputReader {
}

pub async fn sync_to_current_safe_head(&mut self) -> Result<(), InputReaderError> {
let provider = crate::l1::provider::create_provider(&self.config.rpc_url)
.map_err(InputReaderError::Bootstrap)?;
let provider = crate::l1::provider::create_provider(
&self.config.rpc_url,
self.config.allow_insecure_rpc,
)
.map_err(InputReaderError::Bootstrap)?;
self.advance_once(&provider).await
}

Expand All @@ -193,8 +202,11 @@ impl InputReader {
/// errors propagate. Shutdown is handled by the outer `run_forever`
/// select, so this loop has no shutdown concerns.
async fn run_loop(mut self) -> Result<(), InputReaderError> {
let provider = crate::l1::provider::create_provider(&self.config.rpc_url)
.map_err(InputReaderError::Bootstrap)?;
let provider = crate::l1::provider::create_provider(
&self.config.rpc_url,
self.config.allow_insecure_rpc,
)
.map_err(InputReaderError::Bootstrap)?;
loop {
match self.advance_once(&provider).await {
Ok(()) => {}
Expand Down Expand Up @@ -542,6 +554,7 @@ mod tests {
InputReader::from_parts(
InputReaderConfig {
rpc_url,
allow_insecure_rpc: false,
app_address: Address::ZERO,
poll_interval,
long_block_range_error_codes: Vec::new(),
Expand Down Expand Up @@ -660,6 +673,7 @@ mod tests {
let mut reader = InputReader::from_parts(
InputReaderConfig {
rpc_url: anvil.endpoint_url().to_string(),
allow_insecure_rpc: false,
app_address: Address::ZERO,
poll_interval: Duration::from_secs(1),
long_block_range_error_codes: Vec::new(),
Expand Down Expand Up @@ -744,6 +758,7 @@ mod tests {
db_file.path().to_string_lossy().into_owned(),
InputReaderConfig {
rpc_url: "not-a-valid-url".to_string(),
allow_insecure_rpc: false,
app_address: Address::ZERO,
poll_interval: Duration::from_secs(1),
long_block_range_error_codes: Vec::new(),
Expand Down
4 changes: 2 additions & 2 deletions sequencer/src/l1/submitter/poster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ mod tests {
// Anvil account 0 — the submitter; its key signs the (never-sent) txs.
let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key)
let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false)
.expect("signer provider");

let config = BatchPosterConfig {
Expand Down Expand Up @@ -513,7 +513,7 @@ mod tests {
let anvil = Anvil::default().spawn();
let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key)
let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false)
.expect("signer provider");

let wrong_chain_id = anvil.chain_id() + 1;
Expand Down
2 changes: 2 additions & 0 deletions sequencer/src/recovery/flusher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ mod tests {
crate::l1::provider::create_signer_provider(
anvil.endpoint_url().as_str(),
&format!("0x{key_hex}"),
false,
)
.expect("create signer provider")
}
Expand Down Expand Up @@ -750,6 +751,7 @@ mod tests {
let proxied_provider = crate::l1::provider::create_signer_provider(
proxy.endpoint().as_str(),
&format!("0x{key_hex}"),
false,
)
.expect("create signer provider through proxy");

Expand Down
1 change: 1 addition & 0 deletions sequencer/src/recovery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ async fn run_flush_and_cascade(
&l1_config.eth_rpc_url,
&l1_config.batch_submitter_private_key,
l1_config.chain_id,
l1_config.allow_insecure_rpc,
)
.await
.map_err(|e| match e {
Expand Down
Loading