From 1c35a876bd751ff61753d90d50d29cd07e680e87 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 8 Sep 2026 18:32:28 -0300 Subject: [PATCH 1/9] feat(funding-service): add server and status endpoint --- .github/pull_request_template.md | 2 +- .github/workflows/docker.yml | 5 + Cargo.lock | 26 ++ Cargo.toml | 1 + Dockerfile | 2 + Makefile | 18 + bin/funding-service/Cargo.toml | 43 +++ bin/funding-service/README.md | 22 ++ bin/funding-service/src/account.rs | 119 +++++++ bin/funding-service/src/commands/mod.rs | 133 ++++++++ bin/funding-service/src/lib.rs | 207 ++++++++++++ bin/funding-service/src/main.rs | 14 + bin/funding-service/src/node.rs | 312 ++++++++++++++++++ bin/funding-service/src/server.rs | 106 ++++++ bin/funding-service/src/server/status.rs | 88 +++++ bin/funding-service/src/status.rs | 131 ++++++++ crates/proto/build.rs | 2 + crates/proto/src/clients/mod.rs | 23 ++ crates/store/src/genesis/config/errors.rs | 2 + crates/store/src/genesis/config/mod.rs | 32 +- .../src/genesis/config/samples/01-simple.toml | 5 + crates/store/src/genesis/config/tests.rs | 5 +- crates/tracing/src/attribute.rs | 15 +- proto/proto/README.md | 1 + proto/proto/funding_service.proto | 40 +++ xtask/src/changelog.rs | 1 + xtask/src/changelog/render.rs | 6 +- xtask/src/changelog/tests.rs | 13 + 28 files changed, 1361 insertions(+), 13 deletions(-) create mode 100644 bin/funding-service/Cargo.toml create mode 100644 bin/funding-service/README.md create mode 100644 bin/funding-service/src/account.rs create mode 100644 bin/funding-service/src/commands/mod.rs create mode 100644 bin/funding-service/src/lib.rs create mode 100644 bin/funding-service/src/main.rs create mode 100644 bin/funding-service/src/node.rs create mode 100644 bin/funding-service/src/server.rs create mode 100644 bin/funding-service/src/server/status.rs create mode 100644 bin/funding-service/src/status.rs create mode 100644 proto/proto/funding_service.proto 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..680e471204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3961,6 +3961,32 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miden-funding-service" +version = "0.17.0-rc.1" +dependencies = [ + "anyhow", + "backon", + "clap", + "humantime", + "miden-node-proto", + "miden-node-proto-build", + "miden-node-tracing", + "miden-node-utils", + "miden-protocol", + "miden-standards", + "rand 0.10.2", + "rand_chacha 0.10.0", + "tempfile", + "tokio", + "tokio-stream", + "tonic", + "tonic-health", + "tonic-reflection", + "tower-http", + "url", +] + [[package]] name = "miden-large-account-benchmark" version = "0.17.0-rc.1" 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..0f52f04964 --- /dev/null +++ b/bin/funding-service/Cargo.toml @@ -0,0 +1,43 @@ +[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 } +backon = { workspace = true } +clap = { features = ["env", "string"], workspace = true } +humantime = { workspace = true } +miden-node-proto = { workspace = true } +miden-node-proto-build = { workspace = true } +miden-node-tracing = { workspace = true } +miden-node-utils = { workspace = true } +miden-protocol = { features = ["std"], workspace = true } +tokio = { features = ["macros", "net", "rt-multi-thread", "sync", "time"], workspace = true } +tokio-stream = { features = ["net"], workspace = true } +tonic = { workspace = true } +tonic-health = { workspace = true } +tonic-reflection = { workspace = true } +tower-http = { 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 } diff --git a/bin/funding-service/README.md b/bin/funding-service/README.md new file mode 100644 index 0000000000..871a1edb7b --- /dev/null +++ b/bin/funding-service/README.md @@ -0,0 +1,22 @@ +# 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 also needs a trusted genesis block file, from `--genesis`. The genesis block names the chain's fee asset, +which the node's RPC API does not serve. The service refuses to start when the genesis block commits to a different +chain than the node. + +The `Status` endpoint 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 gRPC 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..abfa37196c --- /dev/null +++ b/bin/funding-service/src/account.rs @@ -0,0 +1,119 @@ +//! Loading of the funding account. + +use std::path::Path; + +use anyhow::{Context, Result}; +use miden_protocol::account::auth::AuthSecretKey; +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 and its signing key 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()))?; + + account_file + .auth_secret_keys + .iter() + .find(|key| matches!(key, AuthSecretKey::Falcon512Poseidon2(_))) + .with_context(|| { + format!( + "the account file at {} holds no Falcon512Poseidon2 secret key", + 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; + 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..e17d8812e1 --- /dev/null +++ b/bin/funding-service/src/commands/mod.rs @@ -0,0 +1,133 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use miden_funding_service::{ + DEFAULT_GRPC_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::genesis::read_genesis_block; +use miden_node_utils::shutdown::CancellationToken; +use tokio::net::TcpListener; +use url::Url; + +const ENV_LISTEN: &str = "MIDEN_FUNDING_LISTEN"; +const ENV_GRPC_TIMEOUT: &str = "MIDEN_FUNDING_GRPC_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_GENESIS: &str = "MIDEN_FUNDING_GENESIS"; +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 gRPC API. + #[arg(long = "listen", env = ENV_LISTEN, value_name = "LISTEN")] + listen: SocketAddr, + + /// Maximum duration allocated to a gRPC request served by the funding service. + #[arg( + long = "grpc.timeout", + env = ENV_GRPC_TIMEOUT, + default_value = duration_to_human_readable_string(DEFAULT_GRPC_TIMEOUT), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + grpc_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, + + /// Path to a trusted genesis block file, which names the chain's fee asset. + #[arg(long = "genesis", env = ENV_GENESIS, value_name = "FILE")] + genesis_block_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, + grpc_timeout, + rpc_url, + rpc_timeout, + account_file, + genesis_block_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(), + grpc.timeout = humantime::Duration::from(grpc_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 genesis = + read_genesis_block(&genesis_block_file).context("failed to read the genesis block")?; + + let listener = TcpListener::bind(listen) + .await + .context("failed to bind to the funding service's gRPC socket")?; + + FundingServiceConfig::new(rpc_url, account_file, genesis) + .with_grpc_timeout(grpc_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..a72778c84f --- /dev/null +++ b/bin/funding-service/src/lib.rs @@ -0,0 +1,207 @@ +//! 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_proto::server::funding_service_api; +use miden_node_tracing::info; +use miden_node_utils::genesis::GenesisBlock; +use miden_node_utils::shutdown::CancellationToken; +use miden_node_utils::tasks::Tasks; +use miden_protocol::account::AccountId; +use miden_protocol::asset::FungibleAsset; +use tokio::net::TcpListener; +use url::Url; + +use crate::account::FunderKey; +use crate::node::RpcNodeClient; +use crate::server::FundingRpcServer; +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 a gRPC request served by this service. +pub const DEFAULT_GRPC_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, + genesis: GenesisBlock, + grpc_timeout: Duration, + rpc_timeout: Duration, + max_amount: u64, +} + +impl FundingServiceConfig { + /// Creates a configuration with default timeouts and limits. + /// + /// The genesis block names the fee asset. The node's RPC API does not serve the protocol + /// configuration, so the operator must supply the genesis block from a trusted source. + pub fn new(rpc_url: Url, account_file: PathBuf, genesis: GenesisBlock) -> Self { + Self { + rpc_url, + account_file, + genesis, + grpc_timeout: DEFAULT_GRPC_TIMEOUT, + rpc_timeout: DEFAULT_RPC_TIMEOUT, + max_amount: DEFAULT_MAX_AMOUNT, + } + } + + #[must_use] + pub fn with_grpc_timeout(mut self, timeout: Duration) -> Self { + self.grpc_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")?; + + // A genesis block from another chain would name the wrong fee asset, so the service must + // not start when the node serves a different chain. + let node_genesis = node.genesis_header().commitment(); + let configured_genesis = self.genesis.inner().header().commitment(); + anyhow::ensure!( + configured_genesis == node_genesis, + "the genesis block does not match the node: the genesis block commits to \ + {configured_genesis}, the node to {node_genesis}", + ); + + // The fee asset is constant for the chain and is only named by the protocol configuration, + // which the node's RPC API does not serve. The remaining fee parameters are in every block + // header, and the status refresher reads them at the block it reports. + let fee_faucet_id = self.genesis.protocol_config().fee_asset_id().faucet_id(); + let fee_parameters = node.genesis_header().fee_parameters().clone(); + + // 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_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_faucet_id, + fee.verification_base_fee = fee_parameters.verification_base_fee(), + funding_service.max_amount = self.max_amount + ); + + Ok(FundingService { + node, + funder_key, + fee_faucet_id, + max_amount: self.max_amount, + grpc_timeout: self.grpc_timeout, + }) + } +} + +// FUNDING SERVICE +// ================================================================================================= + +/// The funding service, ready to run. +pub struct FundingService { + node: RpcNodeClient, + funder_key: FunderKey, + fee_faucet_id: AccountId, + max_amount: u64, + grpc_timeout: Duration, +} + +impl FundingService { + /// Runs the gRPC server and the status refresher until one of them stops. + pub async fn run( + self, + listener: TcpListener, + shutdown: CancellationToken, + ) -> anyhow::Result<()> { + let (health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_service_status( + funding_service_api::service_name(), + tonic_health::ServingStatus::Serving, + ) + .await; + + let status = StatusSnapshot::new(self.funder_key.account_id(), self.max_amount); + + let mut tasks = Tasks::new(); + + let server = FundingRpcServer::new(status.clone(), self.grpc_timeout); + let server_shutdown = shutdown.clone(); + tasks.spawn("grpc-server", async move { + server + .serve(listener, health_service, server_shutdown) + .await + .context("the funding service gRPC server failed") + }); + + let refresher = StatusRefresher::new( + self.node, + self.funder_key.account_id(), + self.fee_faucet_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..1486ec1237 --- /dev/null +++ b/bin/funding-service/src/node.rs @@ -0,0 +1,312 @@ +//! Node access. The RPC handling is copied from the network monitor. + +use std::collections::HashMap; +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, StorageMapEntries}; +use miden_node_proto::generated::rpc::{ + AccountRequest as ProtoAccountRequest, + BlockHeaderByNumberRequest, + FinalityLevel, + SyncChainMmrRequest, +}; +use miden_node_tracing::warn; +use miden_node_utils::retry::Retryable; +use miden_protocol::Word; +use miden_protocol::account::{ + Account, + AccountId, + AccountStorage, + StorageMap, + StorageSlot, + StorageSlotType, +}; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta, MmrPeaks, PartialMmr}; +use miden_protocol::transaction::PartialBlockchain; +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_header: BlockHeader, +} + +impl RpcNodeClient { + /// Connects to the node's RPC API. + pub async fn connect(rpc_url: &Url, timeout: Duration) -> Result { + let (mut rpc_client, _genesis_commitment) = + create_genesis_aware_rpc_client(rpc_url, timeout).await?; + let genesis_header = fetch_genesis_block_header(&mut rpc_client).await?; + + Ok(Self { rpc_client, genesis_header }) + } + + /// The genesis block header, which commits to the chain's fee parameters. + pub fn genesis_header(&self) -> &BlockHeader { + &self.genesis_header + } + + /// The committed chain tip header with a partial blockchain which proves it. + pub async fn tip_chain_state(&self) -> Result<(BlockHeader, PartialBlockchain)> { + fetch_tip_chain_state(&mut self.rpc_client.clone(), self.genesis_header.commitment()).await + } + + /// A public account in full with its account-tree witness at `block_num`. + pub async fn public_account( + &self, + account_id: AccountId, + block_num: BlockNumber, + ) -> Result<(Account, AccountWitness)> { + fetch_public_account(&mut self.rpc_client.clone(), account_id, block_num).await + } +} + +// 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)> { + (|| 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 = fetch_genesis_block_header(&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)) + }) + .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 the genesis block header from RPC. +async fn fetch_genesis_block_header(rpc_client: &mut RpcClient) -> Result { + let request = BlockHeaderByNumberRequest { + block_num: Some(BlockNumber::GENESIS.as_u32()), + include_mmr_proof: None, + }; + + let response = rpc_client + .get_block_header_by_number(request) + .await + .context("failed to get the genesis block header from RPC")?; + + let block_header = response + .into_inner() + .block_header + .context("the genesis block header response holds no header")?; + + block_header.try_into().context("failed to convert the genesis block header") +} + +/// Fetches the chain tip header together with a [`PartialBlockchain`] whose peaks hash to that +/// header's chain commitment, making the pair usable as a transaction reference block. +async fn fetch_tip_chain_state( + rpc_client: &mut RpcClient, + genesis_commitment: Word, +) -> Result<(BlockHeader, PartialBlockchain)> { + let response = rpc_client + .sync_chain_mmr(SyncChainMmrRequest { + // The MMR is seeded with the genesis block below, so the delta starts at block 1. + current_client_block_height: BlockNumber::GENESIS.as_u32(), + finality_level: FinalityLevel::Committed.into(), + }) + .await + .context("failed to sync the chain MMR")? + .into_inner(); + + let tip_header: BlockHeader = response + .block_header + .context("the sync_chain_mmr response did not include a block header")? + .try_into() + .context("failed to convert the sync target block header")?; + + let delta: MmrDelta = response + .mmr_delta + .context("the sync_chain_mmr response did not include an MMR delta")? + .try_into() + .context("failed to convert the MMR delta")?; + + let mut mmr = PartialMmr::from_peaks( + MmrPeaks::new(Forest::new(0).context("an empty forest should be valid")?, Vec::new()) + .context("empty MMR peaks should be valid")?, + ); + + if tip_header.block_num() != BlockNumber::GENESIS { + mmr.add(genesis_commitment, false) + .context("failed to seed the MMR with the genesis block")?; + mmr.apply(delta).context("failed to apply the MMR delta")?; + } + + anyhow::ensure!( + mmr.peaks().hash_peaks() == tip_header.chain_commitment(), + "the synced MMR peaks do not match the chain commitment of block {}", + tip_header.block_num() + ); + + let blockchain = PartialBlockchain::new(mmr, Vec::new()) + .context("failed to build the partial blockchain")?; + + Ok((tip_header, blockchain)) +} + +/// Fetches a public account in full, with code, vault and storage maps, plus its account-tree +/// witness at the given block. +async fn fetch_public_account( + rpc_client: &mut RpcClient, + account_id: AccountId, + block_num: BlockNumber, +) -> Result<(Account, AccountWitness)> { + use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; + use miden_node_proto::generated::rpc::account_request::account_detail_request::StorageRequest; + + let id_bytes: [u8; 15] = account_id.into(); + // Dummy commitments force the server to include code and vault data in the response. + let dummy: miden_node_proto::generated::primitives::Word = Word::default().into(); + let request = ProtoAccountRequest { + account_id: Some(miden_node_proto::generated::account::AccountId { id: id_bytes.to_vec() }), + block_num: Some(block_num.into()), + details: Some(AccountDetailRequest { + code_commitment: Some(dummy.clone()), + asset_vault_commitment: Some(dummy), + storage_request: Some(StorageRequest::AllStorageMaps(true)), + }), + }; + + let response = rpc_client + .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 witness = response.witness; + anyhow::ensure!( + witness.id() == account_id, + "the account tree returned a witness for {} when {account_id} was requested", + witness.id(), + ); + + let details = response + .details + .with_context(|| format!("no details returned for public account {account_id}"))?; + + let code = details.account_code.context("the server did not return the account code")?; + + let vault = match details.vault_details { + AccountVaultDetails::Assets(assets) => { + miden_protocol::asset::AssetVault::new(&assets).context("failed to build the vault")? + }, + AccountVaultDetails::LimitExceeded => { + anyhow::bail!("account {account_id} holds too many assets to fetch in full") + }, + }; + + // Value slots come from the header, map slots from the map details. + let mut map_entries = HashMap::new(); + for map_detail in details.storage_details.map_details { + let StorageMapEntries::AllEntries(entries) = map_detail.entries else { + anyhow::bail!("storage map {} was not returned in full", map_detail.slot_name); + }; + map_entries.insert(map_detail.slot_name, entries); + } + + let mut slots = Vec::new(); + for slot in details.storage_details.header.slots() { + match slot.slot_type() { + StorageSlotType::Value => { + slots.push(StorageSlot::with_value(slot.name().clone(), slot.value())); + }, + StorageSlotType::Map => { + let entries = map_entries.remove(slot.name()).with_context(|| { + format!("no map entries returned for storage slot {}", slot.name()) + })?; + let map = + StorageMap::with_entries(entries).context("failed to build the storage map")?; + anyhow::ensure!( + map.root() == slot.value(), + "the storage map root for slot {} does not match the storage header", + slot.name() + ); + slots.push(StorageSlot::with_map(slot.name().clone(), map)); + }, + } + } + let storage = AccountStorage::new(slots).context("failed to build the account storage")?; + + let account = + Account::new(account_id, vault, storage, code, details.account_header.nonce(), None) + .context("failed to build the account")?; + + // The witness and the details come from one response, so a mismatch means a bad reconstruction. + anyhow::ensure!( + account.to_commitment() == witness.state_commitment(), + "the reconstructed account {account_id} does not match its witness at block {block_num}", + ); + + Ok((account, witness)) +} diff --git a/bin/funding-service/src/server.rs b/bin/funding-service/src/server.rs new file mode 100644 index 0000000000..31f5e2e79d --- /dev/null +++ b/bin/funding-service/src/server.rs @@ -0,0 +1,106 @@ +//! The gRPC server. + +use std::time::Duration; + +use anyhow::Context; +use miden_node_proto::server::funding_service_api; +use miden_node_proto_build::funding_service_api_descriptor; +use miden_node_tracing::grpc::grpc_trace_fn; +use miden_node_tracing::info; +use miden_node_tracing::panic::{CatchPanicLayer, catch_panic_layer_fn}; +use miden_node_utils::shutdown::CancellationToken; +use tokio::net::TcpListener; +use tokio_stream::wrappers::TcpListenerStream; +use tonic_health::pb::health_server::{Health, HealthServer}; +use tonic_reflection::server; +use tower_http::classify::{GrpcCode, GrpcErrorsAsFailures, SharedClassifier}; +use tower_http::trace::TraceLayer; + +use crate::LOG_TARGET; +use crate::status::StatusSnapshot; + +mod status; + +// FUNDING SERVICE RPC SERVER +// ================================================================================================ + +/// The gRPC service of the funding service. +/// +/// The handlers do no chain work: `Status` reads the values the status refresher publishes. +pub struct FundingRpcServer { + status: StatusSnapshot, + request_timeout: Duration, +} + +impl FundingRpcServer { + pub(crate) fn new(status: StatusSnapshot, request_timeout: Duration) -> Self { + Self { status, request_timeout } + } + + /// Starts the gRPC server on the given listener. + /// + /// The health service is registered as a liveness signal for the API. + pub async fn serve( + self, + listener: TcpListener, + health_service: HealthServer, + shutdown: CancellationToken, + ) -> anyhow::Result<()> { + let request_timeout = self.request_timeout; + let api_service = funding_service_api::service(self); + let reflection_service = server::Builder::configure() + .register_file_descriptor_set(funding_service_api_descriptor()) + .register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET) + .build_v1() + .context("failed to build the reflection service")?; + + let endpoint = listener + .local_addr() + .context("failed to read the funding service listen address")?; + info!( + target: LOG_TARGET, + "Funding service gRPC API listening", + service.name = "miden-funding-service", + service.version = env!("CARGO_PKG_VERSION"), + funding_service.listen = endpoint.to_string() + ); + + tonic::transport::Server::builder() + .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) + // A rejected request is the client's problem, not a server failure, so those codes do + // not mark the span as failed. + .layer( + TraceLayer::new(SharedClassifier::new( + GrpcErrorsAsFailures::new() + .with_success(GrpcCode::InvalidArgument) + .with_success(GrpcCode::FailedPrecondition) + .with_success(GrpcCode::ResourceExhausted), + )) + .make_span_with(grpc_trace_fn), + ) + .timeout(request_timeout) + .add_service(api_service) + .add_service(health_service) + .add_service(reflection_service) + .serve_with_incoming_shutdown( + TcpListenerStream::new(listener), + shutdown.cancelled_owned(), + ) + .await + .context("failed to serve the funding service gRPC API") + } +} + +#[cfg(test)] +pub(crate) mod tests { + use miden_protocol::asset::FungibleAsset; + + use super::*; + + /// Builds a server for the handler tests. + pub(crate) fn test_server(max_amount: u64) -> FundingRpcServer { + let status = StatusSnapshot::new(FungibleAsset::mock_issuer(), max_amount); + + FundingRpcServer::new(status, Duration::from_secs(1)) + } +} diff --git a/bin/funding-service/src/server/status.rs b/bin/funding-service/src/server/status.rs new file mode 100644 index 0000000000..f605a92bc5 --- /dev/null +++ b/bin/funding-service/src/server/status.rs @@ -0,0 +1,88 @@ +use miden_node_proto::generated as proto; +use miden_node_proto::server::funding_service_api; + +use super::FundingRpcServer; +use crate::COMPONENT; +use crate::status::StatusSnapshot; + +#[tonic::async_trait] +impl funding_service_api::Status for FundingRpcServer { + type Input = (); + type Output = StatusSnapshot; + + fn decode(_request: ()) -> tonic::Result { + Ok(()) + } + + #[miden_node_tracing::miden_instrument(target = COMPONENT, name = "status")] + async fn handle( + &self, + (): Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + // The status is served while the service is still synchronizing, so an operator can read + // the funding account it was configured with. + Ok(self.status.clone()) + } + + fn encode(status: Self::Output) -> tonic::Result { + Ok(proto::funding_service::FundingServiceStatus { + version: env!("CARGO_PKG_VERSION").to_string(), + account_id: Some(status.account_id().into()), + balance: status.balance(), + chain_tip: status.chain_tip().as_u32(), + max_amount: status.max_amount(), + }) + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::account::AccountId; + use miden_protocol::asset::FungibleAsset; + + use super::*; + use crate::server::tests::test_server; + + #[tokio::test] + async fn status_reports_the_configured_account_and_the_published_balance() { + let server = test_server(500); + server.status.update(1_234, 42.into()); + + let status = funding_service_api::Status::handle( + &server, + (), + &tonic::metadata::MetadataMap::new(), + &tonic::codegen::http::Extensions::new(), + ) + .await + .unwrap(); + let encoded = ::encode(status).unwrap(); + + assert_eq!( + AccountId::try_from(encoded.account_id.unwrap()).unwrap(), + FungibleAsset::mock_issuer() + ); + assert_eq!(encoded.balance, 1_234); + assert_eq!(encoded.chain_tip, 42); + assert_eq!(encoded.max_amount, 500); + assert_eq!(encoded.version, env!("CARGO_PKG_VERSION")); + } + + /// The status must be available before the service is ready, so an operator can see which + /// account it is waiting on. + #[tokio::test] + async fn status_is_served_while_the_service_is_not_ready() { + let server = test_server(500); + + funding_service_api::Status::handle( + &server, + (), + &tonic::metadata::MetadataMap::new(), + &tonic::codegen::http::Extensions::new(), + ) + .await + .expect("status must not depend on readiness"); + } +} diff --git a/bin/funding-service/src/status.rs b/bin/funding-service/src/status.rs new file mode 100644 index 0000000000..f8cf72b52f --- /dev/null +++ b/bin/funding-service/src/status.rs @@ -0,0 +1,131 @@ +//! 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, +} + +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)), + } + } + + /// Publishes the balance the worker read at `chain_tip`. + pub fn update(&self, balance: u64, chain_tip: BlockNumber) { + self.balance.store(balance, Ordering::Relaxed); + self.chain_tip.store(chain_tip.as_u32(), 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() + } +} + +// STATUS REFRESHER +// ================================================================================================ + +/// Reads the funding account on an interval so the reported balance stays current. +pub struct StatusRefresher { + node: RpcNodeClient, + account_id: AccountId, + fee_faucet_id: AccountId, + status: StatusSnapshot, + interval: Duration, +} + +impl StatusRefresher { + pub fn new( + node: RpcNodeClient, + account_id: AccountId, + fee_faucet_id: AccountId, + status: StatusSnapshot, + interval: Duration, + ) -> Self { + Self { + node, + account_id, + fee_faucet_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 (header, _blockchain) = self.node.tip_chain_state().await?; + let block_num = header.block_num(); + let (funder, _witness) = self.node.public_account(self.account_id, block_num).await?; + + let balance = funder + .vault() + .get_balance(AssetId::new_fungible(self.fee_faucet_id)) + .map_or(0, |amount| amount.as_u64()); + self.status.update(balance, block_num); + + Ok(()) + } +} diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 285de03ea3..991efe9149 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -5,6 +5,7 @@ use std::process::Command; use codegen::{Function, Impl, Module, Trait, Type}; use fs_err as fs; use miden_node_proto_build::{ + funding_service_api_descriptor, ntx_builder_api_descriptor, remote_prover_api_descriptor, rpc_api_descriptor, @@ -31,6 +32,7 @@ fn main() -> miette::Result<()> { validator_api_descriptor(), ntx_builder_api_descriptor(), sequencer_api_descriptor(), + funding_service_api_descriptor(), ]; for file_descriptors in &descriptor_sets { diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index 2578c0b5bb..9f15fd52c7 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -185,6 +185,7 @@ type GeneratedProverClient = generated::remote_prover::api_client::ApiClient; type GeneratedNtxBuilderClient = generated::ntx_builder::api_client::ApiClient; type GeneratedSequencerClient = generated::sequencer::api_client::ApiClient; +type GeneratedFundingClient = generated::funding_service::api_client::ApiClient; type GeneratedProvenTransaction = generated::submission::ProvenTransactionSubmission; type SealedTransactionInputs = generated::submission::SealedTransactionInputs; @@ -203,6 +204,8 @@ pub struct ValidatorClient(GeneratedValidatorClient); pub struct NtxBuilderClient(GeneratedNtxBuilderClient); #[derive(Debug, Clone)] pub struct SequencerClient(GeneratedSequencerClient); +#[derive(Debug, Clone)] +pub struct FundingClient(GeneratedFundingClient); impl DerefMut for RpcClient { fn deref_mut(&mut self) -> &mut Self::Target { @@ -288,6 +291,20 @@ impl Deref for SequencerClient { } } +impl DerefMut for FundingClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Deref for FundingClient { + type Target = GeneratedFundingClient; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + // GRPC CLIENT BUILDER TRAIT // ================================================================================================ @@ -332,6 +349,12 @@ impl GrpcClient for SequencerClient { } } +impl GrpcClient for FundingClient { + fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self { + Self(GeneratedFundingClient::new(InterceptedService::new(channel, interceptor))) + } +} + // STRICT TYPE-SAFE BUILDER (NO DEFAULTS) // ================================================================================================ diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index 5eadf18839..cc220de347 100644 --- a/crates/store/src/genesis/config/errors.rs +++ b/crates/store/src/genesis/config/errors.rs @@ -74,4 +74,6 @@ 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 }, } diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index 9eb8dbf7f9..d9f70d7d4b 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -1,6 +1,7 @@ //! Describe a subset of the genesis manifest in easily human readable format use std::cmp::Ordering; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -249,7 +250,8 @@ impl GenesisConfig { 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, assets }) in + wallet_configs.into_iter().enumerate() { debug!( target: LOG_TARGET, @@ -285,11 +287,12 @@ impl GenesisConfig { debug_assert_eq!(wallet_account.nonce(), ONE); - secrets.push(( - format!("wallet_{index:0zero_padding_width$}.mac"), - wallet_account.id(), - Some(secret_key), - )); + let file_name = match name { + Some(name) => format!("{name}.mac"), + None => format!("wallet_{index:0zero_padding_width$}.mac"), + }; + + secrets.push((file_name, wallet_account.id(), Some(secret_key))); wallet_accounts.push(wallet_account); } @@ -358,6 +361,18 @@ impl GenesisConfig { // Append file-loaded accounts as-is all_accounts.extend(file_loaded_accounts); + // A duplicate name would make one account file overwrite another. The write itself refuses + // to replace an existing file, so without this check the failure appears only after part of + // the genesis output is already written. + let mut seen_file_names = BTreeSet::new(); + for (file_name, ..) in &secrets { + if !seen_file_names.insert(file_name.clone()) { + return Err(GenesisConfigError::DuplicateAccountFileName { + name: file_name.clone(), + }); + } + } + Ok(( GenesisState { fee_parameters, @@ -592,6 +607,9 @@ 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. + #[serde(default)] + name: Option, #[serde(default)] account_type: AccountTypeConfig, assets: Vec, @@ -600,7 +618,7 @@ pub struct WalletConfig { #[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, } diff --git a/crates/store/src/genesis/config/samples/01-simple.toml b/crates/store/src/genesis/config/samples/01-simple.toml index 14930bdda9..235b67ef65 100644 --- a/crates/store/src/genesis/config/samples/01-simple.toml +++ b/crates/store/src/genesis/config/samples/01-simple.toml @@ -29,3 +29,8 @@ assets = [{ amount = 777, symbol = "MIDEN" }] [[wallet]] account_type = "private" assets = [{ amount = 1, symbol = "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..60456bd253 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" ); diff --git a/crates/tracing/src/attribute.rs b/crates/tracing/src/attribute.rs index e406b4d507..f0b6236ad6 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,6 +116,8 @@ 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", @@ -131,6 +141,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 +308,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 +365,7 @@ impl_display_attribute!( AccountId, &[ "account.id", + "asset.faucet_id", "counter.account.id.new", "counter.account.id.old", "note.sender", diff --git a/proto/proto/README.md b/proto/proto/README.md index 7936030076..695c275089 100644 --- a/proto/proto/README.md +++ b/proto/proto/README.md @@ -15,6 +15,7 @@ The organization of the files is as follows: ```text rpc.proto remote_prover.proto +funding_service.proto types/ ├── submission.proto └── block_proving.proto diff --git a/proto/proto/funding_service.proto b/proto/proto/funding_service.proto new file mode 100644 index 0000000000..e50831f54e --- /dev/null +++ b/proto/proto/funding_service.proto @@ -0,0 +1,40 @@ +// Specification of the funding service gRPC API. +syntax = "proto3"; +package funding_service; + +import "google/protobuf/empty.proto"; + +import "account.proto"; + +// FUNDING SERVICE API +// ================================================================================================ + +// Sends the chain's native asset to an account. +// +// The service owns one wallet account which holds the native asset. The service does not +// authenticate requests; an operator must restrict access to this API at the infrastructure level. +service Api { + // Returns the status of the funding service. + rpc Status(google.protobuf.Empty) returns (FundingServiceStatus) {} +} + +// STATUS +// ================================================================================================ + +// Response message which holds the status of the funding service. +message FundingServiceStatus { + // The version of the funding service. + string version = 1; + + // The account which sends the notes. + account.AccountId account_id = 2; + + // The balance of the native asset in the funding account, in base units, at `chain_tip`. + uint64 balance = 3; + + // The block number which the service is synchronized to. + fixed32 chain_tip = 4; + + // The largest amount which one funding request accepts, in base units. + uint64 max_amount = 5; +} 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( From ab2c8b6b32d4b49550ef2e1b108a99c75cda3c1c Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 10 Sep 2026 17:06:03 -0300 Subject: [PATCH 2/9] review: enforce name and check for duplications in genesis --- crates/store/src/genesis/config/errors.rs | 2 + crates/store/src/genesis/config/mod.rs | 35 +++++------ .../src/genesis/config/samples/01-simple.toml | 5 +- crates/store/src/genesis/config/tests.rs | 59 +++++++++++++++++++ 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index cc220de347..011a0023df 100644 --- a/crates/store/src/genesis/config/errors.rs +++ b/crates/store/src/genesis/config/errors.rs @@ -76,4 +76,6 @@ pub enum GenesisConfigError { 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 }, } diff --git a/crates/store/src/genesis/config/mod.rs b/crates/store/src/genesis/config/mod.rs index d9f70d7d4b..8ec54d99ad 100644 --- a/crates/store/src/genesis/config/mod.rs +++ b/crates/store/src/genesis/config/mod.rs @@ -1,7 +1,6 @@ //! Describe a subset of the genesis manifest in easily human readable format use std::cmp::Ordering; -use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -247,8 +246,6 @@ 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 { name, account_type, assets }) in wallet_configs.into_iter().enumerate() @@ -260,6 +257,11 @@ 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 mut rng = ChaCha20Rng::from_seed(rand::random()); let secret_key = RpoSecretKey::with_rng(&mut rng); let auth = @@ -287,12 +289,7 @@ impl GenesisConfig { debug_assert_eq!(wallet_account.nonce(), ONE); - let file_name = match name { - Some(name) => format!("{name}.mac"), - None => format!("wallet_{index:0zero_padding_width$}.mac"), - }; - - secrets.push((file_name, wallet_account.id(), Some(secret_key))); + secrets.push((format!("{name}.mac"), wallet_account.id(), Some(secret_key))); wallet_accounts.push(wallet_account); } @@ -361,16 +358,13 @@ impl GenesisConfig { // Append file-loaded accounts as-is all_accounts.extend(file_loaded_accounts); - // A duplicate name would make one account file overwrite another. The write itself refuses - // to replace an existing file, so without this check the failure appears only after part of - // the genesis output is already written. - let mut seen_file_names = BTreeSet::new(); - for (file_name, ..) in &secrets { - if !seen_file_names.insert(file_name.clone()) { - return Err(GenesisConfigError::DuplicateAccountFileName { - name: file_name.clone(), - }); - } + // 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(( @@ -608,8 +602,7 @@ impl FungibleFaucetConfig { #[serde(deny_unknown_fields)] pub struct WalletConfig { /// Stem of the account file written for this wallet. - #[serde(default)] - name: Option, + name: String, #[serde(default)] account_type: AccountTypeConfig, assets: Vec, diff --git a/crates/store/src/genesis/config/samples/01-simple.toml b/crates/store/src/genesis/config/samples/01-simple.toml index 235b67ef65..582894887e 100644 --- a/crates/store/src/genesis/config/samples/01-simple.toml +++ b/crates/store/src/genesis/config/samples/01-simple.toml @@ -19,18 +19,21 @@ max_supply = 100_000_000 symbol = "WHAT" [[wallet]] +name = "wallet_treasury" account_type = "private" assets = [{ amount = 999_000, symbol = "MIDEN" }] [[wallet]] +name = "wallet_user" account_type = "private" assets = [{ amount = 777, symbol = "MIDEN" }] [[wallet]] +name = "wallet_what" account_type = "private" assets = [{ amount = 1, symbol = "WHAT" }] [[wallet]] +name = "funding_service" 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 60456bd253..75e9a62fb4 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -407,3 +407,62 @@ 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#" +version = 1 +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_keys())?; + + 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#" +version = 1 +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_keys()).unwrap_err(); + + assert_matches!(err, GenesisConfigError::DuplicateAccountFileName { name } => { + assert_eq!(name, "funding_service.mac"); + }); +} From 7564eb45f358ea818b2ee12dcba78c9473c10200 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 10 Sep 2026 18:04:48 -0300 Subject: [PATCH 3/9] review: use only partial storage --- bin/funding-service/src/node.rs | 222 ++++-------------- bin/funding-service/src/status.rs | 7 +- .../src/genesis/config/samples/01-simple.toml | 8 +- 3 files changed, 54 insertions(+), 183 deletions(-) diff --git a/bin/funding-service/src/node.rs b/bin/funding-service/src/node.rs index 1486ec1237..a2f5690e48 100644 --- a/bin/funding-service/src/node.rs +++ b/bin/funding-service/src/node.rs @@ -1,33 +1,22 @@ //! Node access. The RPC handling is copied from the network monitor. -use std::collections::HashMap; 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, StorageMapEntries}; +use miden_node_proto::domain::account::{AccountResponse, AccountVaultDetails}; +use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; use miden_node_proto::generated::rpc::{ AccountRequest as ProtoAccountRequest, BlockHeaderByNumberRequest, - FinalityLevel, - SyncChainMmrRequest, }; use miden_node_tracing::warn; use miden_node_utils::retry::Retryable; use miden_protocol::Word; -use miden_protocol::account::{ - Account, - AccountId, - AccountStorage, - StorageMap, - StorageSlot, - StorageSlotType, -}; -use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::account::AccountId; +use miden_protocol::asset::AssetVault; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta, MmrPeaks, PartialMmr}; -use miden_protocol::transaction::PartialBlockchain; use url::Url; use crate::COMPONENT; @@ -57,18 +46,52 @@ impl RpcNodeClient { &self.genesis_header } - /// The committed chain tip header with a partial blockchain which proves it. - pub async fn tip_chain_state(&self) -> Result<(BlockHeader, PartialBlockchain)> { - fetch_tip_chain_state(&mut self.rpc_client.clone(), self.genesis_header.commitment()).await - } - - /// A public account in full with its account-tree witness at `block_num`. - pub async fn public_account( + /// 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, - block_num: BlockNumber, - ) -> Result<(Account, AccountWitness)> { - fetch_public_account(&mut self.rpc_client.clone(), account_id, block_num).await + ) -> Result<(AssetVault, BlockNumber)> { + let id_bytes: [u8; 15] = account_id.into(); + // 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(miden_node_proto::generated::account::AccountId { + id: id_bytes.to_vec(), + }), + // 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)) } } @@ -161,152 +184,3 @@ async fn fetch_genesis_block_header(rpc_client: &mut RpcClient) -> Result Result<(BlockHeader, PartialBlockchain)> { - let response = rpc_client - .sync_chain_mmr(SyncChainMmrRequest { - // The MMR is seeded with the genesis block below, so the delta starts at block 1. - current_client_block_height: BlockNumber::GENESIS.as_u32(), - finality_level: FinalityLevel::Committed.into(), - }) - .await - .context("failed to sync the chain MMR")? - .into_inner(); - - let tip_header: BlockHeader = response - .block_header - .context("the sync_chain_mmr response did not include a block header")? - .try_into() - .context("failed to convert the sync target block header")?; - - let delta: MmrDelta = response - .mmr_delta - .context("the sync_chain_mmr response did not include an MMR delta")? - .try_into() - .context("failed to convert the MMR delta")?; - - let mut mmr = PartialMmr::from_peaks( - MmrPeaks::new(Forest::new(0).context("an empty forest should be valid")?, Vec::new()) - .context("empty MMR peaks should be valid")?, - ); - - if tip_header.block_num() != BlockNumber::GENESIS { - mmr.add(genesis_commitment, false) - .context("failed to seed the MMR with the genesis block")?; - mmr.apply(delta).context("failed to apply the MMR delta")?; - } - - anyhow::ensure!( - mmr.peaks().hash_peaks() == tip_header.chain_commitment(), - "the synced MMR peaks do not match the chain commitment of block {}", - tip_header.block_num() - ); - - let blockchain = PartialBlockchain::new(mmr, Vec::new()) - .context("failed to build the partial blockchain")?; - - Ok((tip_header, blockchain)) -} - -/// Fetches a public account in full, with code, vault and storage maps, plus its account-tree -/// witness at the given block. -async fn fetch_public_account( - rpc_client: &mut RpcClient, - account_id: AccountId, - block_num: BlockNumber, -) -> Result<(Account, AccountWitness)> { - use miden_node_proto::generated::rpc::account_request::AccountDetailRequest; - use miden_node_proto::generated::rpc::account_request::account_detail_request::StorageRequest; - - let id_bytes: [u8; 15] = account_id.into(); - // Dummy commitments force the server to include code and vault data in the response. - let dummy: miden_node_proto::generated::primitives::Word = Word::default().into(); - let request = ProtoAccountRequest { - account_id: Some(miden_node_proto::generated::account::AccountId { id: id_bytes.to_vec() }), - block_num: Some(block_num.into()), - details: Some(AccountDetailRequest { - code_commitment: Some(dummy.clone()), - asset_vault_commitment: Some(dummy), - storage_request: Some(StorageRequest::AllStorageMaps(true)), - }), - }; - - let response = rpc_client - .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 witness = response.witness; - anyhow::ensure!( - witness.id() == account_id, - "the account tree returned a witness for {} when {account_id} was requested", - witness.id(), - ); - - let details = response - .details - .with_context(|| format!("no details returned for public account {account_id}"))?; - - let code = details.account_code.context("the server did not return the account code")?; - - let vault = match details.vault_details { - AccountVaultDetails::Assets(assets) => { - miden_protocol::asset::AssetVault::new(&assets).context("failed to build the vault")? - }, - AccountVaultDetails::LimitExceeded => { - anyhow::bail!("account {account_id} holds too many assets to fetch in full") - }, - }; - - // Value slots come from the header, map slots from the map details. - let mut map_entries = HashMap::new(); - for map_detail in details.storage_details.map_details { - let StorageMapEntries::AllEntries(entries) = map_detail.entries else { - anyhow::bail!("storage map {} was not returned in full", map_detail.slot_name); - }; - map_entries.insert(map_detail.slot_name, entries); - } - - let mut slots = Vec::new(); - for slot in details.storage_details.header.slots() { - match slot.slot_type() { - StorageSlotType::Value => { - slots.push(StorageSlot::with_value(slot.name().clone(), slot.value())); - }, - StorageSlotType::Map => { - let entries = map_entries.remove(slot.name()).with_context(|| { - format!("no map entries returned for storage slot {}", slot.name()) - })?; - let map = - StorageMap::with_entries(entries).context("failed to build the storage map")?; - anyhow::ensure!( - map.root() == slot.value(), - "the storage map root for slot {} does not match the storage header", - slot.name() - ); - slots.push(StorageSlot::with_map(slot.name().clone(), map)); - }, - } - } - let storage = AccountStorage::new(slots).context("failed to build the account storage")?; - - let account = - Account::new(account_id, vault, storage, code, details.account_header.nonce(), None) - .context("failed to build the account")?; - - // The witness and the details come from one response, so a mismatch means a bad reconstruction. - anyhow::ensure!( - account.to_commitment() == witness.state_commitment(), - "the reconstructed account {account_id} does not match its witness at block {block_num}", - ); - - Ok((account, witness)) -} diff --git a/bin/funding-service/src/status.rs b/bin/funding-service/src/status.rs index f8cf72b52f..37b7130727 100644 --- a/bin/funding-service/src/status.rs +++ b/bin/funding-service/src/status.rs @@ -116,12 +116,9 @@ impl StatusRefresher { /// Reads the funding account at the chain tip and publishes its balance. async fn refresh(&self) -> Result<()> { - let (header, _blockchain) = self.node.tip_chain_state().await?; - let block_num = header.block_num(); - let (funder, _witness) = self.node.public_account(self.account_id, block_num).await?; + let (vault, block_num) = self.node.public_account_vault(self.account_id).await?; - let balance = funder - .vault() + let balance = vault .get_balance(AssetId::new_fungible(self.fee_faucet_id)) .map_or(0, |amount| amount.as_u64()); self.status.update(balance, block_num); diff --git a/crates/store/src/genesis/config/samples/01-simple.toml b/crates/store/src/genesis/config/samples/01-simple.toml index 582894887e..53b8d269d6 100644 --- a/crates/store/src/genesis/config/samples/01-simple.toml +++ b/crates/store/src/genesis/config/samples/01-simple.toml @@ -19,21 +19,21 @@ max_supply = 100_000_000 symbol = "WHAT" [[wallet]] -name = "wallet_treasury" account_type = "private" assets = [{ amount = 999_000, symbol = "MIDEN" }] +name = "wallet_treasury" [[wallet]] -name = "wallet_user" account_type = "private" assets = [{ amount = 777, symbol = "MIDEN" }] +name = "wallet_user" [[wallet]] -name = "wallet_what" account_type = "private" assets = [{ amount = 1, symbol = "WHAT" }] +name = "wallet_what" [[wallet]] -name = "funding_service" account_type = "public" assets = [{ amount = 1_000_000_000, symbol = "MIDEN" }] +name = "funding_service" From 570d68f29be7b029b11323c6b5f2add95c405c50 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 10 Sep 2026 18:40:56 -0300 Subject: [PATCH 4/9] review: use ECDSA for key --- bin/funding-service/src/account.rs | 16 ++------------ crates/store/src/genesis/config/errors.rs | 3 +++ crates/store/src/genesis/config/mod.rs | 26 +++++++++++++++-------- crates/store/src/genesis/config/tests.rs | 6 ++---- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/bin/funding-service/src/account.rs b/bin/funding-service/src/account.rs index abfa37196c..2fe767e083 100644 --- a/bin/funding-service/src/account.rs +++ b/bin/funding-service/src/account.rs @@ -3,7 +3,6 @@ use std::path::Path; use anyhow::{Context, Result}; -use miden_protocol::account::auth::AuthSecretKey; use miden_protocol::account::{AccountFile, AccountId, AccountType}; // FUNDER KEY @@ -16,22 +15,11 @@ pub struct FunderKey { } impl FunderKey { - /// Reads the funding account and its signing key from an account file. + /// 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()))?; - account_file - .auth_secret_keys - .iter() - .find(|key| matches!(key, AuthSecretKey::Falcon512Poseidon2(_))) - .with_context(|| { - format!( - "the account file at {} holds no Falcon512Poseidon2 secret key", - path.display() - ) - })?; - let account = account_file.account; anyhow::ensure!( account.id().account_type() == AccountType::Public, @@ -51,7 +39,7 @@ impl FunderKey { #[cfg(test)] mod tests { use miden_protocol::ONE; - use miden_protocol::account::auth::AuthScheme; + 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; diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index 011a0023df..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, }; @@ -78,4 +79,6 @@ pub enum GenesisConfigError { 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 8ec54d99ad..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. @@ -247,7 +247,7 @@ impl GenesisConfig { ProtocolConfig::current(AssetId::new_fungible(native_faucet_account_id))?; // Setup all wallet accounts, which reference the faucet's for their provided assets. - for (index, WalletConfig { name, account_type, assets }) in + for (index, WalletConfig { name, account_type, auth_scheme, assets }) in wallet_configs.into_iter().enumerate() { debug!( @@ -262,10 +262,15 @@ impl GenesisConfig { 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())?; @@ -605,6 +610,10 @@ pub struct WalletConfig { 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, } @@ -653,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 { @@ -674,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/tests.rs b/crates/store/src/genesis/config/tests.rs index 75e9a62fb4..e46cda681f 100644 --- a/crates/store/src/genesis/config/tests.rs +++ b/crates/store/src/genesis/config/tests.rs @@ -413,7 +413,6 @@ path = "does_not_exist.mac" #[test] fn wallet_name_sets_the_account_file_name() -> TestResult { let toml = r#" -version = 1 timestamp = 1717344256 [fee_parameters] @@ -425,7 +424,7 @@ assets = [] "#; let gcfg = GenesisConfig::read_toml(toml, Path::new("."))?; - let (state, secrets) = gcfg.into_state(dev_validator_keys())?; + let (state, secrets) = gcfg.into_state(dev_validator_config())?; let names: Vec = secrets .as_account_files(&state) @@ -444,7 +443,6 @@ assets = [] #[test] fn duplicate_wallet_names_are_rejected() { let toml = r#" -version = 1 timestamp = 1717344256 [fee_parameters] @@ -460,7 +458,7 @@ assets = [] "#; let gcfg = GenesisConfig::read_toml(toml, Path::new(".")).unwrap(); - let err = gcfg.into_state(dev_validator_keys()).unwrap_err(); + let err = gcfg.into_state(dev_validator_config()).unwrap_err(); assert_matches!(err, GenesisConfigError::DuplicateAccountFileName { name } => { assert_eq!(name, "funding_service.mac"); From c25b87860e8571ceb1d34c6bda77cb7878f3a87c Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 10 Sep 2026 18:50:05 -0300 Subject: [PATCH 5/9] review: refresh fee parameters --- bin/funding-service/src/lib.rs | 7 +++-- bin/funding-service/src/node.rs | 39 +++++++++++++++--------- bin/funding-service/src/server/status.rs | 4 ++- bin/funding-service/src/status.rs | 16 ++++++++-- proto/proto/funding_service.proto | 3 ++ 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/bin/funding-service/src/lib.rs b/bin/funding-service/src/lib.rs index a72778c84f..19eddeac1a 100644 --- a/bin/funding-service/src/lib.rs +++ b/bin/funding-service/src/lib.rs @@ -106,7 +106,7 @@ impl FundingServiceConfig { // A genesis block from another chain would name the wrong fee asset, so the service must // not start when the node serves a different chain. - let node_genesis = node.genesis_header().commitment(); + let node_genesis = node.genesis_commitment(); let configured_genesis = self.genesis.inner().header().commitment(); anyhow::ensure!( configured_genesis == node_genesis, @@ -118,7 +118,10 @@ impl FundingServiceConfig { // which the node's RPC API does not serve. The remaining fee parameters are in every block // header, and the status refresher reads them at the block it reports. let fee_faucet_id = self.genesis.protocol_config().fee_asset_id().faucet_id(); - let fee_parameters = node.genesis_header().fee_parameters().clone(); + 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. diff --git a/bin/funding-service/src/node.rs b/bin/funding-service/src/node.rs index a2f5690e48..74fbeae412 100644 --- a/bin/funding-service/src/node.rs +++ b/bin/funding-service/src/node.rs @@ -16,7 +16,7 @@ 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}; +use miden_protocol::block::{BlockHeader, BlockNumber, FeeParameters}; use url::Url; use crate::COMPONENT; @@ -28,22 +28,28 @@ use crate::COMPONENT; #[derive(Clone)] pub struct RpcNodeClient { rpc_client: RpcClient, - genesis_header: BlockHeader, + genesis_commitment: Word, } impl RpcNodeClient { /// Connects to the node's RPC API. pub async fn connect(rpc_url: &Url, timeout: Duration) -> Result { - let (mut rpc_client, _genesis_commitment) = + let (rpc_client, genesis_commitment) = create_genesis_aware_rpc_client(rpc_url, timeout).await?; - let genesis_header = fetch_genesis_block_header(&mut rpc_client).await?; - Ok(Self { rpc_client, genesis_header }) + Ok(Self { rpc_client, genesis_commitment }) } - /// The genesis block header, which commits to the chain's fee parameters. - pub fn genesis_header(&self) -> &BlockHeader { - &self.genesis_header + /// The commitment of the genesis block the node serves. It identifies the chain. + pub fn genesis_commitment(&self) -> Word { + self.genesis_commitment + } + + /// 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. @@ -134,7 +140,7 @@ async fn create_genesis_aware_rpc_client( .await .context("failed to create an RPC client for genesis discovery")?; - let genesis_header = fetch_genesis_block_header(&mut rpc).await?; + let genesis_header = fetch_block_header(&mut rpc, Some(BlockNumber::GENESIS)).await?; let genesis_commitment = genesis_header.commitment(); // Rebuild the client, this time including the required genesis metadata so that write RPCs @@ -165,22 +171,25 @@ async fn create_genesis_aware_rpc_client( .await } -/// Fetches the genesis block header from RPC. -async fn fetch_genesis_block_header(rpc_client: &mut RpcClient) -> Result { +/// Fetches a block header from RPC. +async fn fetch_block_header( + rpc_client: &mut RpcClient, + block_num: Option, +) -> Result { let request = BlockHeaderByNumberRequest { - block_num: Some(BlockNumber::GENESIS.as_u32()), + block_num: block_num.map(|block_num| block_num.as_u32()), include_mmr_proof: None, }; let response = rpc_client .get_block_header_by_number(request) .await - .context("failed to get the genesis block header from RPC")?; + .context("failed to get the block header from RPC")?; let block_header = response .into_inner() .block_header - .context("the genesis block header response holds no header")?; + .context("the block header response holds no header")?; - block_header.try_into().context("failed to convert the genesis block header") + block_header.try_into().context("failed to convert the block header") } diff --git a/bin/funding-service/src/server/status.rs b/bin/funding-service/src/server/status.rs index f605a92bc5..e2fc058e96 100644 --- a/bin/funding-service/src/server/status.rs +++ b/bin/funding-service/src/server/status.rs @@ -33,6 +33,7 @@ impl funding_service_api::Status for FundingRpcServer { balance: status.balance(), chain_tip: status.chain_tip().as_u32(), max_amount: status.max_amount(), + verification_base_fee: status.verification_base_fee(), }) } } @@ -48,7 +49,7 @@ mod tests { #[tokio::test] async fn status_reports_the_configured_account_and_the_published_balance() { let server = test_server(500); - server.status.update(1_234, 42.into()); + server.status.update(1_234, 42.into(), 7); let status = funding_service_api::Status::handle( &server, @@ -67,6 +68,7 @@ mod tests { assert_eq!(encoded.balance, 1_234); assert_eq!(encoded.chain_tip, 42); assert_eq!(encoded.max_amount, 500); + assert_eq!(encoded.verification_base_fee, 7); assert_eq!(encoded.version, env!("CARGO_PKG_VERSION")); } diff --git a/bin/funding-service/src/status.rs b/bin/funding-service/src/status.rs index 37b7130727..586b42c092 100644 --- a/bin/funding-service/src/status.rs +++ b/bin/funding-service/src/status.rs @@ -28,6 +28,7 @@ pub struct StatusSnapshot { max_amount: u64, balance: Arc, chain_tip: Arc, + verification_base_fee: Arc, } impl StatusSnapshot { @@ -38,13 +39,15 @@ impl StatusSnapshot { 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 balance the worker read at `chain_tip`. - pub fn update(&self, balance: u64, chain_tip: BlockNumber) { + /// 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 { @@ -62,6 +65,10 @@ impl StatusSnapshot { 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 @@ -117,11 +124,14 @@ impl StatusRefresher { /// 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(AssetId::new_fungible(self.fee_faucet_id)) .map_or(0, |amount| amount.as_u64()); - self.status.update(balance, block_num); + self.status.update(balance, block_num, fee_parameters.verification_base_fee()); Ok(()) } diff --git a/proto/proto/funding_service.proto b/proto/proto/funding_service.proto index e50831f54e..176455502e 100644 --- a/proto/proto/funding_service.proto +++ b/proto/proto/funding_service.proto @@ -37,4 +37,7 @@ message FundingServiceStatus { // The largest amount which one funding request accepts, in base units. uint64 max_amount = 5; + + // The base fee for the verification of a transaction, in base units, at `chain_tip`. + fixed32 verification_base_fee = 6; } From 81b5faa6d723888ceee4c4dd813f934038cddb99 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 11 Sep 2026 10:22:54 -0300 Subject: [PATCH 6/9] review: rename LISTEN to IP:PORT --- bin/funding-service/src/commands/mod.rs | 2 +- bin/node/src/commands/modes.rs | 2 +- bin/node/src/commands/rpc.rs | 2 +- bin/ntx-builder/src/commands/mod.rs | 2 +- bin/validator/src/commands/mod.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/funding-service/src/commands/mod.rs b/bin/funding-service/src/commands/mod.rs index e17d8812e1..5a76a7e910 100644 --- a/bin/funding-service/src/commands/mod.rs +++ b/bin/funding-service/src/commands/mod.rs @@ -32,7 +32,7 @@ pub enum FundingServiceCommand { /// Starts the funding service. Start { /// Socket address at which to serve the funding service'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 funding service. 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). From 6067bbb5cd33f158f685e8c462358a346b5cdb93 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 11 Sep 2026 10:50:32 -0300 Subject: [PATCH 7/9] review: move to HTTP --- Cargo.lock | 9 +- bin/funding-service/Cargo.toml | 30 +++--- bin/funding-service/README.md | 6 +- bin/funding-service/src/commands/mod.rs | 24 ++--- bin/funding-service/src/lib.rs | 37 +++----- bin/funding-service/src/server.rs | 105 +++++++++++---------- bin/funding-service/src/server/status.rs | 112 ++++++++++------------- crates/proto/build.rs | 2 - crates/proto/src/clients/mod.rs | 23 ----- crates/tracing/src/attribute.rs | 1 + proto/proto/README.md | 1 - proto/proto/funding_service.proto | 43 --------- 12 files changed, 146 insertions(+), 247 deletions(-) delete mode 100644 proto/proto/funding_service.proto diff --git a/Cargo.lock b/Cargo.lock index 680e471204..152845de7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3966,23 +3966,21 @@ name = "miden-funding-service" version = "0.17.0-rc.1" dependencies = [ "anyhow", + "axum", "backon", "clap", "humantime", "miden-node-proto", - "miden-node-proto-build", "miden-node-tracing", "miden-node-utils", "miden-protocol", "miden-standards", "rand 0.10.2", "rand_chacha 0.10.0", + "serde", "tempfile", "tokio", - "tokio-stream", - "tonic", - "tonic-health", - "tonic-reflection", + "tower", "tower-http", "url", ] @@ -7678,6 +7676,7 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", diff --git a/bin/funding-service/Cargo.toml b/bin/funding-service/Cargo.toml index 0f52f04964..fdd3e6265e 100644 --- a/bin/funding-service/Cargo.toml +++ b/bin/funding-service/Cargo.toml @@ -18,22 +18,19 @@ workspace = true doctest = false [dependencies] -anyhow = { workspace = true } -backon = { workspace = true } -clap = { features = ["env", "string"], workspace = true } -humantime = { workspace = true } -miden-node-proto = { workspace = true } -miden-node-proto-build = { workspace = true } -miden-node-tracing = { workspace = true } -miden-node-utils = { workspace = true } -miden-protocol = { features = ["std"], workspace = true } -tokio = { features = ["macros", "net", "rt-multi-thread", "sync", "time"], workspace = true } -tokio-stream = { features = ["net"], workspace = true } -tonic = { workspace = true } -tonic-health = { workspace = true } -tonic-reflection = { workspace = true } -tower-http = { workspace = true } -url = { workspace = true } +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 } @@ -41,3 +38,4 @@ 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 index 871a1edb7b..c340b02718 100644 --- a/bin/funding-service/README.md +++ b/bin/funding-service/README.md @@ -11,10 +11,10 @@ The service also needs a trusted genesis block file, from `--genesis`. The genes which the node's RPC API does not serve. The service refuses to start when the genesis block commits to a different chain than the node. -The `Status` endpoint 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 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 gRPC API at the infrastructure +The service does not authenticate requests. An operator must restrict access to its HTTP API at the infrastructure level. ## License diff --git a/bin/funding-service/src/commands/mod.rs b/bin/funding-service/src/commands/mod.rs index 5a76a7e910..b71d18a4aa 100644 --- a/bin/funding-service/src/commands/mod.rs +++ b/bin/funding-service/src/commands/mod.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use clap::Parser; use miden_funding_service::{ - DEFAULT_GRPC_TIMEOUT, + DEFAULT_HTTP_TIMEOUT, DEFAULT_MAX_AMOUNT, DEFAULT_RPC_TIMEOUT, FundingServiceConfig, @@ -19,7 +19,7 @@ use tokio::net::TcpListener; use url::Url; const ENV_LISTEN: &str = "MIDEN_FUNDING_LISTEN"; -const ENV_GRPC_TIMEOUT: &str = "MIDEN_FUNDING_GRPC_TIMEOUT"; +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"; @@ -31,19 +31,19 @@ const ENV_MAX_AMOUNT: &str = "MIDEN_FUNDING_MAX_AMOUNT"; pub enum FundingServiceCommand { /// Starts the funding service. Start { - /// Socket address at which to serve the funding service's gRPC API. + /// 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 a gRPC request served by the funding service. + /// Maximum duration allocated to an HTTP request served by the funding service. #[arg( - long = "grpc.timeout", - env = ENV_GRPC_TIMEOUT, - default_value = duration_to_human_readable_string(DEFAULT_GRPC_TIMEOUT), + 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" )] - grpc_timeout: Duration, + http_timeout: Duration, /// The node RPC service gRPC url. #[arg(long = "rpc.url", env = ENV_RPC_URL, value_name = "URL")] @@ -82,7 +82,7 @@ impl FundingServiceCommand { pub async fn handle(self, shutdown: CancellationToken) -> Result<()> { let Self::Start { listen, - grpc_timeout, + http_timeout, rpc_url, rpc_timeout, account_file, @@ -96,7 +96,7 @@ impl FundingServiceCommand { service.name = "miden-funding-service", service.version = env!("CARGO_PKG_VERSION"), funding_service.listen = listen.to_string(), - grpc.timeout = humantime::Duration::from(grpc_timeout).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(), @@ -108,10 +108,10 @@ impl FundingServiceCommand { let listener = TcpListener::bind(listen) .await - .context("failed to bind to the funding service's gRPC socket")?; + .context("failed to bind to the funding service's HTTP socket")?; FundingServiceConfig::new(rpc_url, account_file, genesis) - .with_grpc_timeout(grpc_timeout) + .with_http_timeout(http_timeout) .with_rpc_timeout(rpc_timeout) .with_max_amount(max_amount) .build() diff --git a/bin/funding-service/src/lib.rs b/bin/funding-service/src/lib.rs index 19eddeac1a..1a3d7cf19a 100644 --- a/bin/funding-service/src/lib.rs +++ b/bin/funding-service/src/lib.rs @@ -8,7 +8,6 @@ use std::path::PathBuf; use std::time::Duration; use anyhow::Context; -use miden_node_proto::server::funding_service_api; use miden_node_tracing::info; use miden_node_utils::genesis::GenesisBlock; use miden_node_utils::shutdown::CancellationToken; @@ -20,7 +19,7 @@ use url::Url; use crate::account::FunderKey; use crate::node::RpcNodeClient; -use crate::server::FundingRpcServer; +use crate::server::FundingServer; use crate::status::{StatusRefresher, StatusSnapshot}; mod account; @@ -42,8 +41,8 @@ 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 a gRPC request served by this service. -pub const DEFAULT_GRPC_TIMEOUT: Duration = Duration::from_secs(300); +/// 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); @@ -56,7 +55,7 @@ pub struct FundingServiceConfig { rpc_url: Url, account_file: PathBuf, genesis: GenesisBlock, - grpc_timeout: Duration, + http_timeout: Duration, rpc_timeout: Duration, max_amount: u64, } @@ -71,15 +70,15 @@ impl FundingServiceConfig { rpc_url, account_file, genesis, - grpc_timeout: DEFAULT_GRPC_TIMEOUT, + http_timeout: DEFAULT_HTTP_TIMEOUT, rpc_timeout: DEFAULT_RPC_TIMEOUT, max_amount: DEFAULT_MAX_AMOUNT, } } #[must_use] - pub fn with_grpc_timeout(mut self, timeout: Duration) -> Self { - self.grpc_timeout = timeout; + pub fn with_http_timeout(mut self, timeout: Duration) -> Self { + self.http_timeout = timeout; self } @@ -142,7 +141,7 @@ impl FundingServiceConfig { funder_key, fee_faucet_id, max_amount: self.max_amount, - grpc_timeout: self.grpc_timeout, + http_timeout: self.http_timeout, }) } } @@ -156,35 +155,27 @@ pub struct FundingService { funder_key: FunderKey, fee_faucet_id: AccountId, max_amount: u64, - grpc_timeout: Duration, + http_timeout: Duration, } impl FundingService { - /// Runs the gRPC server and the status refresher until one of them stops. + /// 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 (health_reporter, health_service) = tonic_health::server::health_reporter(); - health_reporter - .set_service_status( - funding_service_api::service_name(), - tonic_health::ServingStatus::Serving, - ) - .await; - let status = StatusSnapshot::new(self.funder_key.account_id(), self.max_amount); let mut tasks = Tasks::new(); - let server = FundingRpcServer::new(status.clone(), self.grpc_timeout); + let server = FundingServer::new(status.clone(), self.http_timeout); let server_shutdown = shutdown.clone(); - tasks.spawn("grpc-server", async move { + tasks.spawn("http-server", async move { server - .serve(listener, health_service, server_shutdown) + .serve(listener, server_shutdown) .await - .context("the funding service gRPC server failed") + .context("the funding service HTTP server failed") }); let refresher = StatusRefresher::new( diff --git a/bin/funding-service/src/server.rs b/bin/funding-service/src/server.rs index 31f5e2e79d..f7acc7085a 100644 --- a/bin/funding-service/src/server.rs +++ b/bin/funding-service/src/server.rs @@ -1,19 +1,15 @@ -//! The gRPC server. +//! The HTTP server. use std::time::Duration; use anyhow::Context; -use miden_node_proto::server::funding_service_api; -use miden_node_proto_build::funding_service_api_descriptor; -use miden_node_tracing::grpc::grpc_trace_fn; +use axum::Router; +use axum::http::StatusCode; +use axum::routing::get; use miden_node_tracing::info; -use miden_node_tracing::panic::{CatchPanicLayer, catch_panic_layer_fn}; use miden_node_utils::shutdown::CancellationToken; use tokio::net::TcpListener; -use tokio_stream::wrappers::TcpListenerStream; -use tonic_health::pb::health_server::{Health, HealthServer}; -use tonic_reflection::server; -use tower_http::classify::{GrpcCode, GrpcErrorsAsFailures, SharedClassifier}; +use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; use crate::LOG_TARGET; @@ -21,86 +17,87 @@ use crate::status::StatusSnapshot; mod status; -// FUNDING SERVICE RPC SERVER +// FUNDING SERVICE HTTP SERVER // ================================================================================================ -/// The gRPC service of the funding service. -/// -/// The handlers do no chain work: `Status` reads the values the status refresher publishes. -pub struct FundingRpcServer { +/// 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 FundingRpcServer { +impl FundingServer { pub(crate) fn new(status: StatusSnapshot, request_timeout: Duration) -> Self { Self { status, request_timeout } } - /// Starts the gRPC server on the given listener. - /// - /// The health service is registered as a liveness signal for the API. + /// Starts the HTTP server on the given listener. pub async fn serve( self, listener: TcpListener, - health_service: HealthServer, shutdown: CancellationToken, ) -> anyhow::Result<()> { - let request_timeout = self.request_timeout; - let api_service = funding_service_api::service(self); - let reflection_service = server::Builder::configure() - .register_file_descriptor_set(funding_service_api_descriptor()) - .register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET) - .build_v1() - .context("failed to build the reflection service")?; - let endpoint = listener .local_addr() .context("failed to read the funding service listen address")?; info!( target: LOG_TARGET, - "Funding service gRPC API listening", + "Funding service HTTP API listening", service.name = "miden-funding-service", service.version = env!("CARGO_PKG_VERSION"), funding_service.listen = endpoint.to_string() ); - tonic::transport::Server::builder() - .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) - // A rejected request is the client's problem, not a server failure, so those codes do - // not mark the span as failed. - .layer( - TraceLayer::new(SharedClassifier::new( - GrpcErrorsAsFailures::new() - .with_success(GrpcCode::InvalidArgument) - .with_success(GrpcCode::FailedPrecondition) - .with_success(GrpcCode::ResourceExhausted), - )) - .make_span_with(grpc_trace_fn), - ) - .timeout(request_timeout) - .add_service(api_service) - .add_service(health_service) - .add_service(reflection_service) - .serve_with_incoming_shutdown( - TcpListenerStream::new(listener), - shutdown.cancelled_owned(), - ) + axum::serve(listener, self.router()) + .with_graceful_shutdown(shutdown.cancelled_owned()) .await - .context("failed to serve the funding service gRPC API") + .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 server for the handler tests. - pub(crate) fn test_server(max_amount: u64) -> FundingRpcServer { - let status = StatusSnapshot::new(FungibleAsset::mock_issuer(), max_amount); + /// 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(); - FundingRpcServer::new(status, Duration::from_secs(1)) + 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 index e2fc058e96..6c904c84c1 100644 --- a/bin/funding-service/src/server/status.rs +++ b/bin/funding-service/src/server/status.rs @@ -1,41 +1,47 @@ -use miden_node_proto::generated as proto; -use miden_node_proto::server::funding_service_api; +use axum::Json; +use axum::extract::State; +use serde::{Deserialize, Serialize}; -use super::FundingRpcServer; use crate::COMPONENT; use crate::status::StatusSnapshot; -#[tonic::async_trait] -impl funding_service_api::Status for FundingRpcServer { - type Input = (); - type Output = StatusSnapshot; +// STATUS RESPONSE +// ================================================================================================ - fn decode(_request: ()) -> tonic::Result { - Ok(()) - } +/// 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, +} - #[miden_node_tracing::miden_instrument(target = COMPONENT, name = "status")] - async fn handle( - &self, - (): Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - // The status is served while the service is still synchronizing, so an operator can read - // the funding account it was configured with. - Ok(self.status.clone()) - } +// STATUS HANDLER +// ================================================================================================ - fn encode(status: Self::Output) -> tonic::Result { - Ok(proto::funding_service::FundingServiceStatus { - version: env!("CARGO_PKG_VERSION").to_string(), - account_id: Some(status.account_id().into()), - balance: status.balance(), - chain_tip: status.chain_tip().as_u32(), - max_amount: status.max_amount(), - verification_base_fee: status.verification_base_fee(), - }) - } +/// 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)] @@ -44,47 +50,23 @@ mod tests { use miden_protocol::asset::FungibleAsset; use super::*; - use crate::server::tests::test_server; + use crate::server::tests::test_status; #[tokio::test] async fn status_reports_the_configured_account_and_the_published_balance() { - let server = test_server(500); - server.status.update(1_234, 42.into(), 7); + let snapshot = test_status(500); + snapshot.update(1_234, 42.into(), 7); - let status = funding_service_api::Status::handle( - &server, - (), - &tonic::metadata::MetadataMap::new(), - &tonic::codegen::http::Extensions::new(), - ) - .await - .unwrap(); - let encoded = ::encode(status).unwrap(); + let Json(response) = status(State(snapshot)).await; assert_eq!( - AccountId::try_from(encoded.account_id.unwrap()).unwrap(), + AccountId::from_hex(&response.account_id).unwrap(), FungibleAsset::mock_issuer() ); - assert_eq!(encoded.balance, 1_234); - assert_eq!(encoded.chain_tip, 42); - assert_eq!(encoded.max_amount, 500); - assert_eq!(encoded.verification_base_fee, 7); - assert_eq!(encoded.version, env!("CARGO_PKG_VERSION")); - } - - /// The status must be available before the service is ready, so an operator can see which - /// account it is waiting on. - #[tokio::test] - async fn status_is_served_while_the_service_is_not_ready() { - let server = test_server(500); - - funding_service_api::Status::handle( - &server, - (), - &tonic::metadata::MetadataMap::new(), - &tonic::codegen::http::Extensions::new(), - ) - .await - .expect("status must not depend on readiness"); + 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/crates/proto/build.rs b/crates/proto/build.rs index 991efe9149..285de03ea3 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -5,7 +5,6 @@ use std::process::Command; use codegen::{Function, Impl, Module, Trait, Type}; use fs_err as fs; use miden_node_proto_build::{ - funding_service_api_descriptor, ntx_builder_api_descriptor, remote_prover_api_descriptor, rpc_api_descriptor, @@ -32,7 +31,6 @@ fn main() -> miette::Result<()> { validator_api_descriptor(), ntx_builder_api_descriptor(), sequencer_api_descriptor(), - funding_service_api_descriptor(), ]; for file_descriptors in &descriptor_sets { diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index 9f15fd52c7..2578c0b5bb 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -185,7 +185,6 @@ type GeneratedProverClient = generated::remote_prover::api_client::ApiClient; type GeneratedNtxBuilderClient = generated::ntx_builder::api_client::ApiClient; type GeneratedSequencerClient = generated::sequencer::api_client::ApiClient; -type GeneratedFundingClient = generated::funding_service::api_client::ApiClient; type GeneratedProvenTransaction = generated::submission::ProvenTransactionSubmission; type SealedTransactionInputs = generated::submission::SealedTransactionInputs; @@ -204,8 +203,6 @@ pub struct ValidatorClient(GeneratedValidatorClient); pub struct NtxBuilderClient(GeneratedNtxBuilderClient); #[derive(Debug, Clone)] pub struct SequencerClient(GeneratedSequencerClient); -#[derive(Debug, Clone)] -pub struct FundingClient(GeneratedFundingClient); impl DerefMut for RpcClient { fn deref_mut(&mut self) -> &mut Self::Target { @@ -291,20 +288,6 @@ impl Deref for SequencerClient { } } -impl DerefMut for FundingClient { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Deref for FundingClient { - type Target = GeneratedFundingClient; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - // GRPC CLIENT BUILDER TRAIT // ================================================================================================ @@ -349,12 +332,6 @@ impl GrpcClient for SequencerClient { } } -impl GrpcClient for FundingClient { - fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self { - Self(GeneratedFundingClient::new(InterceptedService::new(channel, interceptor))) - } -} - // STRICT TYPE-SAFE BUILDER (NO DEFAULTS) // ================================================================================================ diff --git a/crates/tracing/src/attribute.rs b/crates/tracing/src/attribute.rs index f0b6236ad6..6b7f5d0229 100644 --- a/crates/tracing/src/attribute.rs +++ b/crates/tracing/src/attribute.rs @@ -121,6 +121,7 @@ const STRING_FIELD_NAMES: &[&str] = &[ "genesis.source", "genesis.source.kind", "grpc.timeout", + "http.timeout", "internal.listen", "mempool.removal.reason", "network_monitor.listen", diff --git a/proto/proto/README.md b/proto/proto/README.md index 695c275089..7936030076 100644 --- a/proto/proto/README.md +++ b/proto/proto/README.md @@ -15,7 +15,6 @@ The organization of the files is as follows: ```text rpc.proto remote_prover.proto -funding_service.proto types/ ├── submission.proto └── block_proving.proto diff --git a/proto/proto/funding_service.proto b/proto/proto/funding_service.proto deleted file mode 100644 index 176455502e..0000000000 --- a/proto/proto/funding_service.proto +++ /dev/null @@ -1,43 +0,0 @@ -// Specification of the funding service gRPC API. -syntax = "proto3"; -package funding_service; - -import "google/protobuf/empty.proto"; - -import "account.proto"; - -// FUNDING SERVICE API -// ================================================================================================ - -// Sends the chain's native asset to an account. -// -// The service owns one wallet account which holds the native asset. The service does not -// authenticate requests; an operator must restrict access to this API at the infrastructure level. -service Api { - // Returns the status of the funding service. - rpc Status(google.protobuf.Empty) returns (FundingServiceStatus) {} -} - -// STATUS -// ================================================================================================ - -// Response message which holds the status of the funding service. -message FundingServiceStatus { - // The version of the funding service. - string version = 1; - - // The account which sends the notes. - account.AccountId account_id = 2; - - // The balance of the native asset in the funding account, in base units, at `chain_tip`. - uint64 balance = 3; - - // The block number which the service is synchronized to. - fixed32 chain_tip = 4; - - // The largest amount which one funding request accepts, in base units. - uint64 max_amount = 5; - - // The base fee for the verification of a transaction, in base units, at `chain_tip`. - fixed32 verification_base_fee = 6; -} From 82746282eba94c7c4b024adc00fad45096d7b9ba Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Mon, 14 Sep 2026 11:25:29 -0300 Subject: [PATCH 8/9] chore: remove --genesis, use data from block headers --- bin/funding-service/README.md | 4 +- bin/funding-service/src/commands/mod.rs | 12 +----- bin/funding-service/src/lib.rs | 36 ++++------------ bin/funding-service/src/node.rs | 55 ++++++++++++++++++++++--- bin/funding-service/src/status.rs | 10 ++--- 5 files changed, 65 insertions(+), 52 deletions(-) diff --git a/bin/funding-service/README.md b/bin/funding-service/README.md index c340b02718..b01dc1c3e7 100644 --- a/bin/funding-service/README.md +++ b/bin/funding-service/README.md @@ -7,9 +7,7 @@ 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 also needs a trusted genesis block file, from `--genesis`. The genesis block names the chain's fee asset, -which the node's RPC API does not serve. The service refuses to start when the genesis block commits to a different -chain than the node. +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. diff --git a/bin/funding-service/src/commands/mod.rs b/bin/funding-service/src/commands/mod.rs index b71d18a4aa..99f86621a7 100644 --- a/bin/funding-service/src/commands/mod.rs +++ b/bin/funding-service/src/commands/mod.rs @@ -13,7 +13,6 @@ use miden_funding_service::{ 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::genesis::read_genesis_block; use miden_node_utils::shutdown::CancellationToken; use tokio::net::TcpListener; use url::Url; @@ -23,7 +22,6 @@ 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_GENESIS: &str = "MIDEN_FUNDING_GENESIS"; const ENV_MAX_AMOUNT: &str = "MIDEN_FUNDING_MAX_AMOUNT"; #[derive(Parser)] @@ -63,10 +61,6 @@ pub enum FundingServiceCommand { #[arg(long = "account-file", env = ENV_ACCOUNT_FILE, value_name = "PATH")] account_file: PathBuf, - /// Path to a trusted genesis block file, which names the chain's fee asset. - #[arg(long = "genesis", env = ENV_GENESIS, value_name = "FILE")] - genesis_block_file: PathBuf, - /// Largest amount one request may ask for, in base units of the native asset. #[arg( long = "max-amount", @@ -86,7 +80,6 @@ impl FundingServiceCommand { rpc_url, rpc_timeout, account_file, - genesis_block_file, max_amount, } = self; @@ -103,14 +96,11 @@ impl FundingServiceCommand { funding_service.max_amount = max_amount ); - let genesis = - read_genesis_block(&genesis_block_file).context("failed to read the genesis block")?; - let listener = TcpListener::bind(listen) .await .context("failed to bind to the funding service's HTTP socket")?; - FundingServiceConfig::new(rpc_url, account_file, genesis) + FundingServiceConfig::new(rpc_url, account_file) .with_http_timeout(http_timeout) .with_rpc_timeout(rpc_timeout) .with_max_amount(max_amount) diff --git a/bin/funding-service/src/lib.rs b/bin/funding-service/src/lib.rs index 1a3d7cf19a..c5fbfe5027 100644 --- a/bin/funding-service/src/lib.rs +++ b/bin/funding-service/src/lib.rs @@ -9,11 +9,9 @@ use std::time::Duration; use anyhow::Context; use miden_node_tracing::info; -use miden_node_utils::genesis::GenesisBlock; use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tasks::Tasks; -use miden_protocol::account::AccountId; -use miden_protocol::asset::FungibleAsset; +use miden_protocol::asset::{AssetId, FungibleAsset}; use tokio::net::TcpListener; use url::Url; @@ -54,7 +52,6 @@ const STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(30); pub struct FundingServiceConfig { rpc_url: Url, account_file: PathBuf, - genesis: GenesisBlock, http_timeout: Duration, rpc_timeout: Duration, max_amount: u64, @@ -62,14 +59,10 @@ pub struct FundingServiceConfig { impl FundingServiceConfig { /// Creates a configuration with default timeouts and limits. - /// - /// The genesis block names the fee asset. The node's RPC API does not serve the protocol - /// configuration, so the operator must supply the genesis block from a trusted source. - pub fn new(rpc_url: Url, account_file: PathBuf, genesis: GenesisBlock) -> Self { + pub fn new(rpc_url: Url, account_file: PathBuf) -> Self { Self { rpc_url, account_file, - genesis, http_timeout: DEFAULT_HTTP_TIMEOUT, rpc_timeout: DEFAULT_RPC_TIMEOUT, max_amount: DEFAULT_MAX_AMOUNT, @@ -103,20 +96,8 @@ impl FundingServiceConfig { .await .context("failed to connect to the node RPC API")?; - // A genesis block from another chain would name the wrong fee asset, so the service must - // not start when the node serves a different chain. - let node_genesis = node.genesis_commitment(); - let configured_genesis = self.genesis.inner().header().commitment(); - anyhow::ensure!( - configured_genesis == node_genesis, - "the genesis block does not match the node: the genesis block commits to \ - {configured_genesis}, the node to {node_genesis}", - ); - // The fee asset is constant for the chain and is only named by the protocol configuration, - // which the node's RPC API does not serve. The remaining fee parameters are in every block - // header, and the status refresher reads them at the block it reports. - let fee_faucet_id = self.genesis.protocol_config().fee_asset_id().faucet_id(); + let fee_asset_id = node.protocol_config().fee_asset_id(); let fee_parameters = node .fee_parameters(None) .await @@ -124,14 +105,15 @@ impl FundingServiceConfig { // 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_faucet_id, self.max_amount) + 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_faucet_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 ); @@ -139,7 +121,7 @@ impl FundingServiceConfig { Ok(FundingService { node, funder_key, - fee_faucet_id, + fee_asset_id, max_amount: self.max_amount, http_timeout: self.http_timeout, }) @@ -153,7 +135,7 @@ impl FundingServiceConfig { pub struct FundingService { node: RpcNodeClient, funder_key: FunderKey, - fee_faucet_id: AccountId, + fee_asset_id: AssetId, max_amount: u64, http_timeout: Duration, } @@ -181,7 +163,7 @@ impl FundingService { let refresher = StatusRefresher::new( self.node, self.funder_key.account_id(), - self.fee_faucet_id, + self.fee_asset_id, status, STATUS_REFRESH_INTERVAL, ); diff --git a/bin/funding-service/src/node.rs b/bin/funding-service/src/node.rs index 74fbeae412..70ec93199a 100644 --- a/bin/funding-service/src/node.rs +++ b/bin/funding-service/src/node.rs @@ -6,6 +6,7 @@ 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, @@ -17,6 +18,7 @@ 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; @@ -29,15 +31,20 @@ use crate::COMPONENT; 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) = + let (rpc_client, genesis_commitment, protocol_config) = create_genesis_aware_rpc_client(rpc_url, timeout).await?; - Ok(Self { rpc_client, genesis_commitment }) + Ok(Self { + rpc_client, + genesis_commitment, + protocol_config, + }) } /// The commitment of the genesis block the node serves. It identifies the chain. @@ -45,6 +52,11 @@ impl RpcNodeClient { 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?; @@ -124,7 +136,7 @@ fn genesis_discovery_backoff() -> ExponentialBuilder { async fn create_genesis_aware_rpc_client( rpc_url: &Url, timeout: Duration, -) -> Result<(RpcClient, Word)> { +) -> Result<(RpcClient, Word, ProtocolConfig)> { (|| async { // First, create a temporary client without genesis metadata to discover the genesis block // header and its commitment. @@ -140,7 +152,7 @@ async fn create_genesis_aware_rpc_client( .await .context("failed to create an RPC client for genesis discovery")?; - let genesis_header = fetch_block_header(&mut rpc, Some(BlockNumber::GENESIS)).await?; + 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 @@ -157,7 +169,7 @@ async fn create_genesis_aware_rpc_client( .await .context("failed to connect to the RPC server with genesis metadata")?; - Ok((rpc_client, genesis_commitment)) + Ok((rpc_client, genesis_commitment, protocol_config)) }) .retry(genesis_discovery_backoff()) .notify(|err: &anyhow::Error, sleep: Duration| { @@ -179,6 +191,7 @@ async fn fetch_block_header( 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 @@ -193,3 +206,35 @@ async fn fetch_block_header( block_header.try_into().context("failed to convert 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")? + .try_into() + .context("failed to convert 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/status.rs b/bin/funding-service/src/status.rs index 586b42c092..524d5072ae 100644 --- a/bin/funding-service/src/status.rs +++ b/bin/funding-service/src/status.rs @@ -78,7 +78,7 @@ impl StatusSnapshot { pub struct StatusRefresher { node: RpcNodeClient, account_id: AccountId, - fee_faucet_id: AccountId, + fee_asset_id: AssetId, status: StatusSnapshot, interval: Duration, } @@ -87,14 +87,14 @@ impl StatusRefresher { pub fn new( node: RpcNodeClient, account_id: AccountId, - fee_faucet_id: AccountId, + fee_asset_id: AssetId, status: StatusSnapshot, interval: Duration, ) -> Self { Self { node, account_id, - fee_faucet_id, + fee_asset_id, status, interval, } @@ -128,9 +128,7 @@ impl StatusRefresher { // belongs to the block the status reports. let fee_parameters = self.node.fee_parameters(Some(block_num)).await?; - let balance = vault - .get_balance(AssetId::new_fungible(self.fee_faucet_id)) - .map_or(0, |amount| amount.as_u64()); + 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(()) From e549cc3caba9e8342a963b640f99c7e9357559a4 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 15 Sep 2026 11:49:56 -0300 Subject: [PATCH 9/9] chore: update with latest protocol changes --- bin/funding-service/src/node.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/bin/funding-service/src/node.rs b/bin/funding-service/src/node.rs index 70ec93199a..bccef3169a 100644 --- a/bin/funding-service/src/node.rs +++ b/bin/funding-service/src/node.rs @@ -12,6 +12,7 @@ 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; @@ -69,14 +70,11 @@ impl RpcNodeClient { &self, account_id: AccountId, ) -> Result<(AssetVault, BlockNumber)> { - let id_bytes: [u8; 15] = account_id.into(); // 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(miden_node_proto::generated::account::AccountId { - id: id_bytes.to_vec(), - }), + account_id: Some(account_id.into()), // Without a block number the node answers at its chain tip. block_num: None, details: Some(AccountDetailRequest { @@ -204,7 +202,11 @@ async fn fetch_block_header( .block_header .context("the block header response holds no header")?; - block_header.try_into().context("failed to convert the block 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. @@ -227,8 +229,10 @@ async fn fetch_genesis_header_and_config( let block_header: BlockHeader = response .block_header .context("the block header response holds no header")? - .try_into() - .context("failed to convert the block 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,