Skip to content
Draft
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
23 changes: 23 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ members = [
"bin/alpen-client",
"bin/alpen-reth",
"bin/strata-sequencer-client",
"bin/strata-signer",
"bin/prover-client",
"bin/prover-perf",
"bin/strata", # New binary
Expand All @@ -146,6 +147,7 @@ default-members = [
"bin/strata",
"bin/strata-dbtool",
"bin/strata-sequencer-client",
"bin/strata-signer",
"bin/alpen-cli",
"bin/strata-test-cli",
]
Expand Down
31 changes: 31 additions & 0 deletions bin/strata-signer/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[package]
name = "strata-signer"
version = "0.1.0"
edition = "2024"

[lints]
workspace = true

[[bin]]
name = "strata-signer"
path = "src/main.rs"

[dependencies]
strata-common.workspace = true
strata-crypto.workspace = true
strata-key-derivation.workspace = true
strata-ol-rpc-api = { workspace = true, features = ["client"] }
strata-ol-sequencer.workspace = true
strata-primitives.workspace = true
strata-tasks.workspace = true

anyhow.workspace = true
argh.workspace = true
bitcoin.workspace = true
jsonrpsee.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
zeroize.workspace = true
13 changes: 13 additions & 0 deletions bin/strata-signer/src/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! CLI argument definitions.

use std::path::PathBuf;

use argh::FromArgs;

#[derive(Debug, FromArgs)]
#[argh(description = "Standalone sequencer signer for Strata")]
pub(crate) struct Args {
/// path to the TOML configuration file
#[argh(option, short = 'c')]
pub(crate) config: PathBuf,
}
53 changes: 53 additions & 0 deletions bin/strata-signer/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//! Configuration for the signer, loaded from a TOML file.

use std::path::PathBuf;

use serde::Deserialize;

use crate::constants::DEFAULT_POLL_INTERVAL_MS;

/// Top-level signer configuration.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SignerConfig {
/// Path to the sequencer root key file (xprv).
pub(crate) sequencer_key: PathBuf,

/// WebSocket RPC URL of the sequencer node (e.g. ws://127.0.0.1:9944).
pub(crate) rpc_url: String,

/// Duty poll interval in milliseconds.
#[serde(default = "default_poll_interval")]
pub(crate) poll_interval: u64,

/// Logging configuration.
#[serde(default)]
pub(crate) logging: LoggingConfig,
}

/// Logging configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub(crate) struct LoggingConfig {
/// Service label appended to the service name (e.g. "prod", "dev").
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) service_label: Option<String>,

/// OpenTelemetry OTLP endpoint URL for distributed tracing.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) otlp_url: Option<String>,

/// Directory path for file-based logging.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) log_dir: Option<PathBuf>,

/// Prefix for log file names.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) log_file_prefix: Option<String>,

/// Use JSON format for logs instead of compact format.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) json_format: Option<bool>,
}

fn default_poll_interval() -> u64 {
DEFAULT_POLL_INTERVAL_MS
}
7 changes: 7 additions & 0 deletions bin/strata-signer/src/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Shared constants.

/// Default duty poll interval in milliseconds.
pub(crate) const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;

/// Shutdown timeout in milliseconds.
pub(crate) const SHUTDOWN_TIMEOUT_MS: u64 = 5_000;
125 changes: 125 additions & 0 deletions bin/strata-signer/src/duty_executor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! Receives signing duties, deduplicates, signs, and submits results via RPC.

use std::{collections::HashSet, sync::Arc};

use jsonrpsee::core::ClientError;
use strata_common::ws_client::ManagedWsClient;
use strata_ol_rpc_api::OLSequencerRpcClient;
use strata_ol_sequencer::{
BlockCompletionData, BlockSigningDuty, CheckpointSigningDuty, Duty, sign_checkpoint,
sign_header,
};
use strata_primitives::{HexBytes64, buf::Buf32};
use thiserror::Error;
use tokio::{select, sync::mpsc, time};
use tracing::{debug, error, info, warn};

