Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bridge-signer): implement FROST threshold signer node #1960

Open
wants to merge 14 commits into
base: noot/bridge-withdrawer
Choose a base branch
from
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
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.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ exclude = ["tools/protobuf-compiler", "tools/solidity-compiler"]

members = [
"crates/astria-bridge-contracts",
"crates/astria-bridge-signer",
"crates/astria-bridge-withdrawer",
"crates/astria-build-info",
"crates/astria-cli",
Expand Down
37 changes: 37 additions & 0 deletions crates/astria-bridge-signer/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[package]
name = "astria-bridge-signer"
version = "0.1.0"
edition = "2021"
rust-version = "1.83.0"
license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/astriaorg/astria"
homepage = "https://astria.org"

[dependencies]
frost-ed25519 = { version = "2.1.0", features = [] }

ethers = { workspace = true }
futures = { workspace = true }
prost = { workspace = true }
rand = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true, features = ["macros", "signal"] }
tonic = { workspace = true }

astria-bridge-contracts = { path = "../astria-bridge-contracts", features = [
"tracing",
] }
astria-build-info = { path = "../astria-build-info", features = ["runtime"] }
astria-core = { path = "../astria-core", features = ["serde", "server"] }
astria-eyre = { path = "../astria-eyre" }
config = { package = "astria-config", path = "../astria-config" }
telemetry = { package = "astria-telemetry", path = "../astria-telemetry", features = [
"display",
] }

[build-dependencies]
astria-build-info = { path = "../astria-build-info", features = ["build"] }
4 changes: 4 additions & 0 deletions crates/astria-bridge-signer/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
astria_build_info::emit("bridge-signer-v")?;
Ok(())
}
12 changes: 12 additions & 0 deletions crates/astria-bridge-signer/justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
default:
@just --list

set dotenv-load
set fallback

default_env := 'local'
copy-env type=default_env:
cp {{ type }}.env.example .env

run:
cargo run
30 changes: 30 additions & 0 deletions crates/astria-bridge-signer/local.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# A list of filter directives of the form target[span{field=value}]=level.
ASTRIA_BRIDGE_SIGNER_LOG=astria_bridge_signer=info

# The address of the grpc endpoint.
ASTRIA_BRIDGE_SIGNER_GRPC_ENDPOINT="127.0.0.1:9001"

# The path to the json-encoded frost secret key package.
ASTRIA_BRIDGE_SIGNER_FROST_SECRET_KEY_PACKAGE_PATH=""

# Rollup EVM node RPC endpoint.
ASTRIA_BRIDGE_SIGNER_ROLLUP_RPC_ENDPOINT="http://127.0.0.1:8545"

# Disables writing trace data to an opentelemetry endpoint.
ASTRIA_BRIDGE_SIGNER_NO_OTEL=true

# If true disables tty detection and forces writing telemetry to stdout.
# If false span data is written to stdout only if it is connected to a tty.
ASTRIA_BRIDGE_SIGNER_FORCE_STDOUT=false

# If true uses an exceedingly pretty human readable format to write to stdout.
# If false uses JSON formatted OTEL traces.
# This does nothing unless stdout is connected to a tty or
# `ASTRIA_BRIDGE_SIGNER_FORCE_STDOUT` is set to `true`.
ASTRIA_BRIDGE_SIGNER_PRETTY_PRINT=false

# Set to true to disable prometheus metrics.
ASTRIA_BRIDGE_SIGNER_NO_METRICS=true

# The address at which the prometheus HTTP listener will bind if enabled.
ASTRIA_BRIDGE_SIGNER_METRICS_HTTP_LISTENER_ADDR="127.0.0.1:9000"
3 changes: 3 additions & 0 deletions crates/astria-bridge-signer/src/build_info.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use astria_build_info::BuildInfo;

pub const BUILD_INFO: BuildInfo = astria_build_info::get!();
35 changes: 35 additions & 0 deletions crates/astria-bridge-signer/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use serde::{
Deserialize,
Serialize,
};

#[expect(
clippy::struct_excessive_bools,
reason = "This is used as a container for deserialization. Making this a builder-pattern is \
not actionable"
)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Config {
/// The address of the grpc endpoint.
pub grpc_endpoint: String,
/// The path to the json-encoded frost secret key package.
pub frost_secret_key_package_path: String,
/// Rollup EVM node RPC endpoint.
pub rollup_rpc_endpoint: String,
/// Log filter directives.
pub log: String,
/// Forces writing trace data to stdout no matter if connected to a tty or not.
pub force_stdout: bool,
/// Disables writing trace data to an opentelemetry endpoint.
pub no_otel: bool,
/// Set to true to disable the metrics server
pub no_metrics: bool,
/// The endpoint which will be listened on for serving prometheus metrics
pub metrics_http_listener_addr: String,
/// Writes a human readable format to stdout instead of JSON formatted OTEL trace data.
pub pretty_print: bool,
}

impl config::Config for Config {
const PREFIX: &'static str = "ASTRIA_BRIDGE_SIGNER_";
}
181 changes: 181 additions & 0 deletions crates/astria-bridge-signer/src/grpc_server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
use std::{
collections::HashMap,
sync::Arc,
};

