diff --git a/Cargo.lock b/Cargo.lock index 2559a1f088..ea6f04db40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -497,6 +497,38 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "astria-auctioneer" +version = "0.0.1" +dependencies = [ + "astria-build-info", + "astria-config", + "astria-core", + "astria-eyre", + "astria-telemetry", + "astria-test-utils", + "async-trait", + "axum", + "futures", + "insta", + "itertools 0.12.1", + "once_cell", + "pin-project-lite", + "prost", + "serde", + "serde_json", + "sha2 0.10.8", + "tempfile", + "tokio", + "tokio-stream", + "tokio-test", + "tokio-util 0.7.11", + "tonic 0.10.2", + "tracing", + "tryhard", + "wiremock", +] + [[package]] name = "astria-bridge-contracts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 649ba90f08..8bf9c6d254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ exclude = ["tools/protobuf-compiler", "tools/solidity-compiler"] members = [ + "crates/astria-auctioneer", "crates/astria-bridge-contracts", "crates/astria-bridge-withdrawer", "crates/astria-build-info", diff --git a/crates/astria-auctioneer/Cargo.toml b/crates/astria-auctioneer/Cargo.toml new file mode 100644 index 0000000000..2deebe0751 --- /dev/null +++ b/crates/astria-auctioneer/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "astria-auctioneer" +version = "0.0.1" +edition = "2021" +rust-version = "1.76" +license = "MIT OR Apache-2.0" +readme = "README.md" +repository = "https://github.com/astriaorg/astria" +homepage = "https://astria.org" + +[[bin]] +name = "astria-auctioneer" + +[dependencies] +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", +] } + +async-trait = { workspace = true } +axum = { workspace = true } +futures = { workspace = true } +itertools = { workspace = true } +once_cell = { workspace = true } +pin-project-lite = { workspace = true } +prost = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = [ + "macros", + "rt-multi-thread", + "sync", + "time", + "signal", +] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true, features = ["attributes"] } +tryhard = { workspace = true } +tonic = { workspace = true } +tokio-stream = { workspace = true, features = ["net"] } + +[dev-dependencies] +astria-core = { path = "../astria-core", features = ["client"] } +config = { package = "astria-config", path = "../astria-config", features = [ + "tests", +] } +insta = { workspace = true, features = ["json"] } +tempfile = { workspace = true } +test_utils = { package = "astria-test-utils", path = "../astria-test-utils", features = [ + "geth", +] } +tokio-test = { workspace = true } +wiremock = { workspace = true } + +[build-dependencies] +astria-build-info = { path = "../astria-build-info", features = ["build"] } diff --git a/crates/astria-auctioneer/README.md b/crates/astria-auctioneer/README.md new file mode 100644 index 0000000000..33936060e0 --- /dev/null +++ b/crates/astria-auctioneer/README.md @@ -0,0 +1,35 @@ +# Astria Auctioneer + +TODO: Add a description of the binary. + +## Running The Auctioneer + +### Dependencies + +We use [just](https://just.systems/man/en/chapter_4.html) for convenient project +specific commands. + +### Configuration + +The Auctioneer is configured via environment variables. An example configuration +can be seen in `local.env.example`. + +To copy a configuration to your `.env` file run: + +```sh + +# Can specify an environment +just copy-env + +# By default will copy `local.env.example` +just copy-env +``` + +### Running locally + +After creating a `.env` file either manually or by copying as above, `just` will +load it and run locally: + +```bash +just run +``` diff --git a/crates/astria-auctioneer/build.rs b/crates/astria-auctioneer/build.rs new file mode 100644 index 0000000000..7347735f49 --- /dev/null +++ b/crates/astria-auctioneer/build.rs @@ -0,0 +1,4 @@ +pub fn main() -> Result<(), Box> { + astria_build_info::emit("auctioneer-v")?; + Ok(()) +} diff --git a/crates/astria-auctioneer/justfile b/crates/astria-auctioneer/justfile new file mode 100644 index 0000000000..e5c9ef9653 --- /dev/null +++ b/crates/astria-auctioneer/justfile @@ -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 diff --git a/crates/astria-auctioneer/local.env.example b/crates/astria-auctioneer/local.env.example new file mode 100644 index 0000000000..0dd1638542 --- /dev/null +++ b/crates/astria-auctioneer/local.env.example @@ -0,0 +1,91 @@ +# Configuration options of Astria AUCTIONEER. + +# Log level. One of debug, info, warn, or error +ASTRIA_AUCTIONEER_LOG="astria_AUCTIONEER=info" + +# If true disables writing to the opentelemetry OTLP endpoint. +ASTRIA_AUCTIONEER_NO_OTEL=false + +# 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_AUCTIONEER_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_AUCTIONEER_FORCE_STDOUT` is set to `true`. +ASTRIA_AUCTIONEER_PRETTY_PRINT=false + +# If set to any non-empty value removes ANSI escape characters from the pretty +# printed output. Note that this does nothing unless `ASTRIA_AUCTIONEER_PRETTY_PRINT` +# is set to `true`. +NO_COLOR= + +# Address of the API server +ASTRIA_AUCTIONEER_API_ADDR="0.0.0.0:0" + +# Address of the RPC server for the sequencer chain +ASTRIA_AUCTIONEER_SEQUENCER_URL="http://127.0.0.1:26657" + +# Chain ID of the sequencer chain which transactions are submitted to. +ASTRIA_AUCTIONEER_SEQUENCER_CHAIN_ID="astria-dev-1" + +# A list of execution `::,::`. +# Rollup names are not case sensitive. If a name is repeated, the last list item is used. +# names are sha256 hashed and used as the `rollup_id` in `SequenceAction`s +ASTRIA_AUCTIONEER_ROLLUPS="astriachain::ws://127.0.0.1:8545" + +# The path to the file storing the private key for the sequencer account used for signing +# transactions. The file should contain a hex-encoded Ed25519 secret key. +ASTRIA_AUCTIONEER_PRIVATE_KEY_FILE=/path/to/priv_sequencer_key.json + +# The prefix that will be used to construct bech32m sequencer addresses. +ASTRIA_AUCTIONEER_SEQUENCER_ADDRESS_PREFIX=astria + +# Block time in milliseconds, used to force submitting of finished bundles. +# Should match the sequencer node configuration for 'timeout_commit', as +# specified in https://docs.tendermint.com/v0.34/tendermint-core/configuration.html +ASTRIA_AUCTIONEER_MAX_SUBMIT_INTERVAL_MS=2000 + +# Max bytes to encode into a single sequencer `SignedTransaction`, not including signature, +# public key, nonce. This is the sum of the sizes of all the `SequenceAction`s. Should be +# set below the sequencer's max block size to allow space for encoding, signature, public +# key and nonce bytes +ASTRIA_AUCTIONEER_MAX_BYTES_PER_BUNDLE=200000 + +# Max amount of finished bundles that can be in the submission queue. +# ASTRIA_AUCTIONEER_BUNDLE_QUEUE_CAPACITY * ASTRIA_AUCTIONEER_MAX_BYTES_PER_BUNDLE (e.g. +# 40000 * 200KB=8GB) is the limit on how much memory the finished bundle queue can consume. +# This should be lower than the resource limit enforced by Kubernetes on the pod, defined here: +# https://github.com/astriaorg/astria/blob/622d4cb8695e4fbcd86456bd16149420b8acda79/charts/evm-rollup/values.yaml#L276 +ASTRIA_AUCTIONEER_BUNDLE_QUEUE_CAPACITY=40000 + +# Set to true to enable prometheus metrics. +ASTRIA_AUCTIONEER_NO_METRICS=true + +# The address at which the prometheus HTTP listener will bind if enabled. +ASTRIA_AUCTIONEER_METRICS_HTTP_LISTENER_ADDR="127.0.0.1:9000" + +# The address at which the gRPC collector and health services are listening. +ASTRIA_AUCTIONEER_GRPC_ADDR="0.0.0.0:0" + +# The asset to use for paying for transactions submitted to sequencer. +ASTRIA_AUCTIONEER_FEE_ASSET="nria" + +# The OTEL specific config options follow the OpenTelemetry Protocol Exporter v1 +# specification as defined here: +# https://github.com/open-telemetry/opentelemetry-specification/blob/e94af89e3d0c01de30127a0f423e912f6cda7bed/specification/protocol/exporter.md + +# Sets the general OTLP endpoint. +OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" +# Sets the OTLP endpoint for trace data. This takes precedence over `OTEL_EXPORTER_OTLP_ENDPOINT` if set. +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:4317/v1/traces" +# The duration in seconds that the OTEL exporter will wait for each batch export. +OTEL_EXPORTER_OTLP_TRACES_TIMEOUT=10 +# The compression format to use for exporting. Only `"gzip"` is supported. +# Don't set the env var if no compression is required. +OTEL_EXPORTER_OTLP_TRACES_COMPRESSION="gzip" +# The HTTP headers that will be set when sending gRPC requests. +OTEL_EXPORTER_OTLP_HEADERS="key1=value1,key2=value2" +# The HTTP headers that will be set when sending gRPC requests. This takes precedence over `OTEL_EXPORTER_OTLP_HEADERS` if set. +OTEL_EXPORTER_OTLP_TRACE_HEADERS="key1=value1,key2=value2" diff --git a/crates/astria-auctioneer/src/auction_driver/builder.rs b/crates/astria-auctioneer/src/auction_driver/builder.rs new file mode 100644 index 0000000000..89baebd619 --- /dev/null +++ b/crates/astria-auctioneer/src/auction_driver/builder.rs @@ -0,0 +1,20 @@ +use astria_eyre::eyre; + +use super::AuctionDriver; +use crate::Metrics; + +pub(crate) struct Builder { + pub(crate) metrics: &'static Metrics, +} + +impl Builder { + pub(crate) fn build(self) -> eyre::Result { + let Self { + metrics, + } = self; + + Ok(AuctionDriver { + metrics, + }) + } +} diff --git a/crates/astria-auctioneer/src/auction_driver/mod.rs b/crates/astria-auctioneer/src/auction_driver/mod.rs new file mode 100644 index 0000000000..d312cfb95f --- /dev/null +++ b/crates/astria-auctioneer/src/auction_driver/mod.rs @@ -0,0 +1,17 @@ +use astria_eyre::eyre; + +use crate::Metrics; + +mod builder; +pub(crate) use builder::Builder; + +pub(crate) struct AuctionDriver { + #[allow(dead_code)] + metrics: &'static Metrics, +} + +impl AuctionDriver { + pub(crate) async fn run(self) -> eyre::Result<()> { + todo!("implement me") + } +} diff --git a/crates/astria-auctioneer/src/auctioneer/inner.rs b/crates/astria-auctioneer/src/auctioneer/inner.rs new file mode 100644 index 0000000000..a6f302c2dc --- /dev/null +++ b/crates/astria-auctioneer/src/auctioneer/inner.rs @@ -0,0 +1,140 @@ +use std::time::Duration; + +use astria_eyre::eyre::{ + self, + WrapErr as _, +}; +use itertools::Itertools as _; +use tokio::{ + select, + task::JoinError, + time::timeout, +}; +use tokio_util::{ + sync::CancellationToken, + task::JoinMap, +}; +use tracing::{ + error, + info, + warn, +}; + +use crate::{ + auction_driver, + Config, + Metrics, +}; + +pub(super) struct Auctioneer { + /// Used to signal the service to shutdown + shutdown_token: CancellationToken, + + /// The different long-running tasks that make up the Auctioneer + tasks: JoinMap<&'static str, eyre::Result<()>>, +} + +impl Auctioneer { + const AUCTION_DRIVER: &'static str = "auction_driver"; + const _BUNDLE_COLLECTOR: &'static str = "bundle_collector"; + const _OPTIMISTIC_EXECUTOR: &'static str = "optimistic_executor"; + + /// Creates an [`Auctioneer`] service from a [`Config`] and [`Metrics`]. + pub(super) fn new( + cfg: Config, + metrics: &'static Metrics, + shutdown_token: CancellationToken, + ) -> eyre::Result { + let Config { + .. + } = cfg; + + let mut tasks = JoinMap::new(); + + // TODO: add tasks here + // - optimistic executor + // - bundle collector + // - auction driver + // - runs the auction + // - runs the sequencer submitter + let auction_driver = auction_driver::Builder { + metrics, + } + .build() + .wrap_err("failed to initialize the auction driver")?; + tasks.spawn(Self::AUCTION_DRIVER, auction_driver.run()); + + Ok(Self { + shutdown_token, + tasks, + }) + } + + /// Runs the [`Auctioneer`] service until it received an exit signal, or one of the constituent + /// tasks either ends unexpectedly or returns an error. + pub(super) async fn run(mut self) -> eyre::Result<()> { + let reason = select! { + biased; + + () = self.shutdown_token.cancelled() => { + Ok("auctioneer received shutdown signal") + }, + + Some((name, res)) = self.tasks.join_next() => { + flatten(res) + .wrap_err_with(|| format!("task `{name}` failed")) + .map(|_| "task `{name}` exited unexpectedly") + } + }; + + match reason { + Ok(msg) => info!(%msg, "received shutdown signal"), + Err(err) => error!(%err, "shutting down due to error"), + } + + self.shutdown().await; + Ok(()) + } + + /// Initiates shutdown of the Auctioneer and waits for all the constituent tasks to shut down. + async fn shutdown(mut self) { + self.shutdown_token.cancel(); + + let shutdown_loop = async { + while let Some((name, res)) = self.tasks.join_next().await { + let message = "task shut down"; + match flatten(res) { + Ok(()) => { + info!(name, message) + } + Err(err) => { + error!(name, %err, message) + } + } + } + }; + + info!("signalling all tasks to shut down; waiting 25 seconds for exit"); + if timeout(Duration::from_secs(25), shutdown_loop) + .await + .is_err() + { + let tasks = self.tasks.keys().join(", "); + warn!( + tasks = format_args!("[{tasks}]"), + "aborting all tasks that have not yet shut down" + ) + } else { + info!("all tasks have shut down regularly"); + } + info!("shutting down"); + } +} + +pub(super) fn flatten(res: Result, JoinError>) -> eyre::Result { + match res { + Ok(Ok(val)) => Ok(val), + Ok(Err(err)) => Err(err).wrap_err("task returned with error"), + Err(err) => Err(err).wrap_err("task panicked"), + } +} diff --git a/crates/astria-auctioneer/src/auctioneer/mod.rs b/crates/astria-auctioneer/src/auctioneer/mod.rs new file mode 100644 index 0000000000..aed31d40bd --- /dev/null +++ b/crates/astria-auctioneer/src/auctioneer/mod.rs @@ -0,0 +1,80 @@ +use std::future::Future; + +use astria_eyre::eyre::{ + self, +}; +use pin_project_lite::pin_project; +use tokio::task::{ + JoinError, + JoinHandle, +}; +use tokio_util::sync::CancellationToken; +use tracing::instrument; + +use crate::{ + Config, + Metrics, +}; + +mod inner; + +pin_project! { + /// Handle to the [`Auctioneer`] service, returned by [`Auctioneer::spawn`]. + pub struct Auctioneer { + shutdown_token: CancellationToken, + task: Option>>, + } +} + +impl Auctioneer { + /// Creates an [`Auctioneer`] service and runs it, returning a handle to the taks and shutdown + /// token. + /// + /// # Errors + /// Returns an error if the Auctioneer cannot be initialized. + #[must_use] + pub fn spawn(cfg: Config, metrics: &'static Metrics) -> eyre::Result { + let shutdown_token = CancellationToken::new(); + let inner = inner::Auctioneer::new(cfg, metrics, shutdown_token.child_token())?; + let task = tokio::spawn(inner.run()); + + Ok(Self { + shutdown_token, + task: Some(task), + }) + } + + /// Initiates shutdown of the Auctioneer and returns its result. + /// + /// # Errors + /// Returns an error if the Auctioneer exited with an error. + /// + /// # Panics + /// Panics if shutdown is called twice. + #[instrument(skip_all, err)] + pub async fn shutdown(&mut self) -> Result, JoinError> { + self.shutdown_token.cancel(); + self.task + .take() + .expect("shutdown must not be called twice") + .await + } +} + +impl Future for Auctioneer { + type Output = Result, tokio::task::JoinError>; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + use futures::future::FutureExt as _; + + let this = self.project(); + let task = this + .task + .as_mut() + .expect("the Auctioneer handle must not be polled after shutdown"); + task.poll_unpin(cx) + } +} diff --git a/crates/astria-auctioneer/src/build_info.rs b/crates/astria-auctioneer/src/build_info.rs new file mode 100644 index 0000000000..2996fcab96 --- /dev/null +++ b/crates/astria-auctioneer/src/build_info.rs @@ -0,0 +1,3 @@ +use astria_build_info::BuildInfo; + +pub const BUILD_INFO: BuildInfo = astria_build_info::get!(); diff --git a/crates/astria-auctioneer/src/config.rs b/crates/astria-auctioneer/src/config.rs new file mode 100644 index 0000000000..9a8db6dbed --- /dev/null +++ b/crates/astria-auctioneer/src/config.rs @@ -0,0 +1,40 @@ +use serde::{ + Deserialize, + Serialize, +}; + +// Allowed `struct_excessive_bools` because this is used as a container +// for deserialization. Making this a builder-pattern is not actionable. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +/// The single config for creating an astria-auctioneer service. +pub struct Config { + /// Log level for the service. + 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_WITHDRAWER_"; +} + +#[cfg(test)] +mod tests { + use super::Config; + + const EXAMPLE_ENV: &str = include_str!("../local.env.example"); + + #[test] + fn example_env_config_is_up_to_date() { + config::tests::example_env_config_is_up_to_date::(EXAMPLE_ENV); + } +} diff --git a/crates/astria-auctioneer/src/lib.rs b/crates/astria-auctioneer/src/lib.rs new file mode 100644 index 0000000000..cbc2c3ca61 --- /dev/null +++ b/crates/astria-auctioneer/src/lib.rs @@ -0,0 +1,13 @@ +//! TODO: Add a description + +mod auction_driver; +mod auctioneer; +mod build_info; +pub mod config; +pub(crate) mod metrics; + +pub use auctioneer::Auctioneer; +pub use build_info::BUILD_INFO; +pub use config::Config; +pub use metrics::Metrics; +pub use telemetry; diff --git a/crates/astria-auctioneer/src/main.rs b/crates/astria-auctioneer/src/main.rs new file mode 100644 index 0000000000..c6d9ea0db0 --- /dev/null +++ b/crates/astria-auctioneer/src/main.rs @@ -0,0 +1,87 @@ +use std::process::ExitCode; + +use astria_auctioneer::{ + Auctioneer, + Config, + BUILD_INFO, +}; +use astria_eyre::eyre::WrapErr as _; +use tokio::{ + select, + signal::unix::{ + signal, + SignalKind, + }, +}; +use tracing::{ + error, + info, + warn, +}; + +#[tokio::main] +async fn main() -> ExitCode { + astria_eyre::install().expect("astria eyre hook must be the first hook installed"); + + eprintln!("{}", telemetry::display::json(&BUILD_INFO)); + + let cfg: Config = config::get().expect("failed to read configuration"); + eprintln!("{}", telemetry::display::json(&cfg),); + + let mut telemetry_conf = telemetry::configure() + .set_no_otel(cfg.no_otel) + .set_force_stdout(cfg.force_stdout) + .set_pretty_print(cfg.pretty_print) + .set_filter_directives(&cfg.log); + + if !cfg.no_metrics { + telemetry_conf = + telemetry_conf.set_metrics(&cfg.metrics_http_listener_addr, env!("CARGO_PKG_NAME")); + } + + let (metrics, _telemetry_guard) = match telemetry_conf + .try_init(&()) + .wrap_err("failed to setup telemetry") + { + Err(e) => { + eprintln!("initializing auctioneer failed:\n{e:?}"); + return ExitCode::FAILURE; + } + Ok(metrics_and_guard) => metrics_and_guard, + }; + + info!( + config = serde_json::to_string(&cfg).expect("serializing to a string cannot fail"), + "initializing auctioneer" + ); + + let mut auctioneer = match Auctioneer::spawn(cfg, metrics) { + Ok(auctioneer) => auctioneer, + Err(error) => { + error!(%error, "failed initializing auctioneer"); + return ExitCode::FAILURE; + } + }; + + let mut sigterm = signal(SignalKind::terminate()) + .expect("setting a SIGTERM listener should always work on Unix"); + + select! { + _ = sigterm.recv() => { + info!("received SIGTERM; shutting down"); + if let Err(error) = auctioneer.shutdown().await { + warn!(%error, "encountered an error while shutting down"); + } + info!("auctioneer stopped"); + ExitCode::SUCCESS + } + + res = &mut auctioneer => { + error!( + error = res.err().map(tracing::field::display), + "auctioneer task exited unexpectedly" + ); + ExitCode::FAILURE + } + } +} diff --git a/crates/astria-auctioneer/src/metrics.rs b/crates/astria-auctioneer/src/metrics.rs new file mode 100644 index 0000000000..3ecafc3fad --- /dev/null +++ b/crates/astria-auctioneer/src/metrics.rs @@ -0,0 +1,25 @@ +use telemetry::{ + metric_names, + metrics::{ + self, + RegisteringBuilder, + }, +}; + +pub struct Metrics {} + +impl Metrics {} + +impl metrics::Metrics for Metrics { + type Config = (); + + fn register( + _builder: &mut RegisteringBuilder, + _config: &Self::Config, + ) -> Result { + Ok(Self {}) + } +} + +metric_names!(const METRICS_NAMES: +);