diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 74cb1f925d..684c91ecf1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,7 +27,7 @@ reason = "Internal change only." Do not add an entry for a protocol, Rust MSRV, or database migration version update. Release notes derive these updates from repository files. -Allowed scopes: rpc, docs, node, note-transport, network-monitor, ntx-builder, prover, validator, internal, general +Allowed scopes: rpc, docs, node, note-transport, network-monitor, funding-service, ntx-builder, prover, validator, internal, general Allowed impacts: breaking, added, changed, fixed, removed, deprecated --> diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a4bacb16d6..0f6edc5ad0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -104,6 +104,7 @@ jobs: {"component":"ntx-builder", "bin":"miden-ntx-builder", "port":50301, "target":"runtime"}, {"component":"remote-prover", "bin":"miden-remote-prover", "port":50051, "target":"runtime"}, {"component":"network-monitor", "bin":"miden-network-monitor", "port":3000, "target":"runtime"}, + {"component":"funding-service", "bin":"miden-funding-service", "port":50401, "target":"runtime"}, {"component":"node-tps-benchmark", "bin":"miden-benchmark", "target":"runtime-tool"} ] ' @@ -306,6 +307,8 @@ jobs: MIDEN_REMOTE_PROVER_IMAGE: ${{ env.REGISTRY_PREFIX }}/miden-remote-prover:${{ needs.preflight.outputs.tag }} MIDEN_NETWORK_MONITOR_IMAGE: ${{ env.REGISTRY_PREFIX }}/miden-network-monitor:${{ needs.preflight.outputs.tag }} + MIDEN_FUNDING_SERVICE_IMAGE: + ${{ env.REGISTRY_PREFIX }}/miden-funding-service:${{ needs.preflight.outputs.tag }} MIDEN_BENCHMARK_IMAGE: ${{ env.REGISTRY_PREFIX }}/miden-node-tps-benchmark:${{ needs.preflight.outputs.tag }} with: compose-file: docker-compose.yml @@ -395,6 +398,8 @@ jobs: ${{ env.REGISTRY_PREFIX }}/miden-remote-prover:${{ needs.preflight.outputs.immutable_tag }} MIDEN_NETWORK_MONITOR_IMAGE: ${{ env.REGISTRY_PREFIX }}/miden-network-monitor:${{ needs.preflight.outputs.immutable_tag }} + MIDEN_FUNDING_SERVICE_IMAGE: + ${{ env.REGISTRY_PREFIX }}/miden-funding-service:${{ needs.preflight.outputs.immutable_tag }} MIDEN_BENCHMARK_IMAGE: ${{ env.REGISTRY_PREFIX }}/miden-node-tps-benchmark:${{ needs.preflight.outputs.immutable_tag }} with: diff --git a/Cargo.lock b/Cargo.lock index de46c1a555..152845de7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3961,6 +3961,30 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miden-funding-service" +version = "0.17.0-rc.1" +dependencies = [ + "anyhow", + "axum", + "backon", + "clap", + "humantime", + "miden-node-proto", + "miden-node-tracing", + "miden-node-utils", + "miden-protocol", + "miden-standards", + "rand 0.10.2", + "rand_chacha 0.10.0", + "serde", + "tempfile", + "tokio", + "tower", + "tower-http", + "url", +] + [[package]] name = "miden-large-account-benchmark" version = "0.17.0-rc.1" @@ -7652,6 +7676,7 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 4561a5b5d7..6162843e53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "bin/benchmark", + "bin/funding-service", "bin/large-account-benchmark", "bin/network-monitor", "bin/node", diff --git a/Dockerfile b/Dockerfile index e593e76f17..258e0ebce1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,6 +109,7 @@ RUN --mount=type=cache,sharing=locked,id=cargo-registry-${TARGETARCH},target=/us --bin miden-note-transport \ --bin miden-ntx-builder \ --bin miden-network-monitor \ + --bin miden-funding-service \ --bin miden-remote-prover \ --bin miden-benchmark && \ mkdir -p /app/bin && \ @@ -117,6 +118,7 @@ RUN --mount=type=cache,sharing=locked,id=cargo-registry-${TARGETARCH},target=/us /app/target/release/miden-note-transport \ /app/target/release/miden-ntx-builder \ /app/target/release/miden-network-monitor \ + /app/target/release/miden-funding-service \ /app/target/release/miden-remote-prover \ /app/target/release/miden-benchmark \ /app/bin/ && \ diff --git a/Makefile b/Makefile index debd86ef44..330f735f72 100644 --- a/Makefile +++ b/Makefile @@ -157,6 +157,10 @@ install-note-transport: ## Installs note transport install-ntx-builder: ## Installs ntx-builder cargo install --path bin/ntx-builder --locked +.PHONY: install-funding-service +install-funding-service: ## Installs funding service + cargo install --path bin/funding-service --locked + .PHONY: install-remote-prover install-remote-prover: ## Install remote prover's CLI cargo install --path bin/remote-prover --bin miden-remote-prover --locked @@ -216,6 +220,7 @@ docker-build: docker-build-node \ docker-build-note-transport \ docker-build-ntx-builder \ docker-build-monitor \ + docker-build-funding-service \ docker-build-remote-prover \ docker-build-benchmark @@ -285,6 +290,19 @@ docker-build-monitor: ## Builds the network monitor using Docker --build-arg PORT=3000 \ -t miden-network-monitor . +.PHONY: docker-build-funding-service +docker-build-funding-service: ## Builds the funding service using Docker + @CREATED=$$(date -u +'%Y-%m-%dT%H:%M:%SZ') && \ + VERSION="$(DOCKER_VERSION)" && \ + COMMIT=$$(git rev-parse HEAD) && \ + $(DOCKER_COMMAND) build $(DOCKER_PULL_ARG) $(DOCKER_PLATFORM_ARG) \ + --build-arg CREATED="$$CREATED" \ + --build-arg VERSION="$$VERSION" \ + --build-arg COMMIT="$$COMMIT" \ + --build-arg BIN=miden-funding-service \ + --build-arg PORT=50401 \ + -t miden-funding-service . + .PHONY: docker-build-remote-prover docker-build-remote-prover: ## Builds the remote prover using Docker @CREATED=$$(date -u +'%Y-%m-%dT%H:%M:%SZ') && \ diff --git a/bin/funding-service/Cargo.toml b/bin/funding-service/Cargo.toml new file mode 100644 index 0000000000..fdd3e6265e --- /dev/null +++ b/bin/funding-service/Cargo.toml @@ -0,0 +1,41 @@ +[package] +authors.workspace = true +description = "Miden funding service" +edition.workspace = true +homepage.workspace = true +keywords = ["funding", "miden"] +license.workspace = true +name = "miden-funding-service" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[lib] +doctest = false + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +backon = { workspace = true } +clap = { features = ["env", "string"], workspace = true } +humantime = { workspace = true } +miden-node-proto = { workspace = true } +miden-node-tracing = { workspace = true } +miden-node-utils = { workspace = true } +miden-protocol = { features = ["std"], workspace = true } +serde = { workspace = true } +tokio = { features = ["macros", "net", "rt-multi-thread", "sync", "time"], workspace = true } +tower-http = { features = ["timeout"], workspace = true } +url = { workspace = true } + +[dev-dependencies] +miden-protocol = { features = ["std", "testing"], workspace = true } +miden-standards = { workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +tempfile = { workspace = true } +tower = { features = ["util"], workspace = true } diff --git a/bin/funding-service/README.md b/bin/funding-service/README.md new file mode 100644 index 0000000000..b01dc1c3e7 --- /dev/null +++ b/bin/funding-service/README.md @@ -0,0 +1,20 @@ +# Miden funding service + +`miden-funding-service` is a Miden node binary that sends the chain's native asset to any account that asks for it. + +## Operation + +The service holds no chain state. It reads the funding account from the node, so a restart needs no recovery. Only the +account file, which holds the account ID and its signing key, is on disk. + +The service reads the chain's protocol configuration from the node at startup, together with the genesis block header. + +The service serves a JSON HTTP API. `GET /status` reports the funding account, its balance, and the block that balance +was read at. An operator alerts on that balance, because the service does not refill itself. + +The service does not authenticate requests. An operator must restrict access to its HTTP API at the infrastructure +level. + +## License + +This project is [MIT licensed](../../LICENSE). diff --git a/bin/funding-service/src/account.rs b/bin/funding-service/src/account.rs new file mode 100644 index 0000000000..2fe767e083 --- /dev/null +++ b/bin/funding-service/src/account.rs @@ -0,0 +1,107 @@ +//! Loading of the funding account. + +use std::path::Path; + +use anyhow::{Context, Result}; +use miden_protocol::account::{AccountFile, AccountId, AccountType}; + +// FUNDER KEY +// ================================================================================================ + +/// The identity of the funding account, loaded from its account file. +#[derive(Clone, Debug)] +pub struct FunderKey { + account_id: AccountId, +} + +impl FunderKey { + /// Reads the funding account from an account file. + pub fn load(path: &Path) -> Result { + let account_file = AccountFile::read(path) + .with_context(|| format!("failed to read the account file at {}", path.display()))?; + + let account = account_file.account; + anyhow::ensure!( + account.id().account_type() == AccountType::Public, + "the funding account {} is not public: the service reads its state from the node, \ + which only stores the full state of a public account", + account.id(), + ); + + Ok(Self { account_id: account.id() }) + } + + pub fn account_id(&self) -> AccountId { + self.account_id + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::ONE; + use miden_protocol::account::auth::{AuthScheme, AuthSecretKey}; + use miden_protocol::account::{Account, AccountType}; + use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; + use miden_standards::account::auth::Approver; + use miden_standards::account::wallets::create_basic_wallet; + use rand::{RngExt, SeedableRng}; + use rand_chacha::ChaCha20Rng; + + use super::*; + + /// Builds a wallet the way the genesis configuration does, so the test covers the file the + /// service actually loads. + fn genesis_wallet(account_type: AccountType) -> (Account, SecretKey) { + let mut rng = ChaCha20Rng::from_seed([7; 32]); + let secret_key = SecretKey::with_rng(&mut rng); + let auth = Approver::new(secret_key.public_key().into(), AuthScheme::Falcon512Poseidon2); + let init_seed: [u8; 32] = rng.random(); + let mut account = + create_basic_wallet(init_seed, auth, account_type).expect("wallet should build"); + account.set_nonce(ONE).expect("nonce should be settable"); + (account, secret_key) + } + + fn write_account_file( + dir: &Path, + account: &Account, + keys: Vec, + ) -> std::path::PathBuf { + let path = dir.join("funding_service.mac"); + AccountFile::new(account.clone(), keys) + .write(&path) + .expect("file should be written"); + path + } + + #[test] + fn loads_a_public_wallet_with_its_key() { + let dir = tempfile::tempdir().unwrap(); + let (account, secret_key) = genesis_wallet(AccountType::Public); + let path = write_account_file( + dir.path(), + &account, + vec![AuthSecretKey::Falcon512Poseidon2(secret_key)], + ); + + let funder = FunderKey::load(&path).expect("a public wallet with a key should load"); + + assert_eq!(funder.account_id(), account.id()); + } + + /// The service reads the funder's vault from the node, which is only possible for a public + /// account. + #[test] + fn rejects_a_private_account() { + let dir = tempfile::tempdir().unwrap(); + let (account, secret_key) = genesis_wallet(AccountType::Private); + let path = write_account_file( + dir.path(), + &account, + vec![AuthSecretKey::Falcon512Poseidon2(secret_key)], + ); + + let err = FunderKey::load(&path).expect_err("a private account must be rejected"); + assert!(err.to_string().contains("is not public"), "unexpected error: {err}"); + } +} diff --git a/bin/funding-service/src/commands/mod.rs b/bin/funding-service/src/commands/mod.rs new file mode 100644 index 0000000000..99f86621a7 --- /dev/null +++ b/bin/funding-service/src/commands/mod.rs @@ -0,0 +1,123 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use miden_funding_service::{ + DEFAULT_HTTP_TIMEOUT, + DEFAULT_MAX_AMOUNT, + DEFAULT_RPC_TIMEOUT, + FundingServiceConfig, +}; +use miden_node_tracing::{OpenTelemetry, info}; +use miden_node_utils::clap::duration_to_human_readable_string; +use miden_node_utils::formatting::format_endpoint; +use miden_node_utils::shutdown::CancellationToken; +use tokio::net::TcpListener; +use url::Url; + +const ENV_LISTEN: &str = "MIDEN_FUNDING_LISTEN"; +const ENV_HTTP_TIMEOUT: &str = "MIDEN_FUNDING_HTTP_TIMEOUT"; +const ENV_RPC_URL: &str = "MIDEN_FUNDING_RPC_URL"; +const ENV_RPC_TIMEOUT: &str = "MIDEN_FUNDING_RPC_TIMEOUT"; +const ENV_ACCOUNT_FILE: &str = "MIDEN_FUNDING_ACCOUNT_FILE"; +const ENV_MAX_AMOUNT: &str = "MIDEN_FUNDING_MAX_AMOUNT"; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub enum FundingServiceCommand { + /// Starts the funding service. + Start { + /// Socket address at which to serve the funding service's HTTP API. + #[arg(long = "listen", env = ENV_LISTEN, value_name = "IP:PORT")] + listen: SocketAddr, + + /// Maximum duration allocated to an HTTP request served by the funding service. + #[arg( + long = "http.timeout", + env = ENV_HTTP_TIMEOUT, + default_value = duration_to_human_readable_string(DEFAULT_HTTP_TIMEOUT), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + http_timeout: Duration, + + /// The node RPC service gRPC url. + #[arg(long = "rpc.url", env = ENV_RPC_URL, value_name = "URL")] + rpc_url: Url, + + /// Request timeout for calls to the node RPC service. + #[arg( + long = "rpc.timeout", + env = ENV_RPC_TIMEOUT, + default_value = duration_to_human_readable_string(DEFAULT_RPC_TIMEOUT), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + rpc_timeout: Duration, + + /// Path to the account file of the funding account. + #[arg(long = "account-file", env = ENV_ACCOUNT_FILE, value_name = "PATH")] + account_file: PathBuf, + + /// Largest amount one request may ask for, in base units of the native asset. + #[arg( + long = "max-amount", + env = ENV_MAX_AMOUNT, + default_value_t = DEFAULT_MAX_AMOUNT, + value_name = "AMOUNT" + )] + max_amount: u64, + }, +} + +impl FundingServiceCommand { + pub async fn handle(self, shutdown: CancellationToken) -> Result<()> { + let Self::Start { + listen, + http_timeout, + rpc_url, + rpc_timeout, + account_file, + max_amount, + } = self; + + info!( + target: miden_funding_service::LOG_TARGET, + "Starting the funding service", + service.name = "miden-funding-service", + service.version = env!("CARGO_PKG_VERSION"), + funding_service.listen = listen.to_string(), + http.timeout = humantime::Duration::from(http_timeout).to_string(), + rpc.endpoint = format_endpoint(&rpc_url), + rpc.timeout = humantime::Duration::from(rpc_timeout).to_string(), + account.file = account_file.as_path(), + funding_service.max_amount = max_amount + ); + + let listener = TcpListener::bind(listen) + .await + .context("failed to bind to the funding service's HTTP socket")?; + + FundingServiceConfig::new(rpc_url, account_file) + .with_http_timeout(http_timeout) + .with_rpc_timeout(rpc_timeout) + .with_max_amount(max_amount) + .build() + .await + .context("failed to initialize the funding service")? + .run(listener, shutdown) + .await + .context("failed while running the funding service") + } + + /// The OpenTelemetry configuration of the only command. + #[expect( + clippy::unused_self, + reason = "the caller reads this from the parsed command, like the other binaries" + )] + pub fn open_telemetry(&self) -> OpenTelemetry { + OpenTelemetry::from_env().with_name("funding-service") + } +} diff --git a/bin/funding-service/src/lib.rs b/bin/funding-service/src/lib.rs new file mode 100644 index 0000000000..c5fbfe5027 --- /dev/null +++ b/bin/funding-service/src/lib.rs @@ -0,0 +1,183 @@ +//! The service owns one wallet account which holds the chain's native asset, and sends that asset +//! to any account which asks for it. + +// Required by code generated by the upstream `#[instrument]` macro. +extern crate miden_node_tracing as tracing; + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use miden_node_tracing::info; +use miden_node_utils::shutdown::CancellationToken; +use miden_node_utils::tasks::Tasks; +use miden_protocol::asset::{AssetId, FungibleAsset}; +use tokio::net::TcpListener; +use url::Url; + +use crate::account::FunderKey; +use crate::node::RpcNodeClient; +use crate::server::FundingServer; +use crate::status::{StatusRefresher, StatusSnapshot}; + +mod account; +mod node; +mod server; +mod status; + +// CONSTANTS +// ================================================================================================= + +const COMPONENT: &str = "miden-funding-service"; + +/// Tracing target used for user-visible events. +pub const LOG_TARGET: &str = "user::miden-funding-service"; + +/// Default largest amount one request may ask for, in base units of the native asset. +pub const DEFAULT_MAX_AMOUNT: u64 = 1_000_000_000; + +/// Default timeout of a request to the node's RPC API. +pub const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default timeout of an HTTP request served by this service. +pub const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(300); + +/// How often the service reads the funding account from the node. +const STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(30); + +// CONFIGURATION +// ================================================================================================= + +/// The configuration of the funding service. +pub struct FundingServiceConfig { + rpc_url: Url, + account_file: PathBuf, + http_timeout: Duration, + rpc_timeout: Duration, + max_amount: u64, +} + +impl FundingServiceConfig { + /// Creates a configuration with default timeouts and limits. + pub fn new(rpc_url: Url, account_file: PathBuf) -> Self { + Self { + rpc_url, + account_file, + http_timeout: DEFAULT_HTTP_TIMEOUT, + rpc_timeout: DEFAULT_RPC_TIMEOUT, + max_amount: DEFAULT_MAX_AMOUNT, + } + } + + #[must_use] + pub fn with_http_timeout(mut self, timeout: Duration) -> Self { + self.http_timeout = timeout; + self + } + + #[must_use] + pub fn with_rpc_timeout(mut self, timeout: Duration) -> Self { + self.rpc_timeout = timeout; + self + } + + #[must_use] + pub fn with_max_amount(mut self, max_amount: u64) -> Self { + self.max_amount = max_amount; + self + } + + /// Connects to the node and builds the service. + pub async fn build(self) -> anyhow::Result { + let funder_key = FunderKey::load(&self.account_file) + .context("failed to load the funding account file")?; + + let node = RpcNodeClient::connect(&self.rpc_url, self.rpc_timeout) + .await + .context("failed to connect to the node RPC API")?; + + // The fee asset is constant for the chain and is only named by the protocol configuration, + let fee_asset_id = node.protocol_config().fee_asset_id(); + let fee_parameters = node + .fee_parameters(None) + .await + .context("failed to read the fee parameters from the node")?; + + // A note holds the amount as a fungible asset, so an amount the asset type cannot express + // must fail at startup instead of on every request. + FungibleAsset::new(fee_asset_id.faucet_id(), self.max_amount) + .context("--max-amount is not a valid amount of the native asset")?; + + info!( + target: LOG_TARGET, + "Funding service initialized", + account.id = funder_key.account_id(), + asset.faucet_id = fee_asset_id.faucet_id(), + genesis.commitment = node.genesis_commitment(), + fee.verification_base_fee = fee_parameters.verification_base_fee(), + funding_service.max_amount = self.max_amount + ); + + Ok(FundingService { + node, + funder_key, + fee_asset_id, + max_amount: self.max_amount, + http_timeout: self.http_timeout, + }) + } +} + +// FUNDING SERVICE +// ================================================================================================= + +/// The funding service, ready to run. +pub struct FundingService { + node: RpcNodeClient, + funder_key: FunderKey, + fee_asset_id: AssetId, + max_amount: u64, + http_timeout: Duration, +} + +impl FundingService { + /// Runs the HTTP server and the status refresher until one of them stops. + pub async fn run( + self, + listener: TcpListener, + shutdown: CancellationToken, + ) -> anyhow::Result<()> { + let status = StatusSnapshot::new(self.funder_key.account_id(), self.max_amount); + + let mut tasks = Tasks::new(); + + let server = FundingServer::new(status.clone(), self.http_timeout); + let server_shutdown = shutdown.clone(); + tasks.spawn("http-server", async move { + server + .serve(listener, server_shutdown) + .await + .context("the funding service HTTP server failed") + }); + + let refresher = StatusRefresher::new( + self.node, + self.funder_key.account_id(), + self.fee_asset_id, + status, + STATUS_REFRESH_INTERVAL, + ); + let refresher_shutdown = shutdown.clone(); + tasks.spawn("status-refresher", async move { + refresher + .run(refresher_shutdown) + .await + .context("the funding service status refresher failed") + }); + + tasks + .join_next_or_cancelled(shutdown) + .await + .context("a funding service task failed") + } +} diff --git a/bin/funding-service/src/main.rs b/bin/funding-service/src/main.rs new file mode 100644 index 0000000000..6347e2d8ab --- /dev/null +++ b/bin/funding-service/src/main.rs @@ -0,0 +1,14 @@ +use clap::Parser; +mod commands; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let command = commands::FundingServiceCommand::parse(); + + let _otel_guard = miden_node_tracing::setup_tracing(command.open_telemetry())?; + + miden_node_utils::shutdown::run_with_shutdown("miden-funding-service", |shutdown| { + command.handle(shutdown) + }) + .await +} diff --git a/bin/funding-service/src/node.rs b/bin/funding-service/src/node.rs new file mode 100644 index 0000000000..bccef3169a --- /dev/null +++ b/bin/funding-service/src/node.rs @@ -0,0 +1,244 @@ +//! Node access. The RPC handling is copied from the network monitor. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use backon::ExponentialBuilder; +use miden_node_proto::clients::{Builder, RpcClient}; +use miden_node_proto::domain::account::{AccountResponse, AccountVaultDetails}; +use miden_node_proto::domain::protocol_config::ensure_protocol_config_is_present_and_matches_header; +use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; +use miden_node_proto::generated::rpc::{ + AccountRequest as ProtoAccountRequest, + BlockHeaderByNumberRequest, +}; +use miden_node_proto::{BuildUnchecked, DecodeMessage}; +use miden_node_tracing::warn; +use miden_node_utils::retry::Retryable; +use miden_protocol::Word; +use miden_protocol::account::AccountId; +use miden_protocol::asset::AssetVault; +use miden_protocol::block::{BlockHeader, BlockNumber, FeeParameters}; +use miden_protocol::protocol_config::ProtocolConfig; +use url::Url; + +use crate::COMPONENT; + +// RPC NODE CLIENT +// ================================================================================================ + +/// Reads chain state from the node's RPC API. +#[derive(Clone)] +pub struct RpcNodeClient { + rpc_client: RpcClient, + genesis_commitment: Word, + protocol_config: ProtocolConfig, +} + +impl RpcNodeClient { + /// Connects to the node's RPC API. + pub async fn connect(rpc_url: &Url, timeout: Duration) -> Result { + let (rpc_client, genesis_commitment, protocol_config) = + create_genesis_aware_rpc_client(rpc_url, timeout).await?; + + Ok(Self { + rpc_client, + genesis_commitment, + protocol_config, + }) + } + + /// The commitment of the genesis block the node serves. It identifies the chain. + pub fn genesis_commitment(&self) -> Word { + self.genesis_commitment + } + + /// The protocol configuration the genesis block commits to. + pub fn protocol_config(&self) -> &ProtocolConfig { + &self.protocol_config + } + + /// The fee parameters of `block_num`, or at the chain tip when it is `None`. + pub async fn fee_parameters(&self, block_num: Option) -> Result { + let header = fetch_block_header(&mut self.rpc_client.clone(), block_num).await?; + + Ok(header.fee_parameters().clone()) + } + + /// The asset vault of a public account, with the block number the node observed it at. + pub async fn public_account_vault( + &self, + account_id: AccountId, + ) -> Result<(AssetVault, BlockNumber)> { + // A dummy commitment never matches the vault root, which makes the node return the vault in + // full. Code and storage are not requested. + let dummy = Word::default().into(); + let request = ProtoAccountRequest { + account_id: Some(account_id.into()), + // Without a block number the node answers at its chain tip. + block_num: None, + details: Some(AccountDetailRequest { + code_commitment: None, + asset_vault_commitment: Some(dummy), + storage_request: None, + }), + }; + + let response = self + .rpc_client + .clone() + .get_account(request) + .await + .with_context(|| format!("failed to fetch account {account_id}"))? + .into_inner(); + let response = AccountResponse::try_from(response) + .context("failed to convert the account response")?; + + let details = response + .details + .with_context(|| format!("no details returned for public account {account_id}"))?; + + let vault = match details.vault_details { + AccountVaultDetails::Assets(assets) => { + AssetVault::new(&assets).context("failed to build the vault")? + }, + AccountVaultDetails::LimitExceeded => { + anyhow::bail!("account {account_id} holds too many assets to fetch in full") + }, + }; + + Ok((vault, response.block_num)) + } +} + +// RPC HELPERS +// ================================================================================================ + +/// Backoff for the genesis-discovery handshake, so a node which is still starting does not abort +/// the service. +const GENESIS_DISCOVERY_BACKOFF_INITIAL: Duration = Duration::from_secs(1); +const GENESIS_DISCOVERY_BACKOFF_MAX: Duration = Duration::from_secs(30); +const GENESIS_DISCOVERY_MAX_RETRIES: usize = 10; + +fn genesis_discovery_backoff() -> ExponentialBuilder { + ExponentialBuilder::default() + .with_min_delay(GENESIS_DISCOVERY_BACKOFF_INITIAL) + .with_max_delay(GENESIS_DISCOVERY_BACKOFF_MAX) + .with_factor(2.0) + .with_max_times(GENESIS_DISCOVERY_MAX_RETRIES) + .with_jitter() +} + +/// Creates an RPC client configured with the correct genesis metadata in the `Accept` header so +/// that write RPCs such as `SubmitProvenTx` are accepted by the node. +async fn create_genesis_aware_rpc_client( + rpc_url: &Url, + timeout: Duration, +) -> Result<(RpcClient, Word, ProtocolConfig)> { + (|| async { + // First, create a temporary client without genesis metadata to discover the genesis block + // header and its commitment. + let mut rpc: RpcClient = Builder::new(rpc_url.clone()) + .with_tls() + .context("failed to configure TLS for the RPC client")? + .with_timeout(timeout) + .without_metadata_version() + .without_metadata_genesis() + .without_auth_header() + .with_otel_context_injection() + .connect() + .await + .context("failed to create an RPC client for genesis discovery")?; + + let (genesis_header, protocol_config) = fetch_genesis_header_and_config(&mut rpc).await?; + let genesis_commitment = genesis_header.commitment(); + + // Rebuild the client, this time including the required genesis metadata so that write RPCs + // like SubmitProvenTx are accepted by the node. + let rpc_client = Builder::new(rpc_url.clone()) + .with_tls() + .context("failed to configure TLS for the RPC client")? + .with_timeout(timeout) + .without_metadata_version() + .with_metadata_genesis(genesis_commitment) + .without_auth_header() + .with_otel_context_injection() + .connect() + .await + .context("failed to connect to the RPC server with genesis metadata")?; + + Ok((rpc_client, genesis_commitment, protocol_config)) + }) + .retry(genesis_discovery_backoff()) + .notify(|err: &anyhow::Error, sleep: Duration| { + warn!( + err, + target: COMPONENT, + "RPC genesis discovery failed; retrying after backoff", + retry.delay_ms = sleep.as_millis() as u64 + ); + }) + .await +} + +/// Fetches a block header from RPC. +async fn fetch_block_header( + rpc_client: &mut RpcClient, + block_num: Option, +) -> Result { + let request = BlockHeaderByNumberRequest { + block_num: block_num.map(|block_num| block_num.as_u32()), + include_mmr_proof: None, + include_protocol_config: None, + }; + + let response = rpc_client + .get_block_header_by_number(request) + .await + .context("failed to get the block header from RPC")?; + + let block_header = response + .into_inner() + .block_header + .context("the block header response holds no header")?; + + block_header + .decode_fields() + .context("failed to decode the block header")? + .build_unchecked() + .context("failed to build the block header") +} + +/// Fetches the genesis block header and the protocol configuration it commits to. +/// +/// The commitment of the returned configuration is checked against the header, so a configuration +/// which names an asset the chain does not use is rejected. +async fn fetch_genesis_header_and_config( + rpc_client: &mut RpcClient, +) -> Result<(BlockHeader, ProtocolConfig)> { + let response = rpc_client + .get_block_header_by_number(BlockHeaderByNumberRequest { + block_num: Some(BlockNumber::GENESIS.as_u32()), + include_mmr_proof: None, + include_protocol_config: Some(true), + }) + .await + .context("failed to get the genesis block header from RPC")? + .into_inner(); + + let block_header: BlockHeader = response + .block_header + .context("the block header response holds no header")? + .decode_fields() + .context("failed to decode the block header")? + .build_unchecked() + .context("failed to build the block header")?; + + let protocol_config = ensure_protocol_config_is_present_and_matches_header( + response.protocol_config, + &block_header, + ) + .context("the node served an invalid protocol configuration")?; + + Ok((block_header, protocol_config)) +} diff --git a/bin/funding-service/src/server.rs b/bin/funding-service/src/server.rs new file mode 100644 index 0000000000..f7acc7085a --- /dev/null +++ b/bin/funding-service/src/server.rs @@ -0,0 +1,103 @@ +//! The HTTP server. + +use std::time::Duration; + +use anyhow::Context; +use axum::Router; +use axum::http::StatusCode; +use axum::routing::get; +use miden_node_tracing::info; +use miden_node_utils::shutdown::CancellationToken; +use tokio::net::TcpListener; +use tower_http::timeout::TimeoutLayer; +use tower_http::trace::TraceLayer; + +use crate::LOG_TARGET; +use crate::status::StatusSnapshot; + +mod status; + +// FUNDING SERVICE HTTP SERVER +// ================================================================================================ + +/// Path of the status endpoint. +const STATUS_PATH: &str = "/status"; + +/// The HTTP service of the funding service. +pub struct FundingServer { + status: StatusSnapshot, + request_timeout: Duration, +} + +impl FundingServer { + pub(crate) fn new(status: StatusSnapshot, request_timeout: Duration) -> Self { + Self { status, request_timeout } + } + + /// Starts the HTTP server on the given listener. + pub async fn serve( + self, + listener: TcpListener, + shutdown: CancellationToken, + ) -> anyhow::Result<()> { + let endpoint = listener + .local_addr() + .context("failed to read the funding service listen address")?; + info!( + target: LOG_TARGET, + "Funding service HTTP API listening", + service.name = "miden-funding-service", + service.version = env!("CARGO_PKG_VERSION"), + funding_service.listen = endpoint.to_string() + ); + + axum::serve(listener, self.router()) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + .context("failed to serve the funding service HTTP API") + } + + /// Builds the router of the API. + fn router(self) -> Router { + Router::new() + .route(STATUS_PATH, get(status::status)) + .layer(TraceLayer::new_for_http()) + // The server cancels a handler which runs longer than the timeout. The client then + // receives the status code 408. + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + self.request_timeout, + )) + .with_state(self.status) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use axum::body::Body; + use axum::http::Request; + use miden_protocol::asset::FungibleAsset; + use tower::ServiceExt; + + use super::*; + + /// Builds a status snapshot for the handler tests. + pub(crate) fn test_status(max_amount: u64) -> StatusSnapshot { + StatusSnapshot::new(FungibleAsset::mock_issuer(), max_amount) + } + + fn test_router(status: StatusSnapshot) -> Router { + FundingServer::new(status, Duration::from_secs(1)).router() + } + + #[tokio::test] + async fn status_is_served_as_json_on_its_route() { + let response = test_router(test_status(500)) + .oneshot(Request::get(STATUS_PATH).body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + } +} diff --git a/bin/funding-service/src/server/status.rs b/bin/funding-service/src/server/status.rs new file mode 100644 index 0000000000..6c904c84c1 --- /dev/null +++ b/bin/funding-service/src/server/status.rs @@ -0,0 +1,72 @@ +use axum::Json; +use axum::extract::State; +use serde::{Deserialize, Serialize}; + +use crate::COMPONENT; +use crate::status::StatusSnapshot; + +// STATUS RESPONSE +// ================================================================================================ + +/// The body of a status response. +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct StatusResponse { + /// The version of the funding service. + version: String, + /// The account which sends the notes, in hexadecimal. + account_id: String, + /// The balance of the native asset in the funding account, in base units, at `chain_tip`. + balance: u64, + /// The block number which the service is synchronized to. + chain_tip: u32, + /// The largest amount which one funding request accepts, in base units. + max_amount: u64, + /// The base fee for the verification of a transaction, in base units, at `chain_tip`. + verification_base_fee: u32, +} + +// STATUS HANDLER +// ================================================================================================ + +/// Returns the status of the funding service. +/// +/// The status is served while the service is still synchronizing, so an operator can read the +/// funding account it was configured with. +#[miden_node_tracing::miden_instrument(target = COMPONENT, name = "status")] +pub(super) async fn status(State(status): State) -> Json { + Json(StatusResponse { + version: env!("CARGO_PKG_VERSION").to_string(), + account_id: status.account_id().to_string(), + balance: status.balance(), + chain_tip: status.chain_tip().as_u32(), + max_amount: status.max_amount(), + verification_base_fee: status.verification_base_fee(), + }) +} + +#[cfg(test)] +mod tests { + use miden_protocol::account::AccountId; + use miden_protocol::asset::FungibleAsset; + + use super::*; + use crate::server::tests::test_status; + + #[tokio::test] + async fn status_reports_the_configured_account_and_the_published_balance() { + let snapshot = test_status(500); + snapshot.update(1_234, 42.into(), 7); + + let Json(response) = status(State(snapshot)).await; + + assert_eq!( + AccountId::from_hex(&response.account_id).unwrap(), + FungibleAsset::mock_issuer() + ); + assert_eq!(response.balance, 1_234); + assert_eq!(response.chain_tip, 42); + assert_eq!(response.max_amount, 500); + assert_eq!(response.verification_base_fee, 7); + assert_eq!(response.version, env!("CARGO_PKG_VERSION")); + } +} diff --git a/bin/funding-service/src/status.rs b/bin/funding-service/src/status.rs new file mode 100644 index 0000000000..524d5072ae --- /dev/null +++ b/bin/funding-service/src/status.rs @@ -0,0 +1,136 @@ +//! The status the service reports, and the task which keeps it current. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::Duration; + +use anyhow::Result; +use miden_node_tracing::warn; +use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::account::AccountId; +use miden_protocol::asset::AssetId; +use miden_protocol::block::BlockNumber; + +use crate::LOG_TARGET; +use crate::node::RpcNodeClient; + +// STATUS SNAPSHOT +// ================================================================================================ + +/// The funding account's balance as of the last block the worker read. +/// +/// The worker publishes the values, and the `Status` endpoint reads them. The two numbers are read +/// separately, so a concurrent update can pair a balance with the neighbouring block number. That +/// is acceptable for a status report and avoids taking a lock on the funding path. +#[derive(Clone)] +pub struct StatusSnapshot { + account_id: AccountId, + max_amount: u64, + balance: Arc, + chain_tip: Arc, + verification_base_fee: Arc, +} + +impl StatusSnapshot { + /// Creates a snapshot for the given funding account. + pub fn new(account_id: AccountId, max_amount: u64) -> Self { + Self { + account_id, + max_amount, + balance: Arc::new(AtomicU64::new(0)), + chain_tip: Arc::new(AtomicU32::new(0)), + verification_base_fee: Arc::new(AtomicU32::new(0)), + } + } + + /// Publishes the values the worker read at `chain_tip`. + pub fn update(&self, balance: u64, chain_tip: BlockNumber, verification_base_fee: u32) { + self.balance.store(balance, Ordering::Relaxed); + self.chain_tip.store(chain_tip.as_u32(), Ordering::Relaxed); + self.verification_base_fee.store(verification_base_fee, Ordering::Relaxed); + } + + pub fn account_id(&self) -> AccountId { + self.account_id + } + + pub fn max_amount(&self) -> u64 { + self.max_amount + } + + pub fn balance(&self) -> u64 { + self.balance.load(Ordering::Relaxed) + } + + pub fn chain_tip(&self) -> BlockNumber { + self.chain_tip.load(Ordering::Relaxed).into() + } + + pub fn verification_base_fee(&self) -> u32 { + self.verification_base_fee.load(Ordering::Relaxed) + } +} + +// STATUS REFRESHER +// ================================================================================================ + +/// Reads the funding account on an interval so the reported balance stays current. +pub struct StatusRefresher { + node: RpcNodeClient, + account_id: AccountId, + fee_asset_id: AssetId, + status: StatusSnapshot, + interval: Duration, +} + +impl StatusRefresher { + pub fn new( + node: RpcNodeClient, + account_id: AccountId, + fee_asset_id: AssetId, + status: StatusSnapshot, + interval: Duration, + ) -> Self { + Self { + node, + account_id, + fee_asset_id, + status, + interval, + } + } + + /// Reads the funding account until the service shuts down. + /// + /// A failed read is not fatal: the node may be restarting, and the reported balance simply + /// stays at the value of the last successful read. + pub async fn run(self, shutdown: CancellationToken) -> Result<()> { + loop { + if let Err(err) = self.refresh().await { + warn!( + &err, + target: LOG_TARGET, + "Failed to read the funding account" + ); + } + + tokio::select! { + () = tokio::time::sleep(self.interval) => {}, + () = shutdown.cancelled() => return Ok(()), + } + } + } + + /// Reads the funding account at the chain tip and publishes its balance. + async fn refresh(&self) -> Result<()> { + let (vault, block_num) = self.node.public_account_vault(self.account_id).await?; + // The fee parameters are read at the block the vault came from, so the reported base fee + // belongs to the block the status reports. + let fee_parameters = self.node.fee_parameters(Some(block_num)).await?; + + let balance = vault.get_balance(self.fee_asset_id).map_or(0, |amount| amount.as_u64()); + self.status.update(balance, block_num, fee_parameters.verification_base_fee()); + + Ok(()) + } +} diff --git a/bin/node/src/commands/modes.rs b/bin/node/src/commands/modes.rs index 5861512df6..c3bc8b596a 100644 --- a/bin/node/src/commands/modes.rs +++ b/bin/node/src/commands/modes.rs @@ -58,7 +58,7 @@ pub struct SequencerCommand { #[arg( long = "internal.listen", env = "MIDEN_NODE_SEQUENCER_INTERNAL_LISTEN", - value_name = "LISTEN" + value_name = "IP:PORT" )] pub internal: Option, diff --git a/bin/node/src/commands/rpc.rs b/bin/node/src/commands/rpc.rs index 763eda4dc4..b6b64d33ea 100644 --- a/bin/node/src/commands/rpc.rs +++ b/bin/node/src/commands/rpc.rs @@ -12,7 +12,7 @@ use url::Url; #[derive(clap::Args, Clone, Debug)] pub struct RpcOptions { /// Socket address at which to serve the public RPC API. - #[arg(long = "rpc.listen", env = "MIDEN_NODE_RPC_LISTEN", value_name = "LISTEN")] + #[arg(long = "rpc.listen", env = "MIDEN_NODE_RPC_LISTEN", value_name = "IP:PORT")] pub listen: SocketAddr, /// Optional metadata header value for internal network-transaction RPC authentication. diff --git a/bin/ntx-builder/src/commands/mod.rs b/bin/ntx-builder/src/commands/mod.rs index ce1bc8e84f..bbc8d096c5 100644 --- a/bin/ntx-builder/src/commands/mod.rs +++ b/bin/ntx-builder/src/commands/mod.rs @@ -44,7 +44,7 @@ pub enum NtxBuilderCommand { /// Starts the network transaction builder component. Start { /// Socket address at which to serve the ntx-builder's gRPC API. - #[arg(long = "listen", env = ENV_LISTEN, value_name = "LISTEN")] + #[arg(long = "listen", env = ENV_LISTEN, value_name = "IP:PORT")] listen: SocketAddr, /// Maximum duration allocated to a gRPC request served by the ntx-builder. diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index eee85e4358..de2ee5fa96 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -192,7 +192,7 @@ pub enum ValidatorCommand { /// Starts the validator component. Start { /// Socket address at which to serve the gRPC API. - #[arg(long = "listen", env = ENV_LISTEN, value_name = "LISTEN")] + #[arg(long = "listen", env = ENV_LISTEN, value_name = "IP:PORT")] listen: std::net::SocketAddr, /// IP address and port for the private administration API (for example, 127.0.0.1:50102). diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index 5eadf18839..1496cd79bb 100644 --- a/crates/store/src/genesis/config/errors.rs +++ b/crates/store/src/genesis/config/errors.rs @@ -6,6 +6,7 @@ use miden_protocol::errors::{ AccountError, AssetError, AssetVaultError, + AuthSchemeError, ProtocolConfigError, TokenSymbolError, }; @@ -74,4 +75,10 @@ pub enum GenesisConfigError { InvalidSecretKey(#[from] DeserializationError), #[error("provided signer config is not supported")] UnsupportedSignerConfig, + #[error("account file name '{name}' is used more than once")] + DuplicateAccountFileName { name: String }, + #[error("account name '{name}' is not a plain file name")] + InvalidAccountFileName { name: String }, + #[error("failed to generate a key for the configured authentication scheme")] + AuthScheme(#[from] AuthSchemeError), } diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index 9eb8dbf7f9..62d063f60e 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -215,7 +215,7 @@ impl GenesisConfig { secrets.push(( FAUCET_OPERATOR_FILE_NAME.to_string(), operator.id(), - Some(operator_secret), + Some(AuthSecretKey::Falcon512Poseidon2(operator_secret)), )); Some(operator) }, @@ -236,7 +236,7 @@ impl GenesisConfig { secrets.push(( format!("faucet_{symbol}.mac", symbol = symbol.to_string().to_lowercase()), faucet_account.id(), - Some(secret_key), + Some(AuthSecretKey::Falcon512Poseidon2(secret_key)), )); // Do _not_ collect the account, only after we know all wallet assets we know the // remaining supply in the faucets. @@ -246,10 +246,9 @@ impl GenesisConfig { let protocol_config = ProtocolConfig::current(AssetId::new_fungible(native_faucet_account_id))?; - let zero_padding_width = usize::ilog10(std::cmp::max(10, wallet_configs.len())) as usize; - // Setup all wallet accounts, which reference the faucet's for their provided assets. - for (index, WalletConfig { account_type, assets }) in wallet_configs.into_iter().enumerate() + for (index, WalletConfig { name, account_type, auth_scheme, assets }) in + wallet_configs.into_iter().enumerate() { debug!( target: LOG_TARGET, @@ -258,10 +257,20 @@ impl GenesisConfig { account.assets.count = assets.len() ); + // The name is joined onto the accounts directory, so it must be a plain file name. + if Path::new(&name).file_name() != Some(name.as_ref()) { + return Err(GenesisConfigError::InvalidAccountFileName { name }); + } + + let auth_scheme = auth_scheme + .as_deref() + .map(AuthScheme::from_str) + .transpose()? + .unwrap_or(AuthScheme::Falcon512Poseidon2); + let mut rng = ChaCha20Rng::from_seed(rand::random()); - let secret_key = RpoSecretKey::with_rng(&mut rng); - let auth = - Approver::new(secret_key.public_key().into(), AuthScheme::Falcon512Poseidon2); + let secret_key = AuthSecretKey::with_scheme_and_rng(auth_scheme, &mut rng)?; + let auth = Approver::from(&secret_key.public_key()); let init_seed: [u8; 32] = rng.random(); let mut wallet_account = create_basic_wallet(init_seed, auth, account_type.into())?; @@ -285,11 +294,7 @@ impl GenesisConfig { debug_assert_eq!(wallet_account.nonce(), ONE); - secrets.push(( - format!("wallet_{index:0zero_padding_width$}.mac"), - wallet_account.id(), - Some(secret_key), - )); + secrets.push((format!("{name}.mac"), wallet_account.id(), Some(secret_key))); wallet_accounts.push(wallet_account); } @@ -358,6 +363,15 @@ impl GenesisConfig { // Append file-loaded accounts as-is all_accounts.extend(file_loaded_accounts); + // Each generated account is written to its own file, so a repeated name would make one + // account overwrite another. This covers every generated name: the wallets, the configured + // faucets, and the native faucet with its operator. + let mut file_names: Vec<&str> = secrets.iter().map(|(name, ..)| name.as_str()).collect(); + file_names.sort_unstable(); + if let Some(pair) = file_names.windows(2).find(|pair| pair[0] == pair[1]) { + return Err(GenesisConfigError::DuplicateAccountFileName { name: pair[0].to_string() }); + } + Ok(( GenesisState { fee_parameters, @@ -592,15 +606,21 @@ impl FungibleFaucetConfig { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct WalletConfig { + /// Stem of the account file written for this wallet. + name: String, #[serde(default)] account_type: AccountTypeConfig, + /// Signature scheme of the account's authentication component, named as [`AuthScheme`] writes + /// it. Defaults to `Falcon512Poseidon2`. + #[serde(default)] + auth_scheme: Option, assets: Vec, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct AssetEntry { symbol: TokenSymbolStr, - /// The amount of full token units the given asset is populated with + /// The amount of the given asset, in base units. amount: u64, } @@ -642,7 +662,7 @@ pub struct AccountFileWithName { #[derive(Debug, Clone)] pub struct AccountSecrets { // name, account, private key of the account, if it has one - pub secrets: Vec<(String, AccountId, Option)>, + pub secrets: Vec<(String, AccountId, Option)>, } impl AccountSecrets { @@ -663,8 +683,7 @@ impl AccountSecrets { let account = account_lut .get(&account_id) .ok_or(GenesisConfigError::MissingGenesisAccount { account_id })?; - let auth_secret_keys = - secret_key.map(AuthSecretKey::Falcon512Poseidon2).into_iter().collect(); + let auth_secret_keys = secret_key.into_iter().collect(); let account_file = AccountFile::new(account.clone(), auth_secret_keys); Ok(AccountFileWithName { name, account_file }) }) diff --git a/crates/store/src/genesis/config/samples/01-simple.toml b/crates/store/src/genesis/config/samples/01-simple.toml index 14930bdda9..53b8d269d6 100644 --- a/crates/store/src/genesis/config/samples/01-simple.toml +++ b/crates/store/src/genesis/config/samples/01-simple.toml @@ -21,11 +21,19 @@ symbol = "WHAT" [[wallet]] account_type = "private" assets = [{ amount = 999_000, symbol = "MIDEN" }] +name = "wallet_treasury" [[wallet]] account_type = "private" assets = [{ amount = 777, symbol = "MIDEN" }] +name = "wallet_user" [[wallet]] account_type = "private" assets = [{ amount = 1, symbol = "WHAT" }] +name = "wallet_what" + +[[wallet]] +account_type = "public" +assets = [{ amount = 1_000_000_000, symbol = "MIDEN" }] +name = "funding_service" diff --git a/crates/store/src/genesis/config/tests.rs b/crates/store/src/genesis/config/tests.rs index 8b988d3b69..e46cda681f 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -69,11 +69,12 @@ fn parsing_yields_expected_default_values() -> TestResult { assert_eq!(val.as_u64(), 777); }); - // check total issuance of the faucet + // check total issuance of the faucet, which covers the operator prefund, both MIDEN wallets and + // the named wallet let faucet = FungibleFaucet::try_from(native_faucet.storage()).unwrap(); assert_eq!( faucet.token_supply().as_u64(), - DEFAULT_FAUCET_OPERATOR_BALANCE + 999_777, + DEFAULT_FAUCET_OPERATOR_BALANCE + 999_777 + 1_000_000_000, "Issuance mismatch" ); @@ -406,3 +407,60 @@ path = "does_not_exist.mac" "Expected AccountFileRead error, got: {err:?}" ); } + +/// The wallet name sets the stem of the account file, so a configuration must be able to point a +/// service at a fixed path. +#[test] +fn wallet_name_sets_the_account_file_name() -> TestResult { + let toml = r#" +timestamp = 1717344256 + +[fee_parameters] +verification_base_fee = 0 + +[[wallet]] +name = "funding_service" +assets = [] +"#; + + let gcfg = GenesisConfig::read_toml(toml, Path::new("."))?; + let (state, secrets) = gcfg.into_state(dev_validator_config())?; + + let names: Vec = secrets + .as_account_files(&state) + .map(|item| item.map(|file| file.name)) + .collect::>()?; + + assert!( + names.contains(&"funding_service.mac".to_string()), + "the named wallet should be written to funding_service.mac, got {names:?}" + ); + + Ok(()) +} + +/// A repeated name would make one account file overwrite another. +#[test] +fn duplicate_wallet_names_are_rejected() { + let toml = r#" +timestamp = 1717344256 + +[fee_parameters] +verification_base_fee = 0 + +[[wallet]] +name = "funding_service" +assets = [] + +[[wallet]] +name = "funding_service" +assets = [] +"#; + + let gcfg = GenesisConfig::read_toml(toml, Path::new(".")).unwrap(); + let err = gcfg.into_state(dev_validator_config()).unwrap_err(); + + assert_matches!(err, GenesisConfigError::DuplicateAccountFileName { name } => { + assert_eq!(name, "funding_service.mac"); + }); +} diff --git a/crates/tracing/src/attribute.rs b/crates/tracing/src/attribute.rs index e406b4d507..6b7f5d0229 100644 --- a/crates/tracing/src/attribute.rs +++ b/crates/tracing/src/attribute.rs @@ -11,6 +11,7 @@ use tracing::Value; const BOOLEAN_FIELD_NAMES: &[&str] = &[ "account.updated", + "funding_service.remote_prover", "note.erased", "note.id_resolved", "panic", @@ -24,6 +25,7 @@ const NUMBER_FIELD_NAMES: &[&str] = &[ "account.index", "asset.amount", "asset.balance", + "asset.reserve", "batch.expiration_height", "batch.expires_at", "batch.reference_block.number", @@ -51,6 +53,10 @@ const NUMBER_FIELD_NAMES: &[&str] = &[ "db.sqlite.wal.size", "dice_roll", "failure_rate", + "fee.verification_base_fee", + "funding_service.max_amount", + "funding_service.max_notes_per_tx", + "funding_service.tx_expiration_delta", "inputs_size", "mempool.accounts", "mempool.batches.proposed", @@ -59,6 +65,8 @@ const NUMBER_FIELD_NAMES: &[&str] = &[ "mempool.output_notes", "mempool.transactions.unbatched", "mempool.transactions.uncommitted", + "note.committed", + "note.count", "note.tag", "ntx_builder.max_cycles", "ntx_builder.tx_expiration_delta", @@ -108,9 +116,12 @@ const STRING_FIELD_NAMES: &[&str] = &[ "block.interval", "dependency.endpoint", "dependency.name", + "funding_service.listen", + "funding_service.poll_interval", "genesis.source", "genesis.source.kind", "grpc.timeout", + "http.timeout", "internal.listen", "mempool.removal.reason", "network_monitor.listen", @@ -131,6 +142,7 @@ const STRING_FIELD_NAMES: &[&str] = &[ "rpc.timeout", "sequencer.endpoint", "service.name", + "service.readiness.reason", "service.version", "shutdown.signal", "sync.block_source.endpoint", @@ -297,7 +309,8 @@ impl RecordAttribute for Option { } impl RecordAttribute for Path { - const FIELD_NAMES: &'static [&'static str] = &["data.directory", "genesis.file", "path"]; + const FIELD_NAMES: &'static [&'static str] = + &["account.file", "data.directory", "genesis.file", "path"]; fn record_attribute(&self) -> impl Value + '_ { tracing::field::display(self.display()) @@ -353,6 +366,7 @@ impl_display_attribute!( AccountId, &[ "account.id", + "asset.faucet_id", "counter.account.id.new", "counter.account.id.old", "note.sender", diff --git a/xtask/src/changelog.rs b/xtask/src/changelog.rs index 53d398ad37..e6d1409925 100644 --- a/xtask/src/changelog.rs +++ b/xtask/src/changelog.rs @@ -44,6 +44,7 @@ enum Scope { Node, NoteTransport, NetworkMonitor, + FundingService, NtxBuilder, Prover, Validator, diff --git a/xtask/src/changelog/render.rs b/xtask/src/changelog/render.rs index ea77fd5b8b..bd2b25a7e2 100644 --- a/xtask/src/changelog/render.rs +++ b/xtask/src/changelog/render.rs @@ -176,7 +176,7 @@ fn append_callout_entry(notes: &mut String, entry: &ReleaseNoteEntry) { } impl Scope { - const fn sort_order() -> [Self; 10] { + const fn sort_order() -> [Self; 11] { [ Self::General, Self::Rpc, @@ -186,6 +186,7 @@ impl Scope { Self::Validator, Self::NoteTransport, Self::NetworkMonitor, + Self::FundingService, Self::Docs, Self::Internal, ] @@ -203,6 +204,7 @@ impl std::fmt::Display for Scope { Self::Validator => "Validator", Self::NoteTransport => "Note Transport", Self::NetworkMonitor => "Network Monitor", + Self::FundingService => "Funding Service", Self::Docs => "Docs", Self::Internal => "Internal", }; @@ -251,7 +253,7 @@ impl std::fmt::Display for Impact { } } -const SCOPE_ORDER: [Scope; 10] = Scope::sort_order(); +const SCOPE_ORDER: [Scope; 11] = Scope::sort_order(); #[cfg(test)] mod tests { diff --git a/xtask/src/changelog/tests.rs b/xtask/src/changelog/tests.rs index 3f1a43b747..2f36d6bed8 100644 --- a/xtask/src/changelog/tests.rs +++ b/xtask/src/changelog/tests.rs @@ -121,6 +121,19 @@ description = "Added the note transport service." verify_pr_body(&body).unwrap(); } +#[test] +fn accepts_funding_service_scope() { + let body = valid_body( + r#"[[entry]] +scope = "funding-service" +impact = "added" +description = "Added the funding service." +"#, + ); + + verify_pr_body(&body).unwrap(); +} + #[test] fn accepts_no_changelog_marker() { let body = valid_body(