use astria_core::generated::astria::signer::v1::{
frost_participant_service_server::FrostParticipantService,
GetVerifyingShareRequest,
GetVerifyingShareResponse,
Part1Request,
Part1Response,
Part2Request,
Part2Response,
};
use astria_eyre::{
eyre,
eyre::WrapErr as _,
};
use frost_ed25519::round1;
use rand::rngs::OsRng;
use tokio::sync::Mutex;
use tonic::{
async_trait,
Request,
Response,
Status,
};
use tracing::{
debug,
instrument,
};

use crate::{
metrics::Metrics,
Verifier,
};

struct State {
next_request_id: u32,
request_id_to_nonces: HashMap<u32, frost_ed25519::round1::SigningNonces>,
}

impl State {
fn get_and_increment_next_request_id(&mut self) -> u32 {
let request_id = self.next_request_id;
self.next_request_id = self.next_request_id.saturating_add(1);
request_id
}
}

pub struct Server {
verifier: Verifier,
metrics: &'static Metrics,
secret_package: frost_ed25519::keys::KeyPackage,
state: Mutex<State>,
}

impl Server {
/// Creates a new `Server` instance.
///
/// # Errors
///
/// - If the secret key package file cannot be read.
/// - If the secret key package cannot be deserialized.
pub fn new(
secret_key_package_path: String,
verifier: Verifier,
metrics: &'static Metrics,
) -> eyre::Result<Self> {
let secret_package = serde_json::from_slice::<frost_ed25519::keys::KeyPackage>(
&std::fs::read(secret_key_package_path)
.wrap_err("failed to read secret key package file")?,
)
.wrap_err("failed to deserialize secret key package")?;

Ok(Self {
verifier,
metrics,
secret_package,
state: Mutex::new(State {
next_request_id: 0,
request_id_to_nonces: HashMap::new(),
}),
})
}
}

#[async_trait]
impl FrostParticipantService for Server {
#[instrument(skip_all)]
async fn get_verifying_share(
self: Arc<Self>,
_request: Request<GetVerifyingShareRequest>,
) -> Result<Response<GetVerifyingShareResponse>, Status> {
let verifying_share = self
.secret_package
.verifying_share()
.to_owned()
.serialize()
.map_err(|e| Status::internal(format!("failed to serialize verifying share: {e}")))?
.into();
Ok(Response::new(GetVerifyingShareResponse {
verifying_share,
}))
}

#[instrument(skip_all)]
async fn part1(
self: Arc<Self>,
_request: Request<Part1Request>,
) -> Result<Response<Part1Response>, Status> {
self.metrics.increment_part_1_request_count();
let mut rng = OsRng;
let (nonces, commitments) =
frost_ed25519::round1::commit(self.secret_package.signing_share(), &mut rng);
let commitment = commitments
.serialize()
.map_err(|e| Status::internal(format!("failed to serialize commitments: {e}")))?
.into();

let mut state = self.state.lock().await;
let request_identifier = state.get_and_increment_next_request_id();
state
.request_id_to_nonces
.insert(request_identifier, nonces);
debug!(request_identifier, "generated part 1 response");
Ok(Response::new(Part1Response {
request_identifier,
commitment,
}))
}

#[instrument(skip_all)]
async fn part2(
self: Arc<Self>,
request: Request<Part2Request>,
) -> Result<Response<Part2Response>, Status> {
self.metrics.increment_part_2_request_count();
let request = request.into_inner();
let mut state = self.state.lock().await;
let Some(nonce) = state
.request_id_to_nonces
.remove(&request.request_identifier)
else {
return Err(Status::invalid_argument("invalid request identifier"));
};

if let Err(e) = self.verifier.verify_message_to_sign(&request.message).await {
self.metrics.increment_invalid_message_count();
return Err(Status::invalid_argument(format!(
"signing message is invalid: {e}"
)));
};

self.metrics.increment_valid_message_count();

let signing_commitments = request
.commitments
.into_iter()
.filter_map(|c| {
Some((
frost_ed25519::Identifier::deserialize(&c.participant_identifier).ok()?,
round1::SigningCommitments::deserialize(&c.commitment).ok()?,
))
})
.collect();
let signing_package =
frost_ed25519::SigningPackage::new(signing_commitments, &request.message);

let signature_share =
frost_ed25519::round2::sign(&signing_package, &nonce, &self.secret_package)
.map_err(|e| Status::internal(format!("failed to sign: {e}")))?
.serialize()
.into();
debug!(request.request_identifier, "generated part 2 response");

Ok(Response::new(Part2Response {
signature_share,
}))
}
}
11 changes: 11 additions & 0 deletions crates/astria-bridge-signer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
mod build_info;
mod config;
mod grpc_server;
mod metrics;
mod verifier;

pub use build_info::BUILD_INFO;
pub use config::Config;
pub use grpc_server::Server;
pub use metrics::Metrics;
pub use verifier::Verifier;
Loading
Loading