#[derive(Debug, Error)]
enum DutyExecError {
#[error("failed completing block template: {0}")]
CompleteTemplate(#[source] ClientError),

#[error("failed submitting checkpoint signature: {0}")]
CompleteCheckpoint(#[source] ClientError),
}

/// Receives duties from the fetcher, deduplicates them, signs, and submits via RPC.
pub(crate) async fn duty_executor_worker(
rpc: Arc<ManagedWsClient>,
mut duty_rx: mpsc::Receiver<Duty>,
sequencer_key: Buf32,
) -> anyhow::Result<()> {
let mut seen_duties: HashSet<Buf32> = HashSet::new();
let (failed_tx, mut failed_rx) = mpsc::channel::<Buf32>(8);

loop {
select! {
duty = duty_rx.recv() => {
let Some(duty) = duty else {
return Ok(());
};

let duty_id = duty.generate_id();
if seen_duties.contains(&duty_id) {
debug!(%duty_id, "skipping already seen duty");
continue;
}
seen_duties.insert(duty_id);

tokio::spawn(handle_duty(
rpc.clone(),
duty,
sequencer_key,
failed_tx.clone(),
));
}
failed = failed_rx.recv() => {
if let Some(duty_id) = failed {
warn!(%duty_id, "removing failed duty for retry");
seen_duties.remove(&duty_id);
}
}
}
}
}

async fn handle_duty(
rpc: Arc<ManagedWsClient>,
duty: Duty,
sk: Buf32,
failed_tx: mpsc::Sender<Buf32>,
) {
let duty_id = duty.generate_id();
debug!(%duty_id, %duty, "handling duty");

let result = match duty {
Duty::SignBlock(block_duty) => handle_sign_block(&rpc, block_duty, &sk).await,
Duty::SignCheckpoint(cp_duty) => handle_sign_checkpoint(&rpc, cp_duty, &sk).await,
};

if let Err(err) = result {
error!(%duty_id, %err, "duty failed");
let _ = failed_tx.send(duty_id).await;
}
}

async fn handle_sign_block(
rpc: &ManagedWsClient,
duty: BlockSigningDuty,
sk: &Buf32,
) -> Result<(), DutyExecError> {
// TODO: recheck this logic
if let Some(wait) = duty.wait_duration() {
debug!(wait_ms = %wait.as_millis(), "waiting for block target time");
time::sleep(wait).await;
}

let template_id = duty.template_id();
let sig = sign_header(duty.template.header(), sk);
let completion = BlockCompletionData::from_signature(sig);

rpc.complete_block_template(template_id, completion)
.await
.map_err(DutyExecError::CompleteTemplate)?;

info!(%template_id, "block signed and submitted");
Ok(())
}

async fn handle_sign_checkpoint(
rpc: &ManagedWsClient,
duty: CheckpointSigningDuty,
sk: &Buf32,
) -> Result<(), DutyExecError> {
let epoch = duty.epoch();
let sig = sign_checkpoint(duty.checkpoint(), sk);

debug!(%epoch, %sig, "signed checkpoint");

rpc.complete_checkpoint_signature(epoch, HexBytes64(sig.0))
.await
.map_err(DutyExecError::CompleteCheckpoint)?;

info!(%epoch, "checkpoint signature submitted");
Ok(())
}
49 changes: 49 additions & 0 deletions bin/strata-signer/src/duty_fetcher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//! Polls the sequencer node for signing duties and forwards them to the executor.

use std::{sync::Arc, time::Duration};

use strata_common::ws_client::ManagedWsClient;
use strata_ol_rpc_api::OLSequencerRpcClient;
use strata_ol_sequencer::Duty;
use tokio::{sync::mpsc, time::interval};
use tracing::{error, info, warn};

/// Polls `get_sequencer_duties()` on a fixed interval and sends converted duties to the channel.
pub(crate) async fn duty_fetcher_worker(
rpc: Arc<ManagedWsClient>,
duty_tx: mpsc::Sender<Duty>,
poll_interval: u64,
) -> anyhow::Result<()> {
let mut interval = interval(Duration::from_millis(poll_interval));

'top: loop {
interval.tick().await;

let rpc_duties = match rpc.get_sequencer_duties().await {
Ok(duties) => duties,
Err(err) => {
error!(%err, "failed to fetch sequencer duties");
continue;
}
};

info!(count = rpc_duties.len(), "fetched duties");

for rpc_duty in rpc_duties {
let duty: Duty = match rpc_duty.try_into() {
Ok(d) => d,
Err(err) => {
warn!(%err, "failed to convert RpcDuty");
continue;
}
};

if duty_tx.send(duty).await.is_err() {
warn!("duty receiver dropped; exiting");
break 'top;
}
}
}

Ok(())
}
44 changes: 44 additions & 0 deletions bin/strata-signer/src/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//! Helpers for sequencer signer key loading.

use std::{fs, path::Path, str::FromStr};

use bitcoin::bip32::Xpriv;
use strata_crypto::keys::zeroizable::ZeroizableXpriv;
use strata_key_derivation::sequencer::SequencerKeys;
use strata_primitives::buf::Buf32;
use tracing::debug;
use zeroize::{Zeroize, ZeroizeOnDrop};

/// Sequencer key data.
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct SequencerKey {
/// Sequencer secret key.
pub(crate) sk: Buf32,

/// Sequencer public key.
pub(crate) pk: Buf32,
}

/// Loads sequencer key from the file at the specified `path`.
pub(crate) fn load_seqkey(path: &Path) -> anyhow::Result<SequencerKey> {
debug!(?path, "loading sequencer root key");
let serialized_xpriv = fs::read_to_string(path)?;
let master_xpriv = ZeroizableXpriv::new(Xpriv::from_str(&serialized_xpriv)?);

let seq_keys = SequencerKeys::new(&master_xpriv)?;
let seq_xpriv = seq_keys.derived_xpriv();
let mut seq_sk = Buf32::from(seq_xpriv.private_key.secret_bytes());
let seq_xpub = seq_keys.derived_xpub();
let seq_pk = seq_xpub.to_x_only_pub().serialize();

let key = SequencerKey {
sk: seq_sk,
pk: Buf32::from(seq_pk),
};

// Zeroize the local copy immediately.
seq_sk.zeroize();

debug!(pubkey = ?key.pk, "ready to sign as sequencer");
Ok(key)
}
Loading
Loading