From e2256d40b24f148db5b8e4eed6216020bf448bb1 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 8 Sep 2026 18:32:28 -0300 Subject: [PATCH 1/4] 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 | 18 + bin/funding-service/src/account.rs | 119 +++++++ bin/funding-service/src/commands/mod.rs | 123 +++++++ bin/funding-service/src/lib.rs | 188 +++++++++++ bin/funding-service/src/main.rs | 14 + bin/funding-service/src/node.rs | 317 ++++++++++++++++++ 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/proto/src/domain/note.rs | 47 +++ 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 + 29 files changed, 1380 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 d8f3506598..4a385fa437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3972,6 +3972,32 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miden-funding-service" +version = "0.16.0-rc.5" +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.16.0-rc.5" diff --git a/Cargo.toml b/Cargo.toml index f483fc852f..37ca684af1 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 17d325e0f9..1d4b3e3f26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -120,6 +120,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 && \ @@ -128,6 +129,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..b5ad1f832c --- /dev/null +++ b/bin/funding-service/README.md @@ -0,0 +1,18 @@ +# 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 `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..afc10ad243 --- /dev/null +++ b/bin/funding-service/src/commands/mod.rs @@ -0,0 +1,123 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use miden_funding_service::{ + DEFAULT_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::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_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, + + /// 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, + 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 listener = TcpListener::bind(listen) + .await + .context("failed to bind to the funding service's gRPC socket")?; + + FundingServiceConfig::new(rpc_url, account_file) + .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..823a654464 --- /dev/null +++ b/bin/funding-service/src/lib.rs @@ -0,0 +1,188 @@ +//! 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::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, + grpc_timeout: Duration, + rpc_timeout: Duration, + max_amount: u64, +} + +impl FundingServiceConfig { + /// Creates a configuration with default timeouts and limits. + pub fn new(rpc_url: Url, account_file: PathBuf) -> Self { + Self { + rpc_url, + account_file, + 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")?; + + let fee_parameters = node.genesis_header().fee_parameters().clone(); + let fee_faucet_id = fee_parameters.fee_faucet_id(); + + // 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..21d781a0ad --- /dev/null +++ b/bin/funding-service/src/node.rs @@ -0,0 +1,317 @@ +//! 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, + AccountCode, + 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 miden_protocol::utils::serde::Deserializable; +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::Digest = 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), + 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 = AccountCode::read_from_bytes( + &details.account_code.context("the server did not return the account code")?, + ) + .context("failed to deserialize 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 1d6a4adee6..04bd8ea1a4 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 e7be926a1c..2d2565263c 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -186,6 +186,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::transaction::ProvenTransaction; type SealedTransactionInputs = generated::transaction::SealedTransactionInputs; @@ -204,6 +205,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 { @@ -289,6 +292,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 // ================================================================================================ @@ -333,6 +350,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/proto/src/domain/note.rs b/crates/proto/src/domain/note.rs index cd36a5db31..b7bc819440 100644 --- a/crates/proto/src/domain/note.rs +++ b/crates/proto/src/domain/note.rs @@ -191,6 +191,14 @@ impl TryFrom for Word { } } +impl TryFrom for NoteId { + type Error = ConversionError; + + fn try_from(note_id: proto::note::NoteId) -> Result { + Word::try_from(note_id).map(NoteId::from_raw) + } +} + impl From<&NoteId> for proto::note::NoteId { fn from(note_id: &NoteId) -> Self { Self { id: Some(note_id.into()) } @@ -230,6 +238,45 @@ impl TryFrom<&proto::note::NoteInclusionInBlockProof> for (NoteId, NoteInclusion } } +// COMMITTED NOTE +// ================================================================================================ + +impl From<(Note, NoteInclusionProof)> for proto::note::CommittedNote { + fn from((note, proof): (Note, NoteInclusionProof)) -> Self { + let inclusion_proof = Some((¬e.id(), &proof).into()); + Self { note: Some(note.into()), inclusion_proof } + } +} + +impl TryFrom for (Note, NoteInclusionProof) { + type Error = ConversionError; + + fn try_from(committed: proto::note::CommittedNote) -> Result { + let decoder = committed.decoder(); + let inclusion_proof = committed + .inclusion_proof + .as_ref() + .ok_or_else(|| { + ConversionError::missing_field::("inclusion_proof") + }) + .and_then(<(NoteId, NoteInclusionProof)>::try_from) + .context("inclusion_proof")?; + let (proven_id, proof) = inclusion_proof; + let note: Note = decode!(decoder, committed.note)?; + + // The proof commits to a note ID. A mismatch means the response is inconsistent, and the + // proof does not prove the inclusion of this note. + if proven_id != note.id() { + return Err(ConversionError::message(format!( + "inclusion proof is for note {proven_id} but the note is {}", + note.id() + ))); + } + + Ok((note, proof)) + } +} + // NOTE HEADER // ================================================================================================ diff --git a/crates/store/src/genesis/config/errors.rs b/crates/store/src/genesis/config/errors.rs index 37a9e4a7b0..abac990271 100644 --- a/crates/store/src/genesis/config/errors.rs +++ b/crates/store/src/genesis/config/errors.rs @@ -71,4 +71,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 258cd43dbc..2088799777 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; @@ -250,7 +251,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, @@ -286,11 +288,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); } @@ -359,6 +362,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, @@ -593,6 +608,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, @@ -601,7 +619,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 2965db3bc7..7fb5530257 100644 --- a/crates/store/src/genesis/config/samples/01-simple.toml +++ b/crates/store/src/genesis/config/samples/01-simple.toml @@ -30,3 +30,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 2abe1a4de1..a343f25396 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 a9c7b7a11c..12fcb0dd18 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", @@ -107,6 +115,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", @@ -130,6 +140,7 @@ const STRING_FIELD_NAMES: &[&str] = &[ "rpc.timeout", "sequencer.endpoint", "service.name", + "service.readiness.reason", "service.version", "shutdown.signal", "sync.block_source.endpoint", @@ -296,7 +307,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()) @@ -352,6 +364,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 fed23cbfd4..090be79a21 100644 --- a/proto/proto/README.md +++ b/proto/proto/README.md @@ -11,6 +11,7 @@ The organization of the files is as follows: ```text rpc.proto remote_prover.proto +funding_service.proto types/ ├── primitives.proto └── xxx.proto diff --git a/proto/proto/funding_service.proto b/proto/proto/funding_service.proto new file mode 100644 index 0000000000..6bc7c137d9 --- /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 "types/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 02509de639ab6d1e7c120ed7cd1d0a44820341cb Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Wed, 9 Sep 2026 13:04:39 -0300 Subject: [PATCH 2/4] feat(funding-service): add the RequestFunds endpoint --- Cargo.lock | 5 + bin/funding-service/Cargo.toml | 16 +- bin/funding-service/README.md | 7 +- bin/funding-service/src/account.rs | 34 +- bin/funding-service/src/commands/mod.rs | 95 ++- bin/funding-service/src/data_store.rs | 185 ++++++ bin/funding-service/src/error.rs | 89 +++ bin/funding-service/src/inclusion.rs | 87 +++ bin/funding-service/src/lib.rs | 160 ++++- bin/funding-service/src/node.rs | 200 +++++- bin/funding-service/src/prover.rs | 130 ++++ bin/funding-service/src/server.rs | 25 +- .../src/server/request_funds.rs | 183 ++++++ bin/funding-service/src/server/status.rs | 4 +- bin/funding-service/src/test_utils.rs | 150 +++++ bin/funding-service/src/tx.rs | 334 ++++++++++ bin/funding-service/src/worker.rs | 576 ++++++++++++++++++ crates/proto/src/domain/funding.rs | 39 ++ crates/proto/src/domain/mod.rs | 1 + proto/proto/funding_service.proto | 40 +- 20 files changed, 2316 insertions(+), 44 deletions(-) create mode 100644 bin/funding-service/src/data_store.rs create mode 100644 bin/funding-service/src/error.rs create mode 100644 bin/funding-service/src/inclusion.rs create mode 100644 bin/funding-service/src/prover.rs create mode 100644 bin/funding-service/src/server/request_funds.rs create mode 100644 bin/funding-service/src/test_utils.rs create mode 100644 bin/funding-service/src/tx.rs create mode 100644 bin/funding-service/src/worker.rs create mode 100644 crates/proto/src/domain/funding.rs diff --git a/Cargo.lock b/Cargo.lock index 4a385fa437..d092c4e8e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3979,6 +3979,8 @@ dependencies = [ "anyhow", "backon", "clap", + "futures", + "hex", "humantime", "miden-node-proto", "miden-node-proto-build", @@ -3986,9 +3988,12 @@ dependencies = [ "miden-node-utils", "miden-protocol", "miden-standards", + "miden-testing", + "miden-tx", "rand 0.10.2", "rand_chacha 0.10.0", "tempfile", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", diff --git a/bin/funding-service/Cargo.toml b/bin/funding-service/Cargo.toml index 0f52f04964..29916fab58 100644 --- a/bin/funding-service/Cargo.toml +++ b/bin/funding-service/Cargo.toml @@ -21,12 +21,19 @@ doctest = false anyhow = { workspace = true } backon = { workspace = true } clap = { features = ["env", "string"], workspace = true } +futures = { workspace = true } +hex = { 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 } +miden-standards = { workspace = true } +miden-tx = { features = ["concurrent", "std"], workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +thiserror = { workspace = true } tokio = { features = ["macros", "net", "rt-multi-thread", "sync", "time"], workspace = true } tokio-stream = { features = ["net"], workspace = true } tonic = { workspace = true } @@ -36,8 +43,7 @@ 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 } +miden-protocol = { features = ["std", "testing"], workspace = true } +miden-testing = { workspace = true } +miden-tx = { features = ["concurrent", "std", "testing"], workspace = true } +tempfile = { workspace = true } diff --git a/bin/funding-service/README.md b/bin/funding-service/README.md index b5ad1f832c..a917ff113f 100644 --- a/bin/funding-service/README.md +++ b/bin/funding-service/README.md @@ -4,8 +4,11 @@ ## 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 holds no chain state. It reads the funding account from the node before every transaction, so a restart +needs no recovery. Only the account file, which holds the account ID and its signing key, is on disk. + +Each request creates a private pay-to-ID note for the requested account. The service waits until the note is committed +in a block, then returns the note together with proof of its inclusion. 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. diff --git a/bin/funding-service/src/account.rs b/bin/funding-service/src/account.rs index abfa37196c..04a16de15a 100644 --- a/bin/funding-service/src/account.rs +++ b/bin/funding-service/src/account.rs @@ -3,8 +3,10 @@ use std::path::Path; use anyhow::{Context, Result}; +use miden_protocol::Word; use miden_protocol::account::auth::AuthSecretKey; use miden_protocol::account::{AccountFile, AccountId, AccountType}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; // FUNDER KEY // ================================================================================================ @@ -13,6 +15,8 @@ use miden_protocol::account::{AccountFile, AccountId, AccountType}; #[derive(Clone, Debug)] pub struct FunderKey { account_id: AccountId, + secret_key: SecretKey, + code_commitment: Word, } impl FunderKey { @@ -21,10 +25,13 @@ impl FunderKey { let account_file = AccountFile::read(path) .with_context(|| format!("failed to read the account file at {}", path.display()))?; - account_file + let secret_key = account_file .auth_secret_keys .iter() - .find(|key| matches!(key, AuthSecretKey::Falcon512Poseidon2(_))) + .find_map(|key| match key { + AuthSecretKey::Falcon512Poseidon2(secret_key) => Some(secret_key.clone()), + _ => None, + }) .with_context(|| { format!( "the account file at {} holds no Falcon512Poseidon2 secret key", @@ -40,12 +47,28 @@ impl FunderKey { account.id(), ); - Ok(Self { account_id: account.id() }) + Ok(Self { + account_id: account.id(), + secret_key, + code_commitment: account.code().commitment(), + }) } pub fn account_id(&self) -> AccountId { self.account_id } + + pub fn secret_key(&self) -> &SecretKey { + &self.secret_key + } + + /// The commitment to the account code in the account file. + /// + /// Compared against the code of the account on chain, so an account file from another network + /// fails at startup instead of as an opaque execution error. + pub fn code_commitment(&self) -> Word { + self.code_commitment + } } #[cfg(test)] @@ -53,7 +76,6 @@ 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}; @@ -93,12 +115,14 @@ mod tests { let path = write_account_file( dir.path(), &account, - vec![AuthSecretKey::Falcon512Poseidon2(secret_key)], + vec![AuthSecretKey::Falcon512Poseidon2(secret_key.clone())], ); let funder = FunderKey::load(&path).expect("a public wallet with a key should load"); assert_eq!(funder.account_id(), account.id()); + assert_eq!(funder.code_commitment(), account.code().commitment()); + assert_eq!(funder.secret_key().public_key(), secret_key.public_key()); } /// The service reads the funder's vault from the node, which is only possible for a public diff --git a/bin/funding-service/src/commands/mod.rs b/bin/funding-service/src/commands/mod.rs index afc10ad243..6c04d620de 100644 --- a/bin/funding-service/src/commands/mod.rs +++ b/bin/funding-service/src/commands/mod.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use std::num::{NonZeroU16, NonZeroUsize}; use std::path::PathBuf; use std::time::Duration; @@ -7,13 +8,19 @@ use clap::Parser; use miden_funding_service::{ DEFAULT_GRPC_TIMEOUT, DEFAULT_MAX_AMOUNT, + DEFAULT_MAX_NOTES_PER_TX, + DEFAULT_POLL_INTERVAL, DEFAULT_RPC_TIMEOUT, + DEFAULT_TX_EXPIRATION_DELTA, + DEFAULT_TX_PROVER_TIMEOUT, FundingServiceConfig, }; use miden_node_tracing::{OpenTelemetry, info}; use miden_node_utils::clap::duration_to_human_readable_string; use miden_node_utils::formatting::format_endpoint; use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; +use miden_protocol::utils::serde::Deserializable; use tokio::net::TcpListener; use url::Url; @@ -21,8 +28,14 @@ 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_TX_PROVER_URL: &str = "MIDEN_FUNDING_TX_PROVER_URL"; +const ENV_TX_PROVER_TIMEOUT: &str = "MIDEN_FUNDING_TX_PROVER_TIMEOUT"; const ENV_ACCOUNT_FILE: &str = "MIDEN_FUNDING_ACCOUNT_FILE"; const ENV_MAX_AMOUNT: &str = "MIDEN_FUNDING_MAX_AMOUNT"; +const ENV_MAX_NOTES_PER_TX: &str = "MIDEN_FUNDING_MAX_NOTES_PER_TX"; +const ENV_TX_EXPIRATION_DELTA: &str = "MIDEN_FUNDING_TX_EXPIRATION_DELTA"; +const ENV_POLL_INTERVAL: &str = "MIDEN_FUNDING_POLL_INTERVAL"; +const ENV_VALIDATOR_SIGNING_PUBLIC_KEYS: &str = "MIDEN_FUNDING_VALIDATOR_SIGNING_PUBLIC_KEYS"; #[derive(Parser)] #[command(version, about, long_about = None)] @@ -57,6 +70,20 @@ pub enum FundingServiceCommand { )] rpc_timeout: Duration, + /// The remote transaction prover's gRPC url. + #[arg(long = "tx-prover.url", env = ENV_TX_PROVER_URL, value_name = "URL")] + tx_prover_url: Option, + + /// Request timeout for calls to the remote transaction prover. + #[arg( + long = "tx-prover.timeout", + env = ENV_TX_PROVER_TIMEOUT, + default_value = duration_to_human_readable_string(DEFAULT_TX_PROVER_TIMEOUT), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + tx_prover_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, @@ -69,6 +96,46 @@ pub enum FundingServiceCommand { value_name = "AMOUNT" )] max_amount: u64, + + /// Largest number of notes one funding transaction creates. + #[arg( + long = "max-notes-per-tx", + env = ENV_MAX_NOTES_PER_TX, + default_value_t = DEFAULT_MAX_NOTES_PER_TX, + value_name = "NUM" + )] + max_notes_per_tx: NonZeroUsize, + + /// Number of blocks after its reference block at which a funding transaction expires. + #[arg( + long = "tx-expiration-delta", + env = ENV_TX_EXPIRATION_DELTA, + default_value_t = DEFAULT_TX_EXPIRATION_DELTA, + value_name = "BLOCKS" + )] + tx_expiration_delta: NonZeroU16, + + /// Interval at which the service asks the node whether its notes are committed. + #[arg( + long = "poll-interval", + env = ENV_POLL_INTERVAL, + default_value = duration_to_human_readable_string(DEFAULT_POLL_INTERVAL), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + poll_interval: Duration, + + /// Hex-encoded validator signing public key trusted to attest the transaction encryption + /// key. + #[arg( + long = "validator-signing-public-key", + env = ENV_VALIDATOR_SIGNING_PUBLIC_KEYS, + value_delimiter = ',', + value_parser = parse_validator_public_key, + required = true, + value_name = "HEX" + )] + validator_signing_public_keys: Vec, }, } @@ -79,8 +146,14 @@ impl FundingServiceCommand { grpc_timeout, rpc_url, rpc_timeout, + tx_prover_url, + tx_prover_timeout, account_file, max_amount, + max_notes_per_tx, + tx_expiration_delta, + poll_interval, + validator_signing_public_keys, } = self; info!( @@ -92,18 +165,28 @@ impl FundingServiceCommand { grpc.timeout = humantime::Duration::from(grpc_timeout).to_string(), rpc.endpoint = format_endpoint(&rpc_url), rpc.timeout = humantime::Duration::from(rpc_timeout).to_string(), + tx_prover.endpoint = + tx_prover_url.as_ref().map_or_else(|| "local".to_owned(), format_endpoint), account.file = account_file.as_path(), - funding_service.max_amount = max_amount + funding_service.max_amount = max_amount, + funding_service.max_notes_per_tx = max_notes_per_tx.get(), + funding_service.tx_expiration_delta = tx_expiration_delta.get(), + funding_service.poll_interval = humantime::Duration::from(poll_interval).to_string() ); let listener = TcpListener::bind(listen) .await .context("failed to bind to the funding service's gRPC socket")?; - FundingServiceConfig::new(rpc_url, account_file) + FundingServiceConfig::new(rpc_url, account_file, validator_signing_public_keys) + .with_tx_prover_url(tx_prover_url) .with_grpc_timeout(grpc_timeout) .with_rpc_timeout(rpc_timeout) + .with_tx_prover_timeout(tx_prover_timeout) .with_max_amount(max_amount) + .with_max_notes_per_tx(max_notes_per_tx) + .with_tx_expiration_delta(tx_expiration_delta) + .with_poll_interval(poll_interval) .build() .await .context("failed to initialize the funding service")? @@ -121,3 +204,11 @@ impl FundingServiceCommand { OpenTelemetry::from_env().with_name("funding-service") } } + +/// Decodes a hex-encoded validator signing public key. +fn parse_validator_public_key(value: &str) -> Result { + let bytes = hex::decode(value.trim_start_matches("0x")) + .context("a validator signing public key must be hex encoded")?; + ValidatorPublicKey::read_from_bytes(&bytes) + .context("a validator signing public key must be a valid K256 public key") +} diff --git a/bin/funding-service/src/data_store.rs b/bin/funding-service/src/data_store.rs new file mode 100644 index 0000000000..7199c71e0e --- /dev/null +++ b/bin/funding-service/src/data_store.rs @@ -0,0 +1,185 @@ +//! In-memory transaction data store. + +use std::collections::{BTreeSet, HashMap}; + +use miden_protocol::Word; +use miden_protocol::account::{ + Account, + AccountId, + PartialAccount, + StorageMapKey, + StorageMapWitness, + StorageSlotContent, +}; +use miden_protocol::asset::{AssetId, AssetWitness}; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::note::{NoteScript, NoteScriptRoot}; +use miden_protocol::transaction::{AccountInputs, PartialBlockchain}; +use miden_protocol::vm::FutureMaybeSend; +use miden_tx::{ + DataStore, + DataStoreError, + LoadedMastForest, + MastForestStore, + TransactionMastStore, +}; + +// IN-MEMORY DATA STORE +// ================================================================================================ + +/// An in-memory [`DataStore`] which holds the accounts one transaction needs. +/// +/// The store is built for a single transaction: it holds the reference block, the partial +/// blockchain proving that block, the executing account, and any account the transaction reaches +/// through a foreign procedure invocation. +pub struct InMemoryDataStore { + accounts: HashMap, + account_witnesses: HashMap, + block_header: BlockHeader, + partial_block_chain: PartialBlockchain, + mast_store: TransactionMastStore, +} + +impl InMemoryDataStore { + pub fn new(block_header: BlockHeader, partial_block_chain: PartialBlockchain) -> Self { + Self { + accounts: HashMap::new(), + account_witnesses: HashMap::new(), + block_header, + partial_block_chain, + mast_store: TransactionMastStore::new(), + } + } + + /// Add or replace an account in the store and load its code into the MAST store. + pub fn add_account(&mut self, account: Account) { + self.mast_store.load_account_code(account.code()); + self.accounts.insert(account.id(), account); + } + + /// Register an account the transaction reaches through a foreign procedure invocation, together + /// with the account-tree witness proving its state in the store's reference block. + pub fn add_foreign_account(&mut self, account: Account, witness: AccountWitness) { + self.add_account(account); + self.account_witnesses.insert(witness.id(), witness); + } + + /// Returns a reference to the account or a standardized "unknown account" error. + fn get_account(&self, account_id: AccountId) -> Result<&Account, DataStoreError> { + self.accounts.get(&account_id).ok_or_else(|| DataStoreError::Other { + error_msg: "unknown account".into(), + source: None, + }) + } +} + +impl DataStore for InMemoryDataStore { + fn get_transaction_inputs( + &self, + account_id: AccountId, + mut _block_refs: BTreeSet, + ) -> impl FutureMaybeSend> + { + async move { + let account = self.get_account(account_id)?; + let partial_account = PartialAccount::from(account); + + Ok((partial_account, self.block_header.clone(), self.partial_block_chain.clone())) + } + } + + fn get_storage_map_witness( + &self, + account_id: AccountId, + map_root: Word, + map_key: StorageMapKey, + ) -> impl FutureMaybeSend> { + async move { + let account = self.get_account(account_id)?; + + account + .storage() + .slots() + .iter() + .filter_map(|slot| match slot.content() { + StorageSlotContent::Map(map) => Some(map), + StorageSlotContent::Value(_) => None, + }) + .find(|map| map.root() == map_root) + .map(|map| map.open(&map_key)) + .ok_or_else(|| DataStoreError::Other { + error_msg: format!( + "no storage map with the requested root in account {account_id}" + ) + .into(), + source: None, + }) + } + } + + fn get_foreign_account_inputs( + &self, + foreign_account_id: AccountId, + _ref_block: BlockNumber, + ) -> impl FutureMaybeSend> { + async move { + let account = self.get_account(foreign_account_id)?; + let witness = + self.account_witnesses.get(&foreign_account_id).cloned().ok_or_else(|| { + DataStoreError::Other { + error_msg: format!( + "no account witness for foreign account {foreign_account_id}" + ) + .into(), + source: None, + } + })?; + + Ok(AccountInputs::new(PartialAccount::from(account), witness)) + } + } + + fn get_vault_asset_witnesses( + &self, + account_id: AccountId, + vault_root: Word, + vault_keys: BTreeSet, + ) -> impl FutureMaybeSend, DataStoreError>> { + async move { + let account = self.get_account(account_id)?; + + if account.vault().root() != vault_root { + return Err(DataStoreError::Other { + error_msg: "vault root mismatch".into(), + source: None, + }); + } + + vault_keys + .into_iter() + .map(|vault_key| { + AssetWitness::new(account.vault().open(vault_key).into(), [vault_key]).map_err( + |err| DataStoreError::Other { + error_msg: "failed to open vault asset tree".into(), + source: Some(Box::new(err)), + }, + ) + }) + .collect::, _>>() + } + } + + fn get_note_script( + &self, + _script_root: NoteScriptRoot, + ) -> impl FutureMaybeSend, DataStoreError>> { + async move { Ok(None) } + } +} + +impl MastForestStore for InMemoryDataStore { + fn get(&self, procedure_hash: &Word) -> Option { + self.mast_store.get(procedure_hash) + } +} diff --git a/bin/funding-service/src/error.rs b/bin/funding-service/src/error.rs new file mode 100644 index 0000000000..afd3d407d7 --- /dev/null +++ b/bin/funding-service/src/error.rs @@ -0,0 +1,89 @@ +use miden_node_tracing::ErrorReport; +use miden_protocol::block::BlockNumber; + +/// The reason a funding request failed. +#[derive(Debug, thiserror::Error)] +pub enum RequestFundsError { + /// The service could not complete the request for a reason the client cannot act on. + #[error("internal error")] + Internal(#[source] anyhow::Error), + + /// A note must hold a non-zero amount. + #[error("the requested amount must not be zero")] + InvalidAmount, + + /// The request asked for more than the configured maximum. + #[error("the requested amount {requested} exceeds the maximum of {maximum}")] + AmountExceedsMaximum { requested: u64, maximum: u64 }, + + /// The funding account cannot cover the request and the fee of one transaction. + #[error( + "the funding account holds {balance} base units, which does not cover the requested \ + {requested} plus a fee reserve of {reserve}" + )] + InsufficientFunds { + requested: u64, + balance: u64, + reserve: u64, + }, + + /// The service cannot serve requests at the moment. + #[error("the funding service is not ready: {0}")] + NotReady(&'static str), + + /// The funding transaction did not commit before it expired. + #[error("the funding transaction did not commit before block {expiration_block}")] + TransactionExpired { expiration_block: BlockNumber }, + + /// The node rejected the funding transaction. + #[error("the node rejected the funding transaction")] + TransactionRejected(#[source] anyhow::Error), + + /// Too many requests are queued. + #[error("too many funding requests are queued")] + Busy, +} + +impl RequestFundsError { + /// The byte code carried in the gRPC status details. + fn code(&self) -> u8 { + match self { + Self::Internal(_) => 0, + Self::InvalidAmount => 1, + Self::AmountExceedsMaximum { .. } => 2, + Self::InsufficientFunds { .. } => 3, + Self::NotReady(_) => 4, + Self::TransactionExpired { .. } => 5, + Self::TransactionRejected(_) => 6, + Self::Busy => 7, + } + } + + /// The gRPC status code for this error. + fn status_code(&self) -> tonic::Code { + match self { + Self::Internal(_) => tonic::Code::Internal, + Self::InvalidAmount | Self::AmountExceedsMaximum { .. } => tonic::Code::InvalidArgument, + Self::InsufficientFunds { .. } => tonic::Code::FailedPrecondition, + Self::NotReady(_) => tonic::Code::Unavailable, + // `ABORTED` tells the client to retry the whole request. `DEADLINE_EXCEEDED` is not + // used here because a tonic client also produces that code when its own deadline fires, + // which would make the two cases indistinguishable. + Self::TransactionExpired { .. } | Self::TransactionRejected(_) => tonic::Code::Aborted, + Self::Busy => tonic::Code::ResourceExhausted, + } + } +} + +impl From for tonic::Status { + fn from(err: RequestFundsError) -> Self { + // An internal error may hold details about the service's own state, so the client only + // receives a fixed message. The full report is logged by the worker. + let message = match &err { + RequestFundsError::Internal(_) => "internal error".to_owned(), + other => other.as_report(), + }; + + Self::with_details(err.status_code(), message, vec![err.code()].into()) + } +} diff --git a/bin/funding-service/src/inclusion.rs b/bin/funding-service/src/inclusion.rs new file mode 100644 index 0000000000..d036a18260 --- /dev/null +++ b/bin/funding-service/src/inclusion.rs @@ -0,0 +1,87 @@ +//! Waiting for a funding transaction to commit. + +use std::collections::HashMap; +use std::time::Duration; + +use miden_node_tracing::warn; +use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::{NoteId, NoteInclusionProof}; + +use crate::LOG_TARGET; +use crate::node::RpcNodeClient; + +// AWAIT INCLUSION +// ================================================================================================ + +/// The outcome of waiting for a set of notes to commit. +#[derive(Debug)] +pub enum Inclusion { + /// Every note is committed. Holds a proof for every requested note. + Committed(HashMap), + /// The transaction expired without committing, so no note was created. + Expired, + /// The service is shutting down and stopped waiting. + ShuttingDown, +} + +/// Polls the node until every note in `note_ids` is committed, or until the transaction expires. +pub async fn await_inclusion( + node: &RpcNodeClient, + note_ids: &[NoteId], + expiration_block: BlockNumber, + poll_interval: Duration, + shutdown: &CancellationToken, +) -> Inclusion { + let mut found: HashMap = HashMap::new(); + + loop { + match node.committed_notes(note_ids).await { + Ok(proofs) => found.extend(proofs), + Err(err) => warn!( + &err, + target: LOG_TARGET, + "Failed to look up the funding notes; retrying" + ), + } + + if found.len() == note_ids.len() { + return Inclusion::Committed(found); + } + + // The chain tip decides whether the transaction can still be included. A failure to read it + // must not end the wait, because the notes may well commit. + match node.chain_tip().await { + Ok(tip) if tip >= expiration_block => { + // The tip may have passed the expiration block while the last block was being + // applied, so look once more before giving up. + if let Ok(proofs) = node.committed_notes(note_ids).await { + found.extend(proofs); + } + + if found.len() == note_ids.len() { + return Inclusion::Committed(found); + } + + // A proof for any note proves that the transaction committed, so every note was + // created and the missing proofs are only not visible yet. Giving up here would + // report notes which exist as never created, and their assets would be lost, + // because the service holds the only copy of a private note. + if found.is_empty() { + return Inclusion::Expired; + } + }, + Ok(_) => {}, + Err(err) => warn!( + &err, + target: LOG_TARGET, + "Failed to read the chain tip while waiting for the funding notes" + ), + } + + tokio::select! { + () = tokio::time::sleep(poll_interval) => {}, + () = shutdown.cancelled() => return Inclusion::ShuttingDown, + } + } +} diff --git a/bin/funding-service/src/lib.rs b/bin/funding-service/src/lib.rs index 823a654464..fbed7281b6 100644 --- a/bin/funding-service/src/lib.rs +++ b/bin/funding-service/src/lib.rs @@ -1,9 +1,11 @@ //! The service owns one wallet account which holds the chain's native asset, and sends that asset -//! to any account which asks for it. +//! to any account which asks for it. Each request produces a private pay-to-ID note, which the +//! service returns once the note is committed in a block. // Required by code generated by the upstream `#[instrument]` macro. extern crate miden_node_tracing as tracing; +use std::num::{NonZeroU16, NonZeroUsize}; use std::path::PathBuf; use std::time::Duration; @@ -14,18 +16,30 @@ 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::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use tokio::net::TcpListener; +use tokio::sync::mpsc; use url::Url; use crate::account::FunderKey; use crate::node::RpcNodeClient; +use crate::prover::Prover; use crate::server::FundingRpcServer; use crate::status::{StatusRefresher, StatusSnapshot}; +use crate::worker::{Funder, FunderSetup, WorkerConfig}; mod account; +mod data_store; +mod error; +mod inclusion; mod node; +mod prover; mod server; mod status; +#[cfg(test)] +mod test_utils; +mod tx; +mod worker; // CONSTANTS // ================================================================================================= @@ -38,15 +52,39 @@ 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 largest number of notes one funding transaction creates. +pub const DEFAULT_MAX_NOTES_PER_TX: NonZeroUsize = + NonZeroUsize::new(16).expect("literal is non-zero"); + +/// The node limits how many note IDs one lookup may hold, and the service looks up a whole +/// transaction's notes at once. +const _: () = assert!(DEFAULT_MAX_NOTES_PER_TX.get() <= MAX_NOTES_PER_TX.get()); + +/// Hard bound on `--max-notes-per-tx`. +pub const MAX_NOTES_PER_TX: NonZeroUsize = NonZeroUsize::new(100).expect("literal is non-zero"); + +/// Default number of blocks after its reference block at which a funding transaction expires. +pub const DEFAULT_TX_EXPIRATION_DELTA: NonZeroU16 = + NonZeroU16::new(50).expect("literal is non-zero"); + +/// Default interval at which the service asks the node whether its notes are committed. +pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1); + /// 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 request to the remote prover. +pub const DEFAULT_TX_PROVER_TIMEOUT: Duration = Duration::from_secs(60); + /// 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. +/// How often the service reads the funding account while no request arrives. const STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(30); +/// How many requests may wait in the queue, per note of one transaction. +const QUEUE_CAPACITY_PER_TX: usize = 4; + // CONFIGURATION // ================================================================================================= @@ -54,23 +92,46 @@ const STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(30); pub struct FundingServiceConfig { rpc_url: Url, account_file: PathBuf, + validator_signing_public_keys: Vec, + tx_prover_url: Option, grpc_timeout: Duration, rpc_timeout: Duration, + tx_prover_timeout: Duration, max_amount: u64, + max_notes_per_tx: NonZeroUsize, + tx_expiration_delta: NonZeroU16, + poll_interval: Duration, } impl FundingServiceConfig { /// Creates a configuration with default timeouts and limits. - pub fn new(rpc_url: Url, account_file: PathBuf) -> Self { + pub fn new( + rpc_url: Url, + account_file: PathBuf, + validator_signing_public_keys: Vec, + ) -> Self { Self { rpc_url, account_file, + validator_signing_public_keys, + tx_prover_url: None, grpc_timeout: DEFAULT_GRPC_TIMEOUT, rpc_timeout: DEFAULT_RPC_TIMEOUT, + tx_prover_timeout: DEFAULT_TX_PROVER_TIMEOUT, max_amount: DEFAULT_MAX_AMOUNT, + max_notes_per_tx: DEFAULT_MAX_NOTES_PER_TX, + tx_expiration_delta: DEFAULT_TX_EXPIRATION_DELTA, + poll_interval: DEFAULT_POLL_INTERVAL, } } + /// Sets the remote prover to use. Without one the service proves locally. + #[must_use] + pub fn with_tx_prover_url(mut self, url: Option) -> Self { + self.tx_prover_url = url; + self + } + #[must_use] pub fn with_grpc_timeout(mut self, timeout: Duration) -> Self { self.grpc_timeout = timeout; @@ -83,42 +144,93 @@ impl FundingServiceConfig { self } + #[must_use] + pub fn with_tx_prover_timeout(mut self, timeout: Duration) -> Self { + self.tx_prover_timeout = timeout; + self + } + #[must_use] pub fn with_max_amount(mut self, max_amount: u64) -> Self { self.max_amount = max_amount; self } + #[must_use] + pub fn with_max_notes_per_tx(mut self, max_notes_per_tx: NonZeroUsize) -> Self { + self.max_notes_per_tx = max_notes_per_tx; + self + } + + #[must_use] + pub fn with_tx_expiration_delta(mut self, delta: NonZeroU16) -> Self { + self.tx_expiration_delta = delta; + self + } + + #[must_use] + pub fn with_poll_interval(mut self, interval: Duration) -> Self { + self.poll_interval = interval; + self + } + /// Connects to the node and builds the service. pub async fn build(self) -> anyhow::Result { + anyhow::ensure!( + self.max_notes_per_tx <= MAX_NOTES_PER_TX, + "--max-notes-per-tx must not exceed {MAX_NOTES_PER_TX} because the node limits how \ + many note IDs one lookup may hold", + ); + 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")?; + let node = RpcNodeClient::connect( + &self.rpc_url, + self.rpc_timeout, + self.validator_signing_public_keys, + ) + .await + .context("failed to connect to the node RPC API")?; let fee_parameters = node.genesis_header().fee_parameters().clone(); let fee_faucet_id = fee_parameters.fee_faucet_id(); + let verification_base_fee = fee_parameters.verification_base_fee(); // 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")?; + let prover = match self.tx_prover_url { + Some(url) => Prover::remote(url, self.tx_prover_timeout) + .context("failed to build the remote prover client")?, + None => Prover::local(), + }; + 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 + fee.verification_base_fee = verification_base_fee, + funding_service.max_amount = self.max_amount, + funding_service.max_notes_per_tx = self.max_notes_per_tx.get(), + funding_service.tx_expiration_delta = self.tx_expiration_delta.get(), + funding_service.remote_prover = matches!(prover, Prover::Remote(_)) ); Ok(FundingService { node, + prover, funder_key, fee_faucet_id, + verification_base_fee, + worker_config: WorkerConfig { + max_notes_per_tx: self.max_notes_per_tx, + expiration_delta: self.tx_expiration_delta, + poll_interval: self.poll_interval, + }, max_amount: self.max_amount, grpc_timeout: self.grpc_timeout, }) @@ -131,14 +243,17 @@ impl FundingServiceConfig { /// The funding service, ready to run. pub struct FundingService { node: RpcNodeClient, + prover: Prover, funder_key: FunderKey, fee_faucet_id: AccountId, + verification_base_fee: u32, + worker_config: WorkerConfig, max_amount: u64, grpc_timeout: Duration, } impl FundingService { - /// Runs the gRPC server and the status refresher until one of them stops. + /// Runs the gRPC server, the status refresher and the funding worker until one of them stops. pub async fn run( self, listener: TcpListener, @@ -153,10 +268,12 @@ impl FundingService { .await; let status = StatusSnapshot::new(self.funder_key.account_id(), self.max_amount); + let (requests, request_receiver) = + mpsc::channel(QUEUE_CAPACITY_PER_TX * self.worker_config.max_notes_per_tx.get()); let mut tasks = Tasks::new(); - let server = FundingRpcServer::new(status.clone(), self.grpc_timeout); + let server = FundingRpcServer::new(requests, status.clone(), self.grpc_timeout); let server_shutdown = shutdown.clone(); tasks.spawn("grpc-server", async move { server @@ -166,10 +283,10 @@ impl FundingService { }); let refresher = StatusRefresher::new( - self.node, + self.node.clone(), self.funder_key.account_id(), self.fee_faucet_id, - status, + status.clone(), STATUS_REFRESH_INTERVAL, ); let refresher_shutdown = shutdown.clone(); @@ -180,6 +297,25 @@ impl FundingService { .context("the funding service status refresher failed") }); + let funder = Funder::new( + self.node, + self.prover, + FunderSetup { + key: self.funder_key, + fee_faucet_id: self.fee_faucet_id, + verification_base_fee: self.verification_base_fee, + config: self.worker_config, + status, + }, + ); + let worker_shutdown = shutdown.clone(); + tasks.spawn("funder", async move { + funder + .run(request_receiver, worker_shutdown) + .await + .context("the funding worker failed") + }); + tasks .join_next_or_cancelled(shutdown) .await diff --git a/bin/funding-service/src/node.rs b/bin/funding-service/src/node.rs index 21d781a0ad..d4a22e1700 100644 --- a/bin/funding-service/src/node.rs +++ b/bin/funding-service/src/node.rs @@ -1,20 +1,29 @@ //! Node access. The RPC handling is copied from the network monitor. use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; 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::encryption::{ + TransactionInputsSealer, + TrustedTransactionEncryptionState, + verify_transaction_encryption_key, +}; +use miden_node_proto::generated::note::NoteIdList; use miden_node_proto::generated::rpc::{ AccountRequest as ProtoAccountRequest, BlockHeaderByNumberRequest, FinalityLevel, SyncChainMmrRequest, }; +use miden_node_proto::generated::transaction::ProvenTransaction as ProtoProvenTransaction; use miden_node_tracing::warn; -use miden_node_utils::retry::Retryable; +use miden_node_utils::retry::{self, Retryable}; use miden_protocol::Word; use miden_protocol::account::{ Account, @@ -27,9 +36,12 @@ use miden_protocol::account::{ }; use miden_protocol::block::account_tree::AccountWitness; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta, MmrPeaks, PartialMmr}; -use miden_protocol::transaction::PartialBlockchain; -use miden_protocol::utils::serde::Deserializable; +use miden_protocol::note::{NoteId, NoteInclusionProof}; +use miden_protocol::transaction::{PartialBlockchain, ProvenTransaction}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use tokio::sync::Mutex; use url::Url; use crate::COMPONENT; @@ -37,21 +49,42 @@ use crate::COMPONENT; // RPC NODE CLIENT // ================================================================================================ -/// Reads chain state from the node's RPC API. +/// Reads chain state from the node's RPC API and submits transactions to it. #[derive(Clone)] pub struct RpcNodeClient { rpc_client: RpcClient, genesis_header: BlockHeader, + trusted_validator_signing_keys: Arc<[ValidatorPublicKey]>, + sealer: Arc>>, } impl RpcNodeClient { - /// Connects to the node's RPC API. - pub async fn connect(rpc_url: &Url, timeout: Duration) -> Result { + /// Connects to the node's RPC API and verifies the attested transaction encryption key. + pub async fn connect( + rpc_url: &Url, + timeout: Duration, + trusted_validator_signing_keys: Vec, + ) -> Result { + anyhow::ensure!( + !trusted_validator_signing_keys.is_empty(), + "at least one trusted validator signing key is required to verify the transaction \ + encryption key", + ); + 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 }) + let client = Self { + rpc_client, + genesis_header, + trusted_validator_signing_keys: Arc::from(trusted_validator_signing_keys), + sealer: Arc::new(Mutex::new(None)), + }; + // Fetch and verify the encryption key eagerly so an untrusted key fails at startup. + client.sealer().await?; + + Ok(client) } /// The genesis block header, which commits to the chain's fee parameters. @@ -72,6 +105,159 @@ impl RpcNodeClient { ) -> Result<(Account, AccountWitness)> { fetch_public_account(&mut self.rpc_client.clone(), account_id, block_num).await } + + /// The chain tip which bounds whether a transaction can still be committed. + pub async fn chain_tip(&self) -> Result { + let status = self + .rpc_client + .clone() + .status(()) + .await + .context("failed to fetch the node status")? + .into_inner(); + + // The block producer's tip leads the store's tip, so it is the tighter bound. + let tip = status.block_producer.map_or(status.chain_tip, |producer| producer.chain_tip); + + Ok(tip.into()) + } + + /// The inclusion proofs of the notes which are committed, keyed by note ID. + pub async fn committed_notes( + &self, + note_ids: &[NoteId], + ) -> Result> { + let ids = note_ids.iter().map(|note_id| note_id.as_word().into()).collect(); + + let response = self + .rpc_client + .clone() + .get_notes_by_id(NoteIdList { ids }) + .await + .context("failed to fetch the funding notes from RPC")? + .into_inner(); + + response + .notes + .iter() + .map(|committed| { + let proof = committed + .inclusion_proof + .as_ref() + .context("committed note response is missing the inclusion proof")?; + <(NoteId, NoteInclusionProof)>::try_from(proof) + .context("failed to convert the note inclusion proof") + }) + .collect() + } + + /// Seals and submits one proven transaction, and returns the block it was accepted at. + pub async fn submit( + &self, + proven_tx: &ProvenTransaction, + transaction_inputs: &[u8], + ) -> Result { + let transaction = proven_tx.to_bytes(); + let tx_id = proven_tx.id(); + let stale_key = AtomicBool::new(false); + + let result = (|| { + let transaction = transaction.clone(); + async { + if stale_key.swap(false, Ordering::Relaxed) { + *self.sealer.lock().await = None; + } + + let sealed = self + .sealer() + .await? + .seal(tx_id, transaction_inputs) + .context("failed to seal the transaction inputs")?; + self.rpc_client + .clone() + .submit_proven_tx(ProtoProvenTransaction { + transaction, + sealed_transaction_inputs: Some(sealed), + }) + .await + .context("failed to submit the proven transaction to RPC") + } + }) + .retry(retry::constant(Duration::ZERO, Some(1))) + .when(|err: &anyhow::Error| { + err.downcast_ref::() + .is_some_and(|status| status.code() == tonic::Code::FailedPrecondition) + }) + .notify(|status: &anyhow::Error, _| { + stale_key.store(true, Ordering::Relaxed); + warn!( + status, + target: COMPONENT, + "Transaction inputs rejected as stale, refreshing the encryption key and retrying", + transaction.id = tx_id + ); + }) + .await; + + Ok(result?.into_inner().block_num.into()) + } + + /// The cached verified sealer. The attested key is fetched and checked on first use. + async fn sealer(&self) -> Result { + if let Some(sealer) = self.sealer.lock().await.clone() { + return Ok(sealer); + } + + let key = self + .rpc_client + .clone() + .get_transaction_encryption_key(()) + .await + .context("failed to fetch the transaction encryption key")? + .into_inner(); + let verified = verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new( + self.genesis_header.commitment(), + &self.trusted_validator_signing_keys, + ), + ) + .context("untrusted transaction encryption key")?; + let sealer = TransactionInputsSealer::new(verified); + + let mut cached = self.sealer.lock().await; + if let Some(sealer) = cached.clone() { + return Ok(sealer); + } + *cached = Some(sealer.clone()); + Ok(sealer) + } +} + +// TRANSIENT ERRORS +// ================================================================================================ + +/// Returns `true` for gRPC status codes that indicate a transient transport- or server-side problem +/// worth retrying. Content-rejection codes (`InvalidArgument`, `FailedPrecondition`, ...) reflect +/// the request itself and are not retried. +pub fn is_transient_status(status: &tonic::Status) -> bool { + matches!( + status.code(), + tonic::Code::Unavailable + | tonic::Code::DeadlineExceeded + | tonic::Code::Cancelled + | tonic::Code::Aborted + | tonic::Code::Unknown + | tonic::Code::Internal + | tonic::Code::ResourceExhausted, + ) +} + +/// Returns `true` when the error chain holds a transient gRPC status. +pub fn is_transient_error(err: &anyhow::Error) -> bool { + err.chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(is_transient_status) } // RPC HELPERS diff --git a/bin/funding-service/src/prover.rs b/bin/funding-service/src/prover.rs new file mode 100644 index 0000000000..7eb3f51aeb --- /dev/null +++ b/bin/funding-service/src/prover.rs @@ -0,0 +1,130 @@ +//! Transaction proving. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use miden_node_proto::clients::{Builder, RemoteProverClient}; +use miden_node_proto::generated::remote_prover::{ProofRequest, ProofType}; +use miden_node_tracing::spawn::spawn_blocking_in_current_span; +use miden_node_tracing::{ErrorReport, warn}; +use miden_protocol::transaction::{ExecutedTransaction, ProvenTransaction}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_tx::LocalTransactionProver; +use url::Url; + +use crate::COMPONENT; + +// PROVER +// ================================================================================================ + +/// The prover the service is configured with. +#[derive(Clone)] +pub enum Prover { + /// Proves in this process. + Local(LocalProver), + /// Proves through a remote prover, and falls back to local proving on failure. + Remote(Box), +} + +impl Prover { + /// Builds a local prover. + pub fn local() -> Self { + Self::Local(LocalProver) + } + + /// Builds a prover which uses the remote prover at `url`, with local proving as a fallback. + pub fn remote(url: Url, timeout: Duration) -> Result { + Ok(Self::Remote(Box::new(RemoteProver::new(url, timeout)?))) + } + + /// Proves one executed transaction. + pub async fn prove(&self, executed_tx: ExecutedTransaction) -> Result { + match self { + Self::Local(prover) => prover.prove(executed_tx).await, + Self::Remote(prover) => prover.prove(executed_tx).await, + } + } +} + +// LOCAL PROVER +// ================================================================================================ + +/// Proves transactions in this process. +#[derive(Clone, Copy)] +pub struct LocalProver; + +impl LocalProver { + /// Proves one executed transaction in this process. + pub async fn prove(&self, executed_tx: ExecutedTransaction) -> Result { + // Proving is CPU bound and would block the runtime's worker thread. + spawn_blocking_in_current_span(move || { + LocalTransactionProver::default() + .prove(executed_tx) + .context("failed to prove the transaction locally") + }) + .await + .context("the local proving task failed")? + } +} + +// REMOTE PROVER +// ================================================================================================ + +/// Proves transactions through the remote prover service, with local proving as a fallback. +#[derive(Clone)] +pub struct RemoteProver { + client: RemoteProverClient, + fallback: LocalProver, +} + +impl RemoteProver { + /// Creates a prover with a lazy connection to the given gRPC endpoint. + pub fn new(url: Url, timeout: Duration) -> Result { + let client = Builder::new(url) + .with_tls() + .context("failed to configure TLS for the remote prover client")? + .with_timeout(timeout) + .without_metadata_version() + .without_metadata_genesis() + .without_auth_header() + .with_otel_context_injection() + .connect_lazy::(); + + Ok(Self { client, fallback: LocalProver }) + } + + /// Proves one transaction on the remote prover. + async fn prove_remotely(&self, executed_tx: &ExecutedTransaction) -> Result { + let request = tonic::Request::new(ProofRequest { + proof_type: ProofType::Transaction.into(), + payload: executed_tx.tx_inputs().to_bytes(), + }); + + let response = self + .client + .clone() + .prove(request) + .await + .context("the remote prover rejected the transaction")?; + + ProvenTransaction::read_from_bytes(&response.into_inner().payload) + .context("failed to deserialize the response of the remote transaction prover") + } + + /// Proves one executed transaction, falling back to local proving. + pub async fn prove(&self, executed_tx: ExecutedTransaction) -> Result { + match self.prove_remotely(&executed_tx).await { + Ok(proven_tx) => Ok(proven_tx), + Err(err) => { + warn!( + &err, + target: COMPONENT, + "Remote proving failed, proving locally instead" + ); + self.fallback.prove(executed_tx).await.with_context(|| { + format!("local proving after a remote prover failure: {}", err.as_report()) + }) + }, + } + } +} diff --git a/bin/funding-service/src/server.rs b/bin/funding-service/src/server.rs index 31f5e2e79d..53f64eb6a4 100644 --- a/bin/funding-service/src/server.rs +++ b/bin/funding-service/src/server.rs @@ -10,6 +10,7 @@ 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::sync::mpsc; use tokio_stream::wrappers::TcpListenerStream; use tonic_health::pb::health_server::{Health, HealthServer}; use tonic_reflection::server; @@ -18,7 +19,9 @@ use tower_http::trace::TraceLayer; use crate::LOG_TARGET; use crate::status::StatusSnapshot; +use crate::worker::FundingRequest; +mod request_funds; mod status; // FUNDING SERVICE RPC SERVER @@ -26,15 +29,21 @@ mod status; /// The gRPC service of the funding service. /// -/// The handlers do no chain work: `Status` reads the values the status refresher publishes. +/// The handlers do no chain work: `RequestFunds` hands the request to the worker and waits for its +/// answer, and `Status` reads the values the worker publishes. pub struct FundingRpcServer { + requests: mpsc::Sender, status: StatusSnapshot, request_timeout: Duration, } impl FundingRpcServer { - pub(crate) fn new(status: StatusSnapshot, request_timeout: Duration) -> Self { - Self { status, request_timeout } + pub(crate) fn new( + requests: mpsc::Sender, + status: StatusSnapshot, + request_timeout: Duration, + ) -> Self { + Self { requests, status, request_timeout } } /// Starts the gRPC server on the given listener. @@ -97,10 +106,14 @@ pub(crate) mod tests { use super::*; - /// Builds a server for the handler tests. - pub(crate) fn test_server(max_amount: u64) -> FundingRpcServer { + /// Builds a server whose worker channel is held by the caller, so a test can assert on what the + /// handler queued without running a worker. + pub(crate) fn test_server( + max_amount: u64, + ) -> (FundingRpcServer, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(4); let status = StatusSnapshot::new(FungibleAsset::mock_issuer(), max_amount); - FundingRpcServer::new(status, Duration::from_secs(1)) + (FundingRpcServer::new(tx, status, Duration::from_secs(1)), rx) } } diff --git a/bin/funding-service/src/server/request_funds.rs b/bin/funding-service/src/server/request_funds.rs new file mode 100644 index 0000000000..36f87624f8 --- /dev/null +++ b/bin/funding-service/src/server/request_funds.rs @@ -0,0 +1,183 @@ +use miden_node_proto::domain::funding::RequestFunds as RequestFundsInput; +use miden_node_proto::generated as proto; +use miden_node_proto::server::funding_service_api; +use tokio::sync::{mpsc, oneshot}; + +use super::FundingRpcServer; +use crate::COMPONENT; +use crate::error::RequestFundsError; +use crate::worker::{FundedNote, FundingRequest}; + +#[tonic::async_trait] +impl funding_service_api::RequestFunds for FundingRpcServer { + type Input = RequestFundsInput; + type Output = FundedNote; + + fn decode(request: proto::funding_service::RequestFundsRequest) -> tonic::Result { + RequestFundsInput::try_from(request).map_err(Into::into) + } + + #[miden_node_tracing::miden_instrument( + target = COMPONENT, + name = "request_funds", + fields ( + account.id = request.account_id, + asset.amount = request.amount, + ), + err, + )] + async fn handle( + &self, + request: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + self.validate_amount(request.amount)?; + + let (reply, response) = oneshot::channel(); + let queued = FundingRequest { + target: request.account_id, + amount: request.amount, + reply, + }; + + self.requests.try_send(queued).map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => RequestFundsError::Busy, + mpsc::error::TrySendError::Closed(_) => { + RequestFundsError::NotReady("the funding worker stopped") + }, + })?; + + // The worker answers once the note is committed, which takes at least one block interval. A + // dropped sender means the worker stopped without answering. + response + .await + .map_err(|_| RequestFundsError::NotReady("the funding worker stopped"))? + .map_err(Into::into) + } + + fn encode(funded: Self::Output) -> tonic::Result { + let FundedNote { note, inclusion_proof, transaction_id } = funded; + + Ok(proto::funding_service::RequestFundsResponse { + note: Some((note, inclusion_proof).into()), + transaction_id: Some(transaction_id.into()), + }) + } +} + +impl FundingRpcServer { + /// Checks the requested amount against the configured maximum. + fn validate_amount(&self, amount: u64) -> Result<(), RequestFundsError> { + if amount == 0 { + return Err(RequestFundsError::InvalidAmount); + } + + let maximum = self.status.max_amount(); + if amount > maximum { + return Err(RequestFundsError::AmountExceedsMaximum { requested: amount, maximum }); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use miden_node_proto::generated::account::AccountId; + use miden_node_proto::generated::funding_service::RequestFundsRequest; + use miden_protocol::Word; + use miden_protocol::asset::FungibleAsset; + use miden_protocol::crypto::merkle::{MerklePath, SparseMerklePath}; + use miden_protocol::note::{NoteInclusionProof, NoteType}; + use miden_standards::note::P2idNote; + + use super::*; + use crate::server::tests::test_server; + + const MAX_AMOUNT: u64 = 1_000; + + #[test] + fn decode_rejects_a_missing_account_id() { + let err = + ::decode(RequestFundsRequest { + account_id: None, + amount: 1, + }) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + + #[test] + fn decode_rejects_a_malformed_account_id() { + let err = + ::decode(RequestFundsRequest { + account_id: Some(AccountId { id: vec![1, 2, 3] }), + amount: 1, + }) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + + #[test] + fn a_zero_amount_is_rejected() { + let (server, _rx) = test_server(MAX_AMOUNT); + let err = server.validate_amount(0).unwrap_err(); + assert_eq!(tonic::Status::from(err).details(), [1]); + } + + #[test] + fn an_amount_above_the_maximum_is_rejected() { + let (server, _rx) = test_server(MAX_AMOUNT); + + server.validate_amount(MAX_AMOUNT).expect("the maximum itself is allowed"); + let err = server.validate_amount(MAX_AMOUNT + 1).unwrap_err(); + assert_eq!(tonic::Status::from(err).details(), [2]); + } + + /// The response must carry the note in full: the node does not store the details of a private + /// note, so the requester cannot fetch them anywhere else. + #[test] + fn encode_round_trips_a_private_note_with_its_proof() { + let faucet_id = FungibleAsset::mock_issuer(); + let note: miden_protocol::note::Note = P2idNote::builder() + .sender(faucet_id) + .target(faucet_id) + .serial_number(Word::from([3u32; 4])) + .note_type(NoteType::Private) + .asset(FungibleAsset::new(faucet_id, 42).unwrap()) + .build() + .unwrap() + .into(); + let proof = NoteInclusionProof::new( + 7.into(), + 3, + SparseMerklePath::try_from(MerklePath::new(vec![Word::from([1u32; 4])])).unwrap(), + ) + .unwrap(); + let transaction_id = + miden_protocol::transaction::TransactionId::from_raw(Word::from([9u32; 4])); + + let response = + ::encode(FundedNote { + note: note.clone(), + inclusion_proof: proof.clone(), + transaction_id, + }) + .unwrap(); + + let committed = response.note.expect("the response holds the note"); + let (decoded_note, decoded_proof) = + <(miden_protocol::note::Note, NoteInclusionProof)>::try_from(committed).unwrap(); + + assert_eq!(decoded_note.id(), note.id()); + assert_eq!(decoded_note.assets(), note.assets()); + assert_eq!(decoded_note.recipient(), note.recipient()); + assert_eq!(decoded_proof.location(), proof.location()); + assert_eq!( + miden_protocol::transaction::TransactionId::try_from(response.transaction_id.unwrap()) + .unwrap(), + transaction_id + ); + } +} diff --git a/bin/funding-service/src/server/status.rs b/bin/funding-service/src/server/status.rs index f605a92bc5..561f50e50f 100644 --- a/bin/funding-service/src/server/status.rs +++ b/bin/funding-service/src/server/status.rs @@ -47,7 +47,7 @@ mod tests { #[tokio::test] async fn status_reports_the_configured_account_and_the_published_balance() { - let server = test_server(500); + let (server, _rx) = test_server(500); server.status.update(1_234, 42.into()); let status = funding_service_api::Status::handle( @@ -74,7 +74,7 @@ mod tests { /// account it is waiting on. #[tokio::test] async fn status_is_served_while_the_service_is_not_ready() { - let server = test_server(500); + let (server, _rx) = test_server(500); funding_service_api::Status::handle( &server, diff --git a/bin/funding-service/src/test_utils.rs b/bin/funding-service/src/test_utils.rs new file mode 100644 index 0000000000..d2b3ba47f9 --- /dev/null +++ b/bin/funding-service/src/test_utils.rs @@ -0,0 +1,150 @@ +//! Test support: a funding account and a fee faucet shaped like the ones genesis creates, on a +//! [`MockChain`]. + +use std::sync::Arc; + +use anyhow::Result; +use miden_protocol::account::auth::{AuthScheme, AuthSecretKey}; +use miden_protocol::account::{Account, AccountFile, AccountId, AccountType}; +use miden_protocol::asset::{AssetAmount, FungibleAsset, TokenSymbol}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; +use miden_protocol::{Felt, ONE}; +use miden_standards::account::access::AccessControl; +use miden_standards::account::auth::Approver; +use miden_standards::account::faucets::{ + FungibleFaucet as FungibleFaucetComponent, + TokenName, + create_network_fungible_faucet, +}; +use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; +use miden_standards::account::policies::{ + BurnPolicy, + MintPolicy, + TokenPolicyManager, + TransferPolicy, +}; +use miden_standards::account::wallets::create_basic_wallet; +use miden_standards::note::{BurnNote, MintNote}; +use miden_testing::MockChain; +use rand::{RngExt, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use tokio::sync::Mutex; + +use crate::account::FunderKey; + +/// The base fee used by the tests which exercise the fee path. +pub const TEST_BASE_FEE: u32 = 500; + +// ACCOUNTS +// ================================================================================================ + +/// Builds a public wallet the way the genesis configuration does, prefunded with `balance` of the +/// native asset. +pub fn genesis_style_wallet( + fee_faucet_id: AccountId, + balance: u64, + seed: [u8; 32], +) -> Result<(Account, SecretKey)> { + let mut rng = ChaCha20Rng::from_seed(seed); + 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 wallet = create_basic_wallet(init_seed, auth, AccountType::Public)?; + if balance > 0 { + wallet + .vault_mut() + .add_asset(FungibleAsset::new(fee_faucet_id, balance)?.into())?; + } + wallet.set_nonce(ONE)?; + + Ok((wallet, secret_key)) +} + +/// Builds a network fungible faucet shaped like the genesis native faucet. +/// +/// Its asset triggers the kernel's asset callbacks, which the asset of +/// `FungibleAsset::mock_issuer()` does not, so the fee path is only covered with this faucet. +pub fn genesis_style_native_faucet(operator: AccountId, seed: [u8; 32]) -> Result { + let faucet_component = FungibleFaucetComponent::builder() + .name(TokenName::new("MIDEN").expect("valid token name")) + .symbol(TokenSymbol::new("MIDEN").expect("valid token symbol")) + .decimals(6) + .max_supply(AssetAmount::new(100_000_000_000_000_000).expect("valid supply")) + .build()?; + let policies = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::allow_all()) + .active_send_policy(TransferPolicy::allow_all()) + .active_receive_policy(TransferPolicy::allow_all()) + .build(); + let fee_policy = BasicConstantFeePolicy::new() + .with_fees([ + (MintNote::script_root(), AssetAmount::ZERO), + (BurnNote::script_root(), AssetAmount::ZERO), + ]) + .into(); + let fee_policy_manager = FeePolicyManager::builder() + .fee_faucet_id(operator) + .active_fee_policy(fee_policy) + .build(); + + let mut faucet = create_network_fungible_faucet( + seed, + faucet_component, + AccessControl::Ownable2Step { owner: operator }, + policies, + fee_policy_manager, + )?; + // Mark the faucet as deployed, the same way genesis does, so the mock chain accepts it. + faucet.set_nonce(Felt::ONE)?; + + Ok(faucet) +} + +/// Writes an account file the way `miden-validator genesis` does, and loads it back. +pub fn funder_key_from(account: &Account, secret_key: &SecretKey) -> Result { + let dir = tempfile::tempdir()?; + let path = dir.path().join("funding_service.mac"); + AccountFile::new(account.clone(), vec![AuthSecretKey::Falcon512Poseidon2(secret_key.clone())]) + .write(&path)?; + FunderKey::load(&path) +} + +// MOCK CHAIN FIXTURE +// ================================================================================================ + +/// A mock chain which holds a prefunded funding wallet and the native faucet. +pub struct Fixture { + pub chain: Arc>, + pub funder: Account, + pub funder_key: FunderKey, + pub fee_faucet_id: AccountId, +} + +impl Fixture { + /// Builds a chain which charges `base_fee` and holds a funding wallet with `balance`. + pub fn new(balance: u64, base_fee: u32) -> Result { + // The faucet's owner does not matter for these tests, so the wallet built first stands in. + let (owner, _) = genesis_style_wallet(FungibleAsset::mock_issuer(), 0, [1; 32])?; + let faucet = genesis_style_native_faucet(owner.id(), [7; 32])?; + let fee_faucet_id = faucet.id(); + + let (funder, secret_key) = genesis_style_wallet(fee_faucet_id, balance, [9; 32])?; + let funder_key = funder_key_from(&funder, &secret_key)?; + + let mut builder = MockChain::builder() + .fee_faucet_id(fee_faucet_id) + .verification_base_fee(base_fee); + builder.add_account(faucet)?; + builder.add_account(funder.clone())?; + let chain = builder.build()?; + + Ok(Self { + chain: Arc::new(Mutex::new(chain)), + funder, + funder_key, + fee_faucet_id, + }) + } +} diff --git a/bin/funding-service/src/tx.rs b/bin/funding-service/src/tx.rs new file mode 100644 index 0000000000..769eb18ad8 --- /dev/null +++ b/bin/funding-service/src/tx.rs @@ -0,0 +1,334 @@ +//! Building and execution of the funding transaction. + +use std::num::NonZeroU16; + +use anyhow::{Context, Result}; +use miden_node_tracing::spawn::spawn_blocking_in_current_span; +use miden_protocol::account::auth::AuthSecretKey; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::asset::FungibleAsset; +use miden_protocol::block::BlockHeader; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; +use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; +use miden_protocol::note::{Note, NoteType, PartialNote}; +use miden_protocol::transaction::{ + ExecutedTransaction, + InputNotes, + PartialBlockchain, + RawOutputNote, + TransactionArgs, +}; +use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info}; +use miden_standards::note::P2idNote; +use miden_standards::tx_script::SendNotesTransactionScript; +use miden_tx::TransactionExecutor; +use miden_tx::auth::BasicAuthenticator; + +use crate::data_store::InMemoryDataStore; + +// NOTE CREATION +// ================================================================================================ + +/// Builds one private P2ID note per target which holds `amount` base units of the fee asset. +pub fn build_funding_notes( + sender: AccountId, + fee_faucet_id: AccountId, + targets: &[(AccountId, u64)], + rng: &mut RandomCoin, +) -> Result> { + targets + .iter() + .map(|&(target, amount)| { + let asset = FungibleAsset::new(fee_faucet_id, amount) + .context("failed to build the funding asset")?; + let note: Note = P2idNote::builder() + .sender(sender) + .target(target) + .asset(asset) + .note_type(NoteType::Private) + .generate_serial_number(rng) + .build() + .context("failed to build the funding note")? + .into(); + Ok(note) + }) + .collect() +} + +// TRANSACTION EXECUTION +// ================================================================================================ + +/// Everything one funding transaction needs besides the notes it creates. +pub struct ExecutionInputs { + /// The funding account, in the state committed in `reference_header`. + pub funder: Account, + /// The signing key of the funding account. + pub secret_key: SecretKey, + /// The faucet which issues the fee asset, together with its account-tree witness. + pub fee_faucet: (Account, AccountWitness), + /// The reference block of the transaction. + pub reference_header: BlockHeader, + /// A partial blockchain which proves the reference block. + pub blockchain: PartialBlockchain, + /// How many blocks after the reference block the transaction expires. + pub expiration_delta: NonZeroU16, +} + +/// Executes the transaction which creates `notes`. +pub async fn execute( + inputs: ExecutionInputs, + notes: Vec, + rng: &mut RandomCoin, +) -> Result { + let ExecutionInputs { + funder, + secret_key, + fee_faucet, + reference_header, + blockchain, + expiration_delta, + } = inputs; + + let fee_faucet_id = reference_header.fee_parameters().fee_faucet_id(); + let account_id = funder.id(); + let reference_block = reference_header.block_num(); + let expected_expiration = reference_block + u32::from(expiration_delta.get()); + let tx_args = build_tx_args(&funder, fee_faucet_id, ¬es, expiration_delta, rng)?; + + let executed_tx = spawn_blocking_in_current_span(move || { + let mut data_store = InMemoryDataStore::new(reference_header, blockchain); + data_store.add_account(funder); + let (faucet_account, faucet_witness) = fee_faucet; + data_store.add_foreign_account(faucet_account, faucet_witness); + + let authenticator = + BasicAuthenticator::new(&[AuthSecretKey::Falcon512Poseidon2(secret_key)]); + let executor = TransactionExecutor::new(&data_store).with_authenticator(&authenticator); + + futures::executor::block_on(executor.execute_transaction( + account_id, + reference_block, + InputNotes::default(), + tx_args, + )) + .context("failed to execute the funding transaction") + }) + .await + .context("the funding transaction task failed")??; + + // The notes and the expiration are what the caller promises to the requester, so a mismatch + // must fail here instead of after the transaction is submitted. + let output_note_ids: Vec<_> = + executed_tx.output_notes().iter().map(RawOutputNote::id).collect(); + for note in ¬es { + anyhow::ensure!( + output_note_ids.contains(¬e.id()), + "the executed transaction does not create note {}", + note.id(), + ); + } + anyhow::ensure!( + executed_tx.expiration_block_num() == expected_expiration, + "the executed transaction expires at block {} instead of {expected_expiration}", + executed_tx.expiration_block_num(), + ); + + Ok(executed_tx) +} + +/// Builds the transaction arguments which emit `notes` and pay the fee from the funder's vault. +fn build_tx_args( + funder: &Account, + fee_faucet_id: AccountId, + notes: &[Note], + expiration_delta: NonZeroU16, + rng: &mut RandomCoin, +) -> Result { + let partial_notes: Vec = notes.iter().map(|note| note.clone().into()).collect(); + let code_interface = funder.code_interface(); + let script = SendNotesTransactionScript::with_expiration_delta( + &code_interface, + &partial_notes, + expiration_delta, + ) + .context("failed to build the send-notes transaction script")?; + + let mut tx_args = TransactionArgs::default() + .with_tx_script_and_args(script.tx_script().clone(), script.tx_script_args()); + + // A private note's recipient is not derivable from the note ID, so the executor needs it to + // build the output note. + for note in notes { + tx_args.add_output_note_recipient(Box::new(note.recipient().clone())); + } + + let (auth_args, conversion_info_preimage) = + commit_fee_conversion_info(FeeConversionInfo::one_to_one(fee_faucet_id), rng.draw_word()); + tx_args = tx_args.with_auth_args(auth_args); + tx_args.extend_advice_map([(auth_args, conversion_info_preimage)]); + + Ok(tx_args) +} + +#[cfg(test)] +mod tests { + use miden_protocol::Word; + use miden_protocol::asset::AssetId; + use miden_standards::note::TxFeeNote; + + use super::*; + use crate::test_utils::{ + Fixture, + TEST_BASE_FEE, + genesis_style_native_faucet, + genesis_style_wallet, + }; + + const BALANCE: u64 = 1_000_000; + + /// Builds the execution inputs for the fixture's funding account at the chain tip. + async fn execution_inputs( + fixture: &Fixture, + expiration_delta: NonZeroU16, + ) -> Result { + let chain = fixture.chain.lock().await; + let reference_header = chain.latest_block_header(); + let blockchain = chain.latest_partial_blockchain(); + let funder = chain.committed_account(fixture.funder.id())?.clone(); + let faucet = chain.committed_account(fixture.fee_faucet_id)?.clone(); + let witness = chain + .account_witnesses([fixture.fee_faucet_id]) + .remove(&fixture.fee_faucet_id) + .context("a witness was requested for the faucet")?; + + Ok(ExecutionInputs { + funder, + secret_key: fixture.funder_key.secret_key().clone(), + fee_faucet: (faucet, witness), + reference_header, + blockchain, + expiration_delta, + }) + } + + /// One transaction must create every requested note, pay its own fee, and expire at the + /// configured delta. + #[tokio::test] + async fn one_transaction_creates_every_note_and_pays_the_fee() -> Result<()> { + let expiration_delta = NonZeroU16::new(20).unwrap(); + let fixture = Fixture::new(BALANCE, TEST_BASE_FEE)?; + let mut rng = RandomCoin::new(Word::from([11u32; 4])); + + let targets: Vec<(AccountId, u64)> = (0u8..3) + .map(|index| { + let (account, _) = + genesis_style_wallet(fixture.fee_faucet_id, 0, [index + 20; 32])?; + Ok((account.id(), 100 * (u64::from(index) + 1))) + }) + .collect::>()?; + let requested: u64 = targets.iter().map(|(_, amount)| amount).sum(); + + let notes = + build_funding_notes(fixture.funder.id(), fixture.fee_faucet_id, &targets, &mut rng)?; + let reference_block = { + let chain = fixture.chain.lock().await; + chain.latest_block_header().block_num() + }; + + let inputs = execution_inputs(&fixture, expiration_delta).await?; + let executed_tx = execute(inputs, notes.clone(), &mut rng).await?; + + // Three funding notes plus the fee note the kernel emits. + assert_eq!(executed_tx.output_notes().num_notes(), 4); + let fee_notes = executed_tx + .output_notes() + .iter() + .filter(|note| { + note.recipient() + .is_some_and(|recipient| recipient.script().root() == TxFeeNote::script_root()) + }) + .count(); + assert_eq!(fee_notes, 1, "the transaction must emit exactly one fee note"); + + assert_eq!( + executed_tx.expiration_block_num(), + reference_block + u32::from(expiration_delta.get()) + ); + + // The vault pays both the notes and the fee, so it loses more than the requested amount. + // The funding account exists on chain, so its patch is a delta and is applied. + let mut updated = fixture.funder.clone(); + updated.apply_patch(executed_tx.account_patch())?; + let remaining = updated + .vault() + .get_balance(AssetId::new_fungible(fixture.fee_faucet_id))? + .as_u64(); + assert!( + remaining < BALANCE - requested, + "the fee must be paid on top of the notes: {remaining} vs {}", + BALANCE - requested + ); + + // Every note must be a private note the requester can consume. + for note in ¬es { + assert_eq!(note.metadata().note_type(), NoteType::Private); + assert_eq!(note.assets().num_assets(), 1); + } + + Ok(()) + } + + /// The native asset is callback-enabled, so moving it loads the issuing faucet in a foreign + /// context. Without the faucet the kernel cannot start that context. + #[tokio::test] + async fn execution_requires_the_fee_faucet_as_a_foreign_account() -> Result<()> { + let fixture = Fixture::new(BALANCE, TEST_BASE_FEE)?; + let mut rng = RandomCoin::new(Word::from([17u32; 4])); + let (target, _) = genesis_style_wallet(fixture.fee_faucet_id, 0, [41; 32])?; + + let notes = build_funding_notes( + fixture.funder.id(), + fixture.fee_faucet_id, + &[(target.id(), 100)], + &mut rng, + )?; + + // Substitute an unrelated account for the faucet, which leaves the real faucet absent from + // the data store. + let mut inputs = execution_inputs(&fixture, NonZeroU16::new(20).unwrap()).await?; + let (unrelated, _) = genesis_style_wallet(fixture.fee_faucet_id, 0, [51; 32])?; + let unrelated_witness = { + let chain = fixture.chain.lock().await; + chain + .account_witnesses([fixture.funder.id()]) + .remove(&fixture.funder.id()) + .context("a witness was requested")? + }; + inputs.fee_faucet = (unrelated, unrelated_witness); + + let err = execute(inputs, notes, &mut rng) + .await + .expect_err("moving a callback-enabled asset requires the issuing faucet"); + assert!( + format!("{err:#}").contains("account"), + "expected an account failure, got: {err:#}" + ); + + Ok(()) + } + + /// A funding amount which the asset type cannot express must fail while the notes are built. + #[tokio::test] + async fn an_invalid_amount_fails_while_building_the_notes() -> Result<()> { + let mut rng = RandomCoin::new(Word::from([23u32; 4])); + let (owner, _) = genesis_style_wallet(FungibleAsset::mock_issuer(), 0, [71; 32])?; + let faucet = genesis_style_native_faucet(owner.id(), [77; 32])?; + + let err = build_funding_notes(owner.id(), faucet.id(), &[(owner.id(), u64::MAX)], &mut rng) + .expect_err("an amount above the asset maximum must be rejected"); + assert!(format!("{err:#}").contains("asset"), "unexpected error: {err:#}"); + + Ok(()) + } +} diff --git a/bin/funding-service/src/worker.rs b/bin/funding-service/src/worker.rs new file mode 100644 index 0000000000..8da4c06164 --- /dev/null +++ b/bin/funding-service/src/worker.rs @@ -0,0 +1,576 @@ +//! The funding worker. +//! +//! One task owns the funding account and turns queued requests into transactions. The account's +//! nonce serialises its transactions, so the worker keeps a single transaction in flight and +//! coalesces every request which arrives while one is in progress into the next transaction. + +use std::collections::HashMap; +use std::num::{NonZeroU16, NonZeroUsize}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use miden_node_tracing::{ErrorReport, error, info, warn}; +use miden_node_utils::retry::{self, Retryable}; +use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::asset::AssetId; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::rand::RandomCoin; +use miden_protocol::note::{Note, NoteId, NoteInclusionProof}; +use miden_protocol::transaction::{PartialBlockchain, ProvenTransaction, TransactionId}; +use miden_protocol::utils::serde::Serializable; +use tokio::sync::{mpsc, oneshot}; + +use crate::account::FunderKey; +use crate::error::RequestFundsError; +use crate::inclusion::{Inclusion, await_inclusion}; +use crate::node::{RpcNodeClient, is_transient_error}; +use crate::prover::Prover; +use crate::status::StatusSnapshot; +use crate::tx::{self, ExecutionInputs}; +use crate::{COMPONENT, LOG_TARGET}; + +// CONSTANTS +// ================================================================================================ + +/// How long the worker waits for more requests after the first one arrives. +const BATCH_LINGER: Duration = Duration::from_millis(250); + +/// Bounds on the retries of a node request inside one batch. +const NODE_RETRY_MIN_DELAY: Duration = Duration::from_millis(100); +const NODE_RETRY_MAX_DELAY: Duration = Duration::from_secs(5); +const NODE_RETRY_MAX_TIMES: usize = 5; + +/// Upper bound on the fee formula's cycle multiplier: the kernel charges `verification_base_fee * +/// (ilog2(total_cycles) + 1)` with cycles capped at `2^29`. +const MAX_FEE_VERIFICATION_CYCLES: u64 = 30; + +// REQUEST AND RESPONSE +// ================================================================================================ + +/// One queued funding request. +pub struct FundingRequest { + /// The account which the note targets. + pub target: AccountId, + /// The amount of the native asset, in base units. + pub amount: u64, + /// Where the outcome is sent. + pub reply: oneshot::Sender>, +} + +/// A committed funding note. +#[derive(Debug, Clone)] +pub struct FundedNote { + /// The note in full. The node does not store the details of a private note, so this is the only + /// copy. + pub note: Note, + /// Proof that the note is in a block. + pub inclusion_proof: NoteInclusionProof, + /// The transaction which created the note. + pub transaction_id: TransactionId, +} + +// CONFIGURATION +// ================================================================================================ + +/// The limits the worker applies to every batch. +#[derive(Debug, Clone, Copy)] +pub struct WorkerConfig { + /// The largest number of notes one transaction creates. + pub max_notes_per_tx: NonZeroUsize, + /// How many blocks after its reference block a funding transaction expires. + pub expiration_delta: NonZeroU16, + /// How often the worker asks the node whether the notes are committed. + pub poll_interval: Duration, +} + +// FUNDER +// ================================================================================================ + +/// What the worker needs besides its node and prover. +pub struct FunderSetup { + /// The funding account's ID and signing key. + pub key: FunderKey, + /// The faucet which issues the native asset. + pub fee_faucet_id: AccountId, + /// The chain's verification base fee. Zero on a chain which does not charge fees. + pub verification_base_fee: u32, + /// The limits applied to every batch. + pub config: WorkerConfig, + /// Where the worker publishes the funding account's balance. + pub status: StatusSnapshot, +} + +/// The chain state one batch is built against, read at one reference block. +struct BatchInputs { + reference_header: BlockHeader, + blockchain: PartialBlockchain, + funder: Account, + fee_faucet: (Account, AccountWitness), +} + +/// One batch's transaction, proven and ready to submit. +struct PreparedBatch { + proven_tx: ProvenTransaction, + /// The encoded transaction inputs, which the submission seals. + transaction_inputs: Vec, + /// The notes the transaction creates, in the order of the requests they answer. + notes: Vec, +} + +/// Turns funding requests into transactions. +pub struct Funder { + node: RpcNodeClient, + prover: Prover, + setup: FunderSetup, + rng: RandomCoin, + account_checked: bool, +} + +impl Funder { + /// Creates a worker for the given funding account. + pub fn new(node: RpcNodeClient, prover: Prover, setup: FunderSetup) -> Self { + Self { + node, + prover, + setup, + rng: RandomCoin::new(Word::from(rand::random::<[u32; 4]>())), + account_checked: false, + } + } + + /// Runs the worker until the request channel closes or the service shuts down. + pub async fn run( + mut self, + mut requests: mpsc::Receiver, + shutdown: CancellationToken, + ) -> Result<()> { + loop { + let first = tokio::select! { + () = shutdown.cancelled() => break, + request = requests.recv() => match request { + Some(request) => request, + None => break, + }, + }; + + // Collect the requests which arrive while this one waits, so they share a transaction. + tokio::select! { + () = tokio::time::sleep(BATCH_LINGER) => {}, + () = shutdown.cancelled() => {}, + } + + let mut batch = vec![first]; + while batch.len() < self.setup.config.max_notes_per_tx.get() { + match requests.try_recv() { + Ok(request) => batch.push(request), + Err(_) => break, + } + } + + // A requester which gave up must not be funded: the note would be created but never + // delivered, and its funds would be stranded in a private note nobody holds. + batch.retain(|request| !request.reply.is_closed()); + if batch.is_empty() { + continue; + } + + if shutdown.is_cancelled() { + fail_all(batch, || RequestFundsError::NotReady("the service is shutting down")); + break; + } + + self.process_batch(batch, &shutdown).await; + } + + // Nothing else will read the queue, so waiting requesters are told to retry elsewhere. + requests.close(); + while let Ok(request) = requests.try_recv() { + let _ = request + .reply + .send(Err(RequestFundsError::NotReady("the service is shutting down"))); + } + + Ok(()) + } + + /// Reads the funding account at the chain tip and publishes its balance. + async fn refresh_status(&mut self) -> Result<()> { + let (reference_header, _blockchain) = self.node.tip_chain_state().await?; + let block_num = reference_header.block_num(); + let (funder, _witness) = self.node.public_account(self.account_id(), block_num).await?; + self.check_account_code(&funder)?; + self.setup.status.update(self.fee_balance(&funder), block_num); + Ok(()) + } + + /// Runs one batch and replies to every requester in it. + async fn process_batch( + &mut self, + mut batch: Vec, + shutdown: &CancellationToken, + ) { + match self.run_batch(&mut batch, shutdown, true).await { + Ok(()) => { + if let Err(err) = self.refresh_status().await { + warn!( + &err, + target: LOG_TARGET, + "Failed to read the funding account after a funding transaction" + ); + } + }, + Err(failure) => fail_all(batch, || failure.to_error()), + } + } + + /// Creates, submits and awaits one funding transaction. + async fn run_batch( + &mut self, + batch: &mut Vec, + shutdown: &CancellationToken, + allow_retry: bool, + ) -> Result<(), BatchFailure> { + let BatchInputs { + reference_header, + blockchain, + funder, + fee_faucet, + } = self.read_batch_inputs().await.map_err(|err| { + error!(&err, target: LOG_TARGET, "Failed to read the chain state for a batch"); + BatchFailure::from_node_error(&err) + })?; + let reference_block = reference_header.block_num(); + + self.check_account_code(&funder).map_err(|err| { + error!(&err, target: LOG_TARGET, "The funding account does not match its account file"); + BatchFailure::Internal(err.as_report()) + })?; + + let balance = self.fee_balance(&funder); + self.setup.status.update(balance, reference_block); + + self.reject_unaffordable(batch, balance); + if batch.is_empty() { + return Ok(()); + } + + let targets: Vec<(AccountId, u64)> = + batch.iter().map(|request| (request.target, request.amount)).collect(); + let PreparedBatch { proven_tx, transaction_inputs, notes } = self + .prepare_batch(reference_header, blockchain, funder, fee_faucet, &targets) + .await + .map_err(|err| { + error!(&err, target: LOG_TARGET, "Failed to prepare the funding transaction"); + BatchFailure::Internal(err.as_report()) + })?; + let transaction_id = proven_tx.id(); + let expiration_block = proven_tx.expiration_block_num(); + + if let Err(err) = self.node.submit(&proven_tx, &transaction_inputs).await { + if !is_transient_error(&err) && allow_retry { + warn!( + &err, + target: LOG_TARGET, + "The node rejected the funding transaction; retrying from a fresh block", + transaction.id = transaction_id + ); + return Box::pin(self.run_batch(batch, shutdown, false)).await; + } + + error!( + &err, + target: LOG_TARGET, + "Failed to submit the funding transaction", + transaction.id = transaction_id + ); + return Err(BatchFailure::from_submit_error(&err)); + } + + info!( + target: LOG_TARGET, + "Submitted a funding transaction", + transaction.id = transaction_id, + transaction.expires_at = expiration_block, + block.number = reference_block, + note.count = notes.len() + ); + + let note_ids: Vec<_> = notes.iter().map(Note::id).collect(); + let proofs = match await_inclusion( + &self.node, + ¬e_ids, + expiration_block, + self.setup.config.poll_interval, + shutdown, + ) + .await + { + Inclusion::Committed(proofs) => proofs, + Inclusion::Expired => { + warn!( + target: LOG_TARGET, + "The funding transaction expired before it committed", + transaction.id = transaction_id, + transaction.expires_at = expiration_block, + note.count = note_ids.len() + ); + return Err(BatchFailure::Expired(expiration_block)); + }, + Inclusion::ShuttingDown => return Err(BatchFailure::ShuttingDown), + }; + + reply_with_notes(std::mem::take(batch), notes, proofs, transaction_id); + + Ok(()) + } + + /// Replies to the requests which `balance` cannot cover and removes them from `batch`. + fn reject_unaffordable(&self, batch: &mut Vec, balance: u64) { + let reserve = u64::from(self.setup.verification_base_fee) * MAX_FEE_VERIFICATION_CYCLES; + let amounts: Vec = batch.iter().map(|request| request.amount).collect(); + let admitted_count = admit(&amounts, balance, reserve); + + for request in batch.drain(admitted_count..) { + let _ = request.reply.send(Err(RequestFundsError::InsufficientFunds { + requested: request.amount, + balance, + reserve, + })); + } + + if batch.is_empty() { + warn!( + target: LOG_TARGET, + "The funding account cannot cover any queued request", + account.id = self.account_id(), + asset.balance = balance, + asset.reserve = reserve + ); + } + } + + /// Reads the chain state one batch needs, at a fresh reference block. + async fn read_batch_inputs(&self) -> Result { + let (reference_header, blockchain) = self + .retry_node_call(|| self.node.tip_chain_state()) + .await + .context("failed to read the chain state")?; + let reference_block = reference_header.block_num(); + + let (funder, _funder_witness) = self + .retry_node_call(|| self.node.public_account(self.account_id(), reference_block)) + .await + .context("failed to read the funding account")?; + let fee_faucet = self + .retry_node_call(|| self.node.public_account(self.setup.fee_faucet_id, reference_block)) + .await + .context("failed to read the fee faucet account")?; + + Ok(BatchInputs { + reference_header, + blockchain, + funder, + fee_faucet, + }) + } + + /// Builds the notes for `targets`, then executes and proves the transaction which creates them. + async fn prepare_batch( + &mut self, + reference_header: BlockHeader, + blockchain: PartialBlockchain, + funder: Account, + fee_faucet: (Account, AccountWitness), + targets: &[(AccountId, u64)], + ) -> Result { + let notes = tx::build_funding_notes( + self.account_id(), + self.setup.fee_faucet_id, + targets, + &mut self.rng, + ) + .context("failed to build the funding notes")?; + + let inputs = ExecutionInputs { + funder, + secret_key: self.setup.key.secret_key().clone(), + fee_faucet, + reference_header, + blockchain, + expiration_delta: self.setup.config.expiration_delta, + }; + let executed_tx = tx::execute(inputs, notes.clone(), &mut self.rng) + .await + .context("failed to execute the funding transaction")?; + let transaction_inputs = executed_tx.tx_inputs().to_bytes(); + + let proven_tx = self + .prover + .prove(executed_tx) + .await + .context("failed to prove the funding transaction")?; + + Ok(PreparedBatch { proven_tx, transaction_inputs, notes }) + } + + /// Retries a node request while it fails for a transient reason. + async fn retry_node_call(&self, call: F) -> Result + where + F: Fn() -> Fut, + Fut: Future>, + { + (|| call()) + .retry(retry::exponential_bounded( + NODE_RETRY_MIN_DELAY, + NODE_RETRY_MAX_DELAY, + NODE_RETRY_MAX_TIMES, + )) + .when(is_transient_error) + .notify(|err: &anyhow::Error, delay: Duration| { + warn!( + err, + target: COMPONENT, + "A node request failed; retrying after backoff", + retry.delay_ms = delay.as_millis() as u64 + ); + }) + .await + } + + /// Checks the account on chain against the account file, once. + fn check_account_code(&mut self, funder: &Account) -> Result<()> { + if self.account_checked { + return Ok(()); + } + + anyhow::ensure!( + funder.code().commitment() == self.setup.key.code_commitment(), + "the code of account {} on chain does not match the account file: is the account file \ + from another network?", + funder.id(), + ); + self.account_checked = true; + + Ok(()) + } + + /// The funding account's balance of the native asset. + fn fee_balance(&self, funder: &Account) -> u64 { + funder + .vault() + .get_balance(AssetId::new_fungible(self.setup.fee_faucet_id)) + .map_or(0, |amount| amount.as_u64()) + } + + fn account_id(&self) -> AccountId { + self.setup.key.account_id() + } +} + +// ADMISSION +// ================================================================================================ + +/// Returns how many of `amounts` the funding account can pay for, in order. +/// +/// The transaction pays its own fee out of the same vault, so `reserve` is held back. Admission +/// stops at the first request which does not fit: a later, smaller request is not admitted ahead of +/// it, which keeps the queue first-come-first-served and stops a stream of small requests from +/// starving a large one. +fn admit(amounts: &[u64], balance: u64, reserve: u64) -> usize { + let mut spendable = balance.saturating_sub(reserve); + + for (index, &amount) in amounts.iter().enumerate() { + // A zero amount is rejected before a request is queued, so every amount here is positive + // and the balance strictly decreases. + match spendable.checked_sub(amount) { + Some(remaining) => spendable = remaining, + None => return index, + } + } + + amounts.len() +} + +// BATCH FAILURE +// ================================================================================================ + +/// Why a batch failed. +#[derive(Debug, Clone)] +enum BatchFailure { + /// The service failed for a reason the requester cannot act on. + Internal(String), + /// The node could not be reached. The cause is logged where the failure is detected. + NodeUnreachable, + /// The node rejected the transaction. + Rejected(String), + /// The transaction expired without committing. + Expired(BlockNumber), + /// The service is shutting down. + ShuttingDown, +} + +impl BatchFailure { + /// Classifies an error from a node request. + fn from_node_error(err: &anyhow::Error) -> Self { + if is_transient_error(err) { + Self::NodeUnreachable + } else { + Self::Internal(err.as_report()) + } + } + + /// Classifies an error from the transaction submission. + fn from_submit_error(err: &anyhow::Error) -> Self { + if is_transient_error(err) { + Self::NodeUnreachable + } else { + Self::Rejected(err.as_report()) + } + } + + /// The error reported to a requester. + fn to_error(&self) -> RequestFundsError { + match self { + Self::Internal(report) => RequestFundsError::Internal(anyhow::anyhow!(report.clone())), + Self::NodeUnreachable => RequestFundsError::NotReady("the node is unreachable"), + Self::Rejected(report) => { + RequestFundsError::TransactionRejected(anyhow::anyhow!(report.clone())) + }, + Self::Expired(expiration_block) => { + RequestFundsError::TransactionExpired { expiration_block: *expiration_block } + }, + Self::ShuttingDown => RequestFundsError::NotReady("the service is shutting down"), + } + } +} + +/// Answers every request with the note built for it. +fn reply_with_notes( + batch: Vec, + notes: Vec, + mut proofs: HashMap, + transaction_id: TransactionId, +) { + for (request, note) in batch.into_iter().zip(notes) { + // `Inclusion::Committed` holds a proof for every note of the transaction, so a missing + // proof is a broken invariant and not an expired transaction. + let response = match proofs.remove(¬e.id()) { + Some(inclusion_proof) => Ok(FundedNote { note, inclusion_proof, transaction_id }), + None => Err(RequestFundsError::Internal(anyhow::anyhow!( + "no inclusion proof for note {} of committed transaction {transaction_id}", + note.id(), + ))), + }; + let _ = request.reply.send(response); + } +} + +/// Answers every request with the same failure. +fn fail_all(batch: Vec, error: impl Fn() -> RequestFundsError) { + for request in batch { + let _ = request.reply.send(Err(error())); + } +} diff --git a/crates/proto/src/domain/funding.rs b/crates/proto/src/domain/funding.rs new file mode 100644 index 0000000000..d83e941fb0 --- /dev/null +++ b/crates/proto/src/domain/funding.rs @@ -0,0 +1,39 @@ +use miden_protocol::account::AccountId; + +use crate::decode::GrpcDecodeExt; +use crate::errors::ConversionError; +use crate::{decode, generated as proto}; + +// REQUEST FUNDS +// ================================================================================================ + +/// A request for native asset funds for one account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestFunds { + /// The account which the created note targets. + pub account_id: AccountId, + /// The amount of the native asset, in base units. + pub amount: u64, +} + +impl From for proto::funding_service::RequestFundsRequest { + fn from(request: RequestFunds) -> Self { + Self { + account_id: Some(request.account_id.into()), + amount: request.amount, + } + } +} + +impl TryFrom for RequestFunds { + type Error = ConversionError; + + fn try_from(value: proto::funding_service::RequestFundsRequest) -> Result { + let decoder = value.decoder(); + let proto::funding_service::RequestFundsRequest { account_id, amount } = value; + + let account_id = decode!(decoder, account_id)?; + + Ok(Self { account_id, amount }) + } +} diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index 5d64056bd0..f8542c89d9 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod block; pub mod digest; pub mod encryption; +pub mod funding; pub mod merkle; pub mod note; pub mod nullifier; diff --git a/proto/proto/funding_service.proto b/proto/proto/funding_service.proto index 6bc7c137d9..8307514367 100644 --- a/proto/proto/funding_service.proto +++ b/proto/proto/funding_service.proto @@ -5,17 +5,51 @@ package funding_service; import "google/protobuf/empty.proto"; import "types/account.proto"; +import "types/note.proto"; +import "types/transaction.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. +// The service owns one wallet account which holds the native asset. Each request creates a private +// P2ID note for the requested account. service Api { // Returns the status of the funding service. rpc Status(google.protobuf.Empty) returns (FundingServiceStatus) {} + + // Creates a private P2ID note which holds `amount` base units of the native asset and targets + // `account_id`, then waits for the note to commit. + // + // Error codes: + // - `INVALID_ARGUMENT`: the account ID is malformed, or the amount is zero or above the maximum. + // - `FAILED_PRECONDITION`: the account cannot cover the amount plus the fee of one transaction. + // - `UNAVAILABLE`: the service is not synchronized, the node is unreachable, or it is stopping. + // - `ABORTED`: the transaction expired or the node rejected it. No note was created. + // - `RESOURCE_EXHAUSTED`: too many requests are queued. + rpc RequestFunds(RequestFundsRequest) returns (RequestFundsResponse) {} +} + +// REQUEST FUNDS +// ================================================================================================ + +// Request message for funds. +message RequestFundsRequest { + // The account which the note targets. + account.AccountId account_id = 1; + + // The amount of the native asset, in base units. + uint64 amount = 2; +} + +// Response message which holds the committed note. +message RequestFundsResponse { + // The private P2ID note together with proof of its inclusion in a block. + note.CommittedNote note = 1; + + // The transaction which created the note. + transaction.TransactionId transaction_id = 2; } // STATUS @@ -35,6 +69,6 @@ message FundingServiceStatus { // The block number which the service is synchronized to. fixed32 chain_tip = 4; - // The largest amount which one funding request accepts, in base units. + // The largest amount which `RequestFunds` accepts, in base units. uint64 max_amount = 5; } From 6b76a4abd5943b3851e112a3147fe285811c13f2 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Wed, 9 Sep 2026 16:13:53 -0300 Subject: [PATCH 3/4] feat(network-monitor): fund accounts from the funding service --- README.md | 2 + bin/network-monitor/README.md | 6 +- bin/network-monitor/src/config.rs | 18 ++ bin/network-monitor/src/counter.rs | 13 +- bin/network-monitor/src/deploy/mod.rs | 65 +++--- bin/network-monitor/src/faucet.rs | 2 + bin/network-monitor/src/funding.rs | 211 +++++++++--------- bin/network-monitor/src/monitor/tasks.rs | 10 +- bin/network-monitor/src/remote_prover.rs | 13 +- compose/bootstrap.yml | 7 + compose/funding-service.yml | 33 +++ compose/monitor.yml | 5 + compose/router.yml | 4 + docker-compose.yml | 4 +- .../external/src/local-network-development.md | 1 + docs/external/src/logging.md | 1 + .../network-operator/bootstrap-and-genesis.md | 5 + .../src/network-operator/funding-service.md | 110 +++++++++ .../src/network-operator/installation.md | 1 + .../src/network-operator/monitoring.md | 6 +- .../external/src/network-operator/overview.md | 7 + .../external/src/network-operator/recovery.md | 2 +- .../upgrades-and-migrations.md | 2 +- docs/internal/src/SUMMARY.md | 1 + docs/internal/src/funding-service.md | 38 ++++ 25 files changed, 396 insertions(+), 171 deletions(-) create mode 100644 compose/funding-service.yml create mode 100644 docs/external/src/network-operator/funding-service.md create mode 100644 docs/internal/src/funding-service.md diff --git a/README.md b/README.md index b75edd9a84..eb8d6ab14b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ A quick overview of the binaries: blocks. - [`network-monitor`](./bin/network-monitor/README.md): a tool which monitors a network's infrastructure, e.g. block production, RPC, validator, prover, faucet, explorer, and note transport. +- [`funding-service`](./bin/funding-service/README.md): sends the chain's native asset to any account which asks for it, + so infrastructure can pay transaction fees. There are additional binaries but they're more supplementary; see their READMEs for more information. diff --git a/bin/network-monitor/README.md b/bin/network-monitor/README.md index 8f6a73345a..f7ae31381c 100644 --- a/bin/network-monitor/README.md +++ b/bin/network-monitor/README.md @@ -29,9 +29,9 @@ validator key that signs transaction encryption key attestations. The monitor wi can verify the advertised encryption key. On a chain with a non-zero verification base fee, network transaction checks additionally require -`MIDEN_MONITOR_FAUCET_URL`: the monitor funds its in-memory accounts by claiming the native fee asset from the faucet, -and it tops the balance up automatically when it runs low. Without a configured faucet the monitor refuses to start its -network transaction checks on such chains. +`MIDEN_MONITOR_FUNDING_SERVICE_URL`: the monitor funds its in-memory accounts from the funding service and tops the +balance up automatically when it runs low. Without it the monitor refuses to start its network transaction checks on +such chains. `MIDEN_MONITOR_FAUCET_URL` is only used for the faucet checks. Use the binary help output for the current command and configuration surface. The help output is the source of truth for flags and environment variables. diff --git a/bin/network-monitor/src/config.rs b/bin/network-monitor/src/config.rs index 6912f73c48..4046e9d457 100644 --- a/bin/network-monitor/src/config.rs +++ b/bin/network-monitor/src/config.rs @@ -57,6 +57,24 @@ pub struct MonitorConfig { )] pub faucet_url: Option, + /// The URL of the funding service (optional). + #[arg( + long = "funding-service-url", + env = "MIDEN_MONITOR_FUNDING_SERVICE_URL", + help = "The URL of the funding service (optional)" + )] + pub funding_service_url: Option, + + /// Timeout for a funding request to the funding service. + #[arg( + long = "funding-request-timeout", + env = "MIDEN_MONITOR_FUNDING_REQUEST_TIMEOUT", + default_value = "2m", + value_parser = humantime::parse_duration, + help = "Timeout for a funding request to the funding service" + )] + pub funding_request_timeout: Duration, + /// The interval at which to test the remote provers services. #[arg( long = "remote-prover-test-interval", diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index 617758f7eb..a03ac5c396 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; -use miden_node_proto::clients::RpcClient; +use miden_node_proto::clients::{FundingClient, RpcClient}; use miden_node_tracing::spawn::spawn_blocking_in_current_span; use miden_node_tracing::{debug, error, info, miden_instrument, warn}; use miden_protocol::account::auth::AuthSecretKey; @@ -50,7 +50,7 @@ use crate::deploy::{ create_and_deploy_accounts, create_genesis_aware_rpc_client, }; -use crate::funding::{FaucetClient, FeeFunder, wallet_funding_amount, wallet_topup_threshold}; +use crate::funding::{FeeFunder, wallet_funding_amount, wallet_topup_threshold}; use crate::service::Service; use crate::status::{ CounterTrackingDetails, @@ -193,8 +193,8 @@ pub struct IncrementService { accounts_sender: watch::Sender, /// Shared client for attestation verification, sealing, and transaction submission. submission_client: TransactionSubmissionClient, - /// Faucet access for fee funding; `None` when no faucet is configured (zero-fee chains only). - funding: Option, + /// The funding service client; `None` when none is configured (zero-fee chains only). + funding: Option, /// Committed faucet note to be consumed by the next increment. Cleared once consumed. pending_funding_note: Option, } @@ -211,7 +211,7 @@ impl IncrementService { submission_client: TransactionSubmissionClient, accounts_sender: watch::Sender, latency_state: Arc>, - funding: Option, + funding: Option, ) -> Result { let rpc_client = submission_client.rpc_client(); let pending_funding_note = accounts.wallet_funding_note; @@ -482,8 +482,7 @@ impl IncrementService { account.id = self.tx.wallet_account.id(), asset.balance = balance ); - let mut funder = - FeeFunder::new(funding, self.rpc_client.clone(), fee_parameters.fee_faucet_id()); + let mut funder = FeeFunder::new(funding, fee_parameters.fee_faucet_id()); match funder .fund(self.tx.wallet_account.id(), wallet_funding_amount(verification_base_fee)) .await diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index 98251c42dc..1e2ed0cbd7 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -9,7 +9,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use backon::{ExponentialBuilder, Retryable}; -use miden_node_proto::clients::{Builder, RpcClient}; +use miden_node_proto::clients::{Builder, FundingClient, RpcClient}; use miden_node_proto::domain::account::{AccountResponse, AccountVaultDetails, StorageMapEntries}; use miden_node_proto::domain::encryption::{ TransactionInputsSealer, @@ -74,7 +74,7 @@ use url::Url; use crate::deploy::counter::create_counter_account; use crate::deploy::wallet::create_wallet_account; -use crate::funding::{FaucetClient, FeeFunder, counter_funding_amount, wallet_funding_amount}; +use crate::funding::{FeeFunder, counter_funding_amount, wallet_funding_amount}; use crate::{COMPONENT, LOG_TARGET}; pub mod counter; @@ -306,7 +306,7 @@ pub async fn create_genesis_aware_rpc_client( pub async fn create_and_deploy_accounts( submission_client: &TransactionSubmissionClient, prover: &LocalTransactionProver, - funding: Option<&FaucetClient>, + funding: Option<&FundingClient>, ) -> Result { info!(target: LOG_TARGET, "Creating fresh monitor accounts"); @@ -314,7 +314,7 @@ pub async fn create_and_deploy_accounts( // The genesis header is immutable, so it is fetched once and reused by every step below. let genesis_header = fetch_genesis_block_header(&mut rpc_client).await?; - let mut funder = active_fee_funder(&genesis_header, funding, &rpc_client)?; + let mut funder = active_fee_funder(&genesis_header, funding)?; let verification_base_fee = genesis_header.fee_parameters().verification_base_fee(); let (wallet_account, secret_key) = create_wallet_account()?; @@ -385,26 +385,25 @@ pub async fn create_and_deploy_accounts( }) } -/// A fee-charging chain without a configured faucet. Permanent, so the NTX bootstrap aborts the -/// monitor instead of retrying (see `run_ntx`). +/// A fee-charging chain without a configured funding service. Permanent, so the NTX bootstrap +/// aborts the monitor instead of retrying (see `run_ntx`). #[derive(Debug)] pub struct UnsupportedChainError; impl std::fmt::Display for UnsupportedChainError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str( - "this chain charges transaction fees: configure --faucet-url so the monitor can fund \ - its accounts", + "this chain charges transaction fees: configure --funding-service-url so the \ + monitor can fund its accounts", ) } } -/// Returns the faucet client on fee-charging chains, `None` on zero-fee chains. -// TODO(#2450): Mainnet has no faucet service; it needs another funding path. +/// Returns the funding service client on fee-charging chains, `None` on zero-fee chains. pub fn active_fee_funding<'a>( genesis_header: &BlockHeader, - funding: Option<&'a FaucetClient>, -) -> Result> { + funding: Option<&'a FundingClient>, +) -> Result> { if genesis_header.fee_parameters().verification_base_fee() == 0 { return Ok(None); } @@ -413,19 +412,14 @@ pub fn active_fee_funding<'a>( /// Returns a [`FeeFunder`] on fee-charging chains, `None` on zero-fee chains. /// -/// The funder binds the faucet client to the given RPC client and to the fee faucet ID from the -/// genesis fee parameters. +/// The funder binds the funding service client to the fee faucet ID from the genesis fee +/// parameters. pub fn active_fee_funder( genesis_header: &BlockHeader, - funding: Option<&FaucetClient>, - rpc_client: &RpcClient, + funding: Option<&FundingClient>, ) -> Result> { - let funder = active_fee_funding(genesis_header, funding)?.map(|faucet| { - FeeFunder::new( - faucet.clone(), - rpc_client.clone(), - genesis_header.fee_parameters().fee_faucet_id(), - ) + let funder = active_fee_funding(genesis_header, funding)?.map(|client| { + FeeFunder::new(client.clone(), genesis_header.fee_parameters().fee_faucet_id()) }); Ok(funder) } @@ -834,14 +828,14 @@ pub(crate) async fn execute_counter_genesis_tx( /// is never submitted, so the note is never spent on-chain: one claim serves every probe run. pub async fn build_probe_transaction_inputs( rpc_url: &Url, - funding: Option<&FaucetClient>, + funding: Option<&FundingClient>, ) -> Result { let (wallet_account, _secret_key) = create_wallet_account()?; let (mut rpc_client, _) = create_genesis_aware_rpc_client(rpc_url, Duration::from_secs(10)).await?; let genesis_header = fetch_genesis_block_header(&mut rpc_client).await?; - let mut funder = active_fee_funder(&genesis_header, funding, &rpc_client)?; + let mut funder = active_fee_funder(&genesis_header, funding)?; let verification_base_fee = genesis_header.fee_parameters().verification_base_fee(); let fee_faucet_id = genesis_header.fee_parameters().fee_faucet_id(); let counter_account = @@ -1083,14 +1077,15 @@ mod tests { use miden_testing::MockChain; - use super::{FaucetClient, active_fee_funding}; + use super::{FundingClient, active_fee_funding}; + use crate::service::build_tls_client; - /// A fee-charging chain without a faucet must fail at startup; a zero-fee chain must not fund - /// even when a faucet is configured. - #[test] - fn fee_funding_is_required_exactly_on_fee_charging_chains() { - let funding = FaucetClient::new( - url::Url::parse("http://faucet.invalid").expect("static URL is valid"), + /// A fee-charging chain without the funding service must fail at startup; a zero-fee chain must + /// not fund even when the service is configured. + #[tokio::test] + async fn fee_funding_is_required_exactly_on_fee_charging_chains() { + let funding = build_tls_client::( + url::Url::parse("http://funding.invalid").expect("static URL is valid"), Duration::from_secs(1), ); @@ -1106,19 +1101,19 @@ mod tests { let genesis_header = fee_charging_chain.genesis_block_header(); let active = active_fee_funding(&genesis_header, Some(&funding)) - .expect("a fee-charging chain with a faucet is supported"); + .expect("a fee-charging chain with the funding service is supported"); assert!(active.is_some(), "funding must be active on a fee-charging chain"); let err = active_fee_funding(&genesis_header, None) - .expect_err("a fee-charging chain without a faucet must be rejected"); + .expect_err("a fee-charging chain without the funding service must be rejected"); assert!( - format!("{err:#}").contains("--faucet-url"), + format!("{err:#}").contains("--funding-service-url"), "the error should point at the missing configuration, got: {err:#}" ); // The bootstrap retry loop keys on this downcast to abort instead of retrying. assert!( err.downcast_ref::().is_some(), - "the missing-faucet error must be typed as permanent" + "the missing-funding error must be typed as permanent" ); } } diff --git a/bin/network-monitor/src/faucet.rs b/bin/network-monitor/src/faucet.rs index 837d7684cb..4ecccb7691 100644 --- a/bin/network-monitor/src/faucet.rs +++ b/bin/network-monitor/src/faucet.rs @@ -61,6 +61,8 @@ struct PowChallengeResponse { #[serde(deny_unknown_fields)] pub(crate) struct GetTokensResponse { pub(crate) tx_id: String, + // Part of the API response, and `deny_unknown_fields` rejects it if it is not declared. + #[expect(dead_code)] pub(crate) note_id: String, } diff --git a/bin/network-monitor/src/funding.rs b/bin/network-monitor/src/funding.rs index ffc0fff772..c7deab0ac5 100644 --- a/bin/network-monitor/src/funding.rs +++ b/bin/network-monitor/src/funding.rs @@ -2,19 +2,23 @@ //! //! Fees are withdrawn from the executing account's vault, so on a fee-charging chain the //! monitor's fresh accounts need the fee asset before they can transact. This module requests a -//! public P2ID note from the faucet, waits for it to commit, and returns it for consumption as an -//! unauthenticated input note. -// TODO(#2450): Mainnet has no faucet service; funding there needs a manual note-import path. +//! P2ID note which holds the fee asset and returns it for consumption as an unauthenticated input +//! note. +//! +//! The funding service is the only source of the fee asset. The chain's faucet is not used for +//! fees, because a network does not always run a public faucet. The monitor still talks to the +//! faucet for its faucet checks, which is what [`FaucetClient`] is for. use std::time::Duration; use anyhow::{Context, Result}; -use miden_node_proto::clients::RpcClient; -use miden_node_proto::generated::note::NoteIdList; -use miden_node_tracing::{info, warn}; +use miden_node_proto::clients::FundingClient; +use miden_node_proto::domain::funding::RequestFunds; +use miden_node_proto::generated::funding_service::RequestFundsRequest as ProtoRequestFundsRequest; +use miden_node_tracing::info; use miden_protocol::account::AccountId; use miden_protocol::asset::Asset; -use miden_protocol::note::{Note, NoteId}; +use miden_protocol::note::{Note, NoteInclusionProof}; use reqwest::Client; use url::Url; @@ -26,6 +30,7 @@ use crate::faucet::{ fetch_faucet_metadata, request_tokens, }; +use crate::service::build_tls_client; /// Upper bound on the fee formula's cycle multiplier: the kernel charges `verification_base_fee * /// (ilog2(total_cycles) + 1)` with cycles capped at `2^29`. @@ -34,12 +39,12 @@ const MAX_FEE_VERIFICATION_CYCLES: u64 = 30; /// Increments one wallet funding request should cover, roughly a week at the default cadence. const WALLET_FUNDING_INCREMENTS: u64 = 20_000; -/// Remaining-increment level at which the wallet requests a top-up from the faucet. +/// Remaining-increment level at which the wallet requests a top-up. const WALLET_TOPUP_THRESHOLD_INCREMENTS: u64 = 1_000; -/// Largest amount requested per faucet call, matching the faucet's default -/// `--max-claimable-amount`. Larger requests are rejected with an HTTP 400. -const MAX_FAUCET_REQUEST_AMOUNT: u64 = 1_000_000_000; +/// Largest amount requested per funding call, matching the funding service's default +/// `--max-amount`. A larger request is rejected with `INVALID_ARGUMENT`. +const MAX_FUNDING_REQUEST_AMOUNT: u64 = 1_000_000_000; /// Transactions the counter is funded for at deployment. It only pays its own creation fee from /// this; later network transactions are paid by the sponsorship note each increment attaches. Kept @@ -47,12 +52,6 @@ const MAX_FAUCET_REQUEST_AMOUNT: u64 = 1_000_000_000; /// dust notes, but increments keep working since sponsorships are collected before fees. const COUNTER_FUNDING_TXS: u64 = 2; -/// Attempts to find a freshly-minted note before giving up. -const NOTE_LOOKUP_ATTEMPTS: usize = 30; - -/// Delay between note lookup attempts. -const NOTE_LOOKUP_DELAY: Duration = Duration::from_secs(2); - /// Hard upper bound on one transaction's fee under the given base fee. pub fn max_fee_per_transaction(verification_base_fee: u32) -> u64 { u64::from(verification_base_fee) * MAX_FEE_VERIFICATION_CYCLES @@ -63,29 +62,29 @@ pub fn wallet_budget_per_increment(verification_base_fee: u32) -> u64 { max_fee_per_transaction(verification_base_fee) * 2 } -/// Amount requested from the faucet when funding or topping up the wallet. +/// Amount requested when funding or topping up the wallet. pub fn wallet_funding_amount(verification_base_fee: u32) -> u64 { (wallet_budget_per_increment(verification_base_fee) * WALLET_FUNDING_INCREMENTS) - .min(MAX_FAUCET_REQUEST_AMOUNT) + .min(MAX_FUNDING_REQUEST_AMOUNT) } /// Wallet balance below which a top-up is requested. Clamped to half the request cap so a capped /// funding request still clears the threshold. pub fn wallet_topup_threshold(verification_base_fee: u32) -> u64 { (wallet_budget_per_increment(verification_base_fee) * WALLET_TOPUP_THRESHOLD_INCREMENTS) - .min(MAX_FAUCET_REQUEST_AMOUNT / 2) + .min(MAX_FUNDING_REQUEST_AMOUNT / 2) } -/// Amount requested from the faucet when funding the counter account at deployment. +/// Amount requested when funding the counter account at deployment. pub fn counter_funding_amount(verification_base_fee: u32) -> u64 { (max_fee_per_transaction(verification_base_fee) * COUNTER_FUNDING_TXS) - .min(MAX_FAUCET_REQUEST_AMOUNT) + .min(MAX_FUNDING_REQUEST_AMOUNT) } -/// HTTP client for the chain's faucet service. +/// HTTP client for the chain's faucet service, used by the monitor's faucet checks. /// -/// Wraps the token-request flow (proof-of-work challenge plus `/get_tokens`) and the -/// monitor-specific funding flow built on top of it. +/// Wraps the token-request flow, which is a proof-of-work challenge plus `/get_tokens`, and the +/// metadata endpoint. The monitor does not pay fees from the faucet. #[derive(Clone, Debug)] pub struct FaucetClient { faucet_url: Url, @@ -95,11 +94,6 @@ pub struct FaucetClient { } impl FaucetClient { - /// Builds the client when a faucet URL is configured. - pub fn from_config(config: &MonitorConfig) -> Option { - config.faucet_url.clone().map(|url| Self::new(url, config.request_timeout)) - } - pub fn new(faucet_url: Url, request_timeout: Duration) -> Self { let client = Client::builder() .timeout(request_timeout) @@ -133,51 +127,62 @@ impl FaucetClient { } } +/// Builds the funding service client when its URL is configured. +/// +/// Returns `None` when it is not, which is only usable on a chain that does not charge fees. +pub fn funding_client_from_config(config: &MonitorConfig) -> Option { + let url = config.funding_service_url.clone()?; + + Some(build_tls_client::(url, config.funding_request_timeout)) +} + /// Funds monitor accounts with the chain's fee asset. /// -/// Binds a [`FaucetClient`] to the RPC client used to await note commitment and to the chain's -/// fee faucet ID, so callers fund an account from just an ID and an amount. Built where the -/// genesis header is known, since the fee faucet ID comes from the genesis fee parameters. +/// Binds the funding service client to the chain's fee faucet ID, so callers fund an account from +/// just an ID and an amount. Built where the genesis header is known, since the fee faucet ID comes +/// from the genesis fee parameters. pub struct FeeFunder { - faucet: FaucetClient, - rpc_client: RpcClient, + client: FundingClient, fee_faucet_id: AccountId, } impl FeeFunder { - pub fn new(faucet: FaucetClient, rpc_client: RpcClient, fee_faucet_id: AccountId) -> Self { - Self { faucet, rpc_client, fee_faucet_id } + pub fn new(client: FundingClient, fee_faucet_id: AccountId) -> Self { + Self { client, fee_faucet_id } } - /// Requests `amount` base units for `account_id` and waits for the resulting public P2ID note - /// to commit. The note's asset is checked against the fee faucet ID so a faucet minting the - /// wrong token fails here instead of as opaque fee aborts later. + /// Requests `amount` base units for `account_id` and returns the committed P2ID note. + /// + /// The service answers only once the note is committed, so no lookup is needed here. The + /// note's asset is checked against the fee faucet ID, so a service configured for another + /// chain fails here instead of as opaque fee aborts later. pub async fn fund(&mut self, account_id: AccountId, amount: u64) -> Result { - let tokens = self - .faucet - .request_tokens(&account_id.to_string(), amount) + let request = RequestFunds { account_id, amount }; + let response = self + .client + .request_funds(ProtoRequestFundsRequest::from(request)) .await - .context("faucet token request failed")?; + .context("the funding service rejected the request")? + .into_inner(); + + let committed = response.note.context("the funding service returned no note")?; + // The note is private, so this response holds the only copy of its details. + let (note, _inclusion_proof) = <(Note, NoteInclusionProof)>::try_from(committed) + .context("failed to convert the note of the funding service")?; - let note_id = NoteId::try_from_hex(&tokens.note_id) - .with_context(|| format!("faucet returned an invalid note id: {}", tokens.note_id))?; + ensure_note_carries_fee_asset(¬e, self.fee_faucet_id).context( + "the funding service did not send the chain's fee asset: is it configured for this \ + chain?", + )?; info!( target: LOG_TARGET, - "Requested fee tokens from the faucet", + "Received fee tokens from the funding service", account.id = account_id, - note.id = note_id, + note.id = note.id(), asset.amount = amount ); - let note = await_committed_note(&mut self.rpc_client, note_id).await?; - ensure_note_carries_fee_asset(¬e, self.fee_faucet_id).with_context(|| { - format!( - "the faucet at {} did not mint the chain's fee asset: is --faucet-url pointing \ - at the chain's native faucet?", - self.faucet.url() - ) - })?; Ok(note) } } @@ -196,58 +201,12 @@ fn ensure_note_carries_fee_asset(note: &Note, fee_faucet_id: AccountId) -> Resul Ok(()) } -/// Polls the node until the given public note is committed and returns it in full. -async fn await_committed_note(rpc_client: &mut RpcClient, note_id: NoteId) -> Result { - for attempt in 1..=NOTE_LOOKUP_ATTEMPTS { - if attempt > 1 { - tokio::time::sleep(NOTE_LOOKUP_DELAY).await; - } - - match fetch_note(rpc_client, note_id).await { - Ok(Some(note)) => return Ok(note), - Ok(None) => {}, - Err(err) => warn!( - &err, - target: LOG_TARGET, - "Failed to look up the funding note; retrying", - retry.attempt = attempt - ), - } - } - - anyhow::bail!( - "funding note {} was not committed within {} attempts", - note_id.to_hex(), - NOTE_LOOKUP_ATTEMPTS - ) -} - -/// Fetches one public note by ID; `Ok(None)` while the note is not committed yet. -async fn fetch_note(rpc_client: &mut RpcClient, note_id: NoteId) -> Result> { - let response = rpc_client - .get_notes_by_id(NoteIdList { ids: vec![note_id.as_word().into()] }) - .await - .context("failed to fetch the funding note from RPC")? - .into_inner(); - - let Some(committed) = response.notes.into_iter().next() else { - return Ok(None); - }; - - let note = committed - .note - .context("committed note response is missing the note")? - .try_into() - .context("failed to convert the funding note")?; - - Ok(Some(note)) -} - // TESTS // ================================================================================================ #[cfg(test)] mod tests { + use clap::Parser; use miden_protocol::Word; use miden_protocol::asset::FungibleAsset; use miden_protocol::note::NoteType; @@ -256,13 +215,20 @@ mod tests { use super::*; use crate::deploy::wallet::create_wallet_account; - /// Requested amounts must stay within the faucet's claim limit, and a capped request must still - /// clear the top-up threshold. + /// Parses a monitor configuration which holds the given arguments and nothing else of interest. + fn config_with(arguments: &[&str]) -> MonitorConfig { + let mut command = vec!["miden-network-monitor", "--rpc-url", "http://rpc.invalid"]; + command.extend_from_slice(arguments); + MonitorConfig::parse_from(command) + } + + /// Requested amounts must stay within the funding service's maximum, and a capped request must + /// still clear the top-up threshold. #[test] - fn funding_amounts_respect_the_faucet_claim_limit() { + fn funding_amounts_respect_the_request_maximum() { for base_fee in [1, 500, 834, 10_000, u32::MAX] { - assert!(wallet_funding_amount(base_fee) <= MAX_FAUCET_REQUEST_AMOUNT); - assert!(counter_funding_amount(base_fee) <= MAX_FAUCET_REQUEST_AMOUNT); + assert!(wallet_funding_amount(base_fee) <= MAX_FUNDING_REQUEST_AMOUNT); + assert!(counter_funding_amount(base_fee) <= MAX_FUNDING_REQUEST_AMOUNT); assert!( wallet_funding_amount(base_fee) >= 2 * wallet_topup_threshold(base_fee), "a single funding request must cover at least two thresholds at base fee \ @@ -276,10 +242,35 @@ mod tests { wallet_budget_per_increment(1) * WALLET_FUNDING_INCREMENTS ); // Large base fees hit the cap instead of producing a rejected request. - assert_eq!(wallet_funding_amount(10_000), MAX_FAUCET_REQUEST_AMOUNT); + assert_eq!(wallet_funding_amount(10_000), MAX_FUNDING_REQUEST_AMOUNT); + } + + /// Fees come from the funding service only, so a configured faucet must not produce a funding + /// client. + /// + /// Runs on a Tokio runtime because building the lazy gRPC client needs one. + #[tokio::test] + async fn only_the_funding_service_url_provides_fee_funding() { + let faucet_url = Url::parse("http://faucet.invalid").expect("static URL is valid"); + let service_url = Url::parse("http://funding.invalid").expect("static URL is valid"); + + let service = config_with(&["--funding-service-url", service_url.as_str()]); + assert!(funding_client_from_config(&service).is_some()); + + let faucet_only = config_with(&["--faucet-url", faucet_url.as_str()]); + assert!( + funding_client_from_config(&faucet_only).is_none(), + "the faucet must not be used as a source of fees" + ); + + let neither = config_with(&[]); + assert!( + funding_client_from_config(&neither).is_none(), + "without the funding service the chain must not charge fees" + ); } - /// A faucet minting the wrong token must fail at claim time, not as later fee aborts. + /// A source sending the wrong token must fail at claim time, not as later fee aborts. #[test] fn funding_note_must_carry_the_fee_asset() { let fee_faucet_id = FungibleAsset::mock_issuer(); diff --git a/bin/network-monitor/src/monitor/tasks.rs b/bin/network-monitor/src/monitor/tasks.rs index 04d56bd575..e56924ee7d 100644 --- a/bin/network-monitor/src/monitor/tasks.rs +++ b/bin/network-monitor/src/monitor/tasks.rs @@ -23,7 +23,7 @@ use crate::deploy::{ use crate::explorer::ExplorerService; use crate::faucet::FaucetService; use crate::frontend::{ServerState, serve}; -use crate::funding::FaucetClient; +use crate::funding::funding_client_from_config; use crate::note_transport::NoteTransportService; use crate::remote_prover::ProverStatusService; use crate::service::{Service, build_tls_client}; @@ -103,8 +103,8 @@ impl Tasks { /// (and keeps alive) a probe task that acquires its test payload from the RPC and runs /// proof-test probes on the test cadence. pub fn spawn_prover_tasks(&mut self, config: &MonitorConfig) -> Vec> { - // The probe payload's creation transaction pays its fee from the faucet. - let funding = FaucetClient::from_config(config); + // The probe payload's creation transaction pays its fee from the funding service. + let funding = funding_client_from_config(config); let mut prover_rxs = Vec::new(); for (i, prover_url) in config.remote_prover_urls.iter().enumerate() { let name = format!("Remote Prover ({})", i + 1); @@ -285,8 +285,8 @@ async fn bootstrap_ntx( trusted_validator_signing_key, ) .await?; - // The faucet funds fee payments; whether it is needed is decided during deployment. - let funding = FaucetClient::from_config(config); + // The funding service pays fees; whether it is needed is decided during deployment. + let funding = funding_client_from_config(config); let accounts = Box::pin(create_and_deploy_accounts(&submission_client, &prover, funding.as_ref())).await?; diff --git a/bin/network-monitor/src/remote_prover.rs b/bin/network-monitor/src/remote_prover.rs index 97bc9110d5..79e4cc6ca9 100644 --- a/bin/network-monitor/src/remote_prover.rs +++ b/bin/network-monitor/src/remote_prover.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; -use miden_node_proto::clients::{RemoteProverClient, RemoteProverProxyStatusClient}; +use miden_node_proto::clients::{FundingClient, RemoteProverClient, RemoteProverProxyStatusClient}; use miden_node_proto::generated as proto; use miden_node_tracing::{debug, miden_instrument, warn}; use miden_protocol::utils::serde::Serializable; @@ -24,7 +24,6 @@ use url::Url; use crate::COMPONENT; use crate::deploy::UnsupportedChainError; -use crate::funding::FaucetClient; use crate::service::{Service, build_tls_client}; use crate::service_status::{ ProverTestOutcome, @@ -91,8 +90,8 @@ pub struct ProbeSnapshot { struct ProbeSpawner { client: RemoteProverClient, rpc_url: Url, - /// Faucet access for funding the probe payload's fee payment on fee-charging chains. - funding: Option, + /// The funding service client for the probe payload's fee payment on fee-charging chains. + funding: Option, interval: Duration, probe_tx: watch::Sender, name: String, @@ -136,7 +135,7 @@ impl ProverStatusService { name: String, prover_url: Url, rpc_url: Url, - funding: Option, + funding: Option, interval: Duration, request_timeout: Duration, probe_interval: Duration, @@ -376,7 +375,7 @@ const PAYLOAD_RETRY_DELAY: Duration = Duration::from_secs(30); async fn run_prover_test( mut client: RemoteProverClient, rpc_url: Url, - funding: Option, + funding: Option, interval: Duration, probe_tx: watch::Sender, name: String, @@ -524,7 +523,7 @@ fn tonic_status_to_json(status: &tonic::Status) -> String { )] async fn generate_prover_test_payload( rpc_url: &Url, - funding: Option<&FaucetClient>, + funding: Option<&FundingClient>, ) -> anyhow::Result { let tx_inputs = crate::deploy::build_probe_transaction_inputs(rpc_url, funding).await?; Ok(proto::remote_prover::ProofRequest { diff --git a/compose/bootstrap.yml b/compose/bootstrap.yml index 9f1b1facc4..94fdd7ad9c 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -230,3 +230,10 @@ configs: [fee_parameters] verification_base_fee = 0 + + # Funds the funding service. The name makes the genesis step write the account file to + # `/data/accounts/funding_service.mac`, which the service loads from a fixed path. + [[wallet]] + account_type = "public" + assets = [{ amount = 1_000_000_000_000, symbol = "MIDEN" }] + name = "funding_service" diff --git a/compose/funding-service.yml b/compose/funding-service.yml new file mode 100644 index 0000000000..6024511871 --- /dev/null +++ b/compose/funding-service.yml @@ -0,0 +1,33 @@ +services: + funding-service: + image: ${MIDEN_FUNDING_SERVICE_IMAGE:-miden-funding-service} + pull_policy: missing + # The service only reads its account file, which the genesis step writes. + volumes: + - node-data:/data:ro + depends_on: + bootstrap-validator: + condition: service_completed_successfully + otel-collector: + condition: service_started + sequencer: + condition: service_started + tx-prover: + condition: service_started + command: + - miden-funding-service + - start + - --listen=0.0.0.0:50401 + - --rpc.url=http://sequencer:57291 + - --tx-prover.url=${MIDEN_REMOTE_PROVER_URL:-http://tx-prover:50051} + - --account-file=/data/accounts/funding_service.mac + environment: + # Public keys for the three validators' insecure default development signing keys. The + # service verifies the attested encryption key at startup and exits when the attestation + # comes from a validator it does not trust, so every validator of the set is listed. + MIDEN_FUNDING_VALIDATOR_SIGNING_PUBLIC_KEYS: 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f,02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337,03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + OTEL_RESOURCE_ATTRIBUTES: service.instance.id=funding-service + ports: + - "127.0.0.1:50401:50401" + restart: unless-stopped diff --git a/compose/monitor.yml b/compose/monitor.yml index 6192885cd3..aedac85524 100644 --- a/compose/monitor.yml +++ b/compose/monitor.yml @@ -4,6 +4,8 @@ services: image: ${MIDEN_NETWORK_MONITOR_IMAGE:-miden-network-monitor} pull_policy: missing depends_on: + funding-service: + condition: service_started otel-collector: condition: service_started sequencer: @@ -13,6 +15,9 @@ services: - start environment: MIDEN_MONITOR_RPC_URL: http://sequencer:57291 + # The monitor pays transaction fees from notes this service sends it. Only used on a chain + # which charges fees. + MIDEN_MONITOR_FUNDING_SERVICE_URL: http://funding-service:50401 MIDEN_MONITOR_PORT: "3001" MIDEN_MONITOR_NETWORK_NAME: Localhost # Public key for validator 1's insecure default development signing key. diff --git a/compose/router.yml b/compose/router.yml index e4038d42e9..4ed0147c27 100644 --- a/compose/router.yml +++ b/compose/router.yml @@ -38,6 +38,10 @@ configs: reverse_proxy h2c://note-transport:57292 } + http://funding.localhost { + reverse_proxy h2c://funding-service:50401 + } + http://faucet.localhost { handle_path /api/* { reverse_proxy faucet:8000 diff --git a/docker-compose.yml b/docker-compose.yml index cd401b9f51..3f6477182d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,8 @@ # seed service. # - MIDEN_NODE_IMAGE, MIDEN_VALIDATOR_IMAGE, MIDEN_NTX_BUILDER_IMAGE, # MIDEN_REMOTE_PROVER_IMAGE, MIDEN_NETWORK_MONITOR_IMAGE, -# MIDEN_BENCHMARK_IMAGE, MIDEN_FAUCET_IMAGE, and MIDEN_NOTE_TRANSPORT_IMAGE: +# MIDEN_FUNDING_SERVICE_IMAGE, MIDEN_BENCHMARK_IMAGE, MIDEN_FAUCET_IMAGE, and +# MIDEN_NOTE_TRANSPORT_IMAGE: # container images used by the local network. The core images default to the # unqualified names built by the Makefile. Optional external images default # to their pinned versions in the corresponding Compose files. @@ -28,6 +29,7 @@ include: - compose/validator.yml - compose/node.yml - compose/ntx-builder.yml + - compose/funding-service.yml - compose/tx-prover.yml - compose/seed.yml - compose/note-transport.yml diff --git a/docs/external/src/local-network-development.md b/docs/external/src/local-network-development.md index 84d92b8f0d..6c8d8181d3 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -114,6 +114,7 @@ Existing direct ports remain available for native gRPC clients, automation, and | RPC API (gRPC-Web) | `http://rpc.localhost` | `localhost:57291` for native gRPC | | Transaction prover | `http://prover.localhost` | Not published directly | | Note transport | `http://ntl.localhost` | `localhost:57292` for native gRPC | +| Funding service | `http://funding.localhost` | `localhost:50401` for native gRPC | | Faucet frontend | `http://faucet.localhost` | `http://localhost:8081` | | Faucet API | `http://faucet.localhost/api` | `http://localhost:8000` | | Block explorer | `http://explorer.localhost` | `http://localhost:8080` | diff --git a/docs/external/src/logging.md b/docs/external/src/logging.md index 70da44c5f6..1001a12efa 100644 --- a/docs/external/src/logging.md +++ b/docs/external/src/logging.md @@ -81,6 +81,7 @@ events at `info` while still printing user-visible `debug` events. | `user::miden-ntx-builder` | Network transaction construction and account actor activity | | `user::miden-prover` | Remote prover lifecycle events | | `user::miden-network-monitor` | Network monitor checks and end-to-end probes | +| `user::miden-funding-service` | Funding service readiness, funding transactions, and note commits | A `miden-node` process contains multiple components. For example, a sequencer can emit `user::miden-node`, `user::miden-rpc`, `user::miden-block-producer`, and `user::miden-store` events. diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 1a8b12417c..c40d4ee10b 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -62,6 +62,11 @@ printed. The operator file carries the only signing key permitted to mint, so tr To run a faucet against the network, pass `faucet_operator.mac` to the faucet's `init --import`, and the faucet account id to `--faucet-account-id`. +A `[[wallet]]` entry is written to `wallet_.mac`, where the index is the entry's position in the configuration. +Give an entry a `name` to write it to `.mac` instead, which keeps the path stable when another wallet is added +before it. A service which loads its account from a fixed path needs this; see the +[funding service](./funding-service.md). + Upload `genesis-data/genesis.dat` so it is served at: ```text diff --git a/docs/external/src/network-operator/funding-service.md b/docs/external/src/network-operator/funding-service.md new file mode 100644 index 0000000000..c4901bf507 --- /dev/null +++ b/docs/external/src/network-operator/funding-service.md @@ -0,0 +1,110 @@ +--- +title: "Funding Service" +sidebar_position: 8 +--- + +# Funding Service + +The funding service sends the chain's native asset to any account that asks for it. It owns one wallet account, which +holds the native asset, and creates a private pay-to-ID note for each request. + +A transaction pays its fee in the native asset out of the vault of the account that executes it. Infrastructure that +submits transactions therefore needs a source of that asset. On a network without a public faucet the funding service is +that source, and it gives an operator a single account to keep funded. + +## Provision the funding account + +The funding account is created at genesis. Add a named wallet to the genesis configuration: + +```toml +[[wallet]] +account_type = "public" +assets = [{ amount = 1_000_000_000_000, symbol = "MIDEN" }] +name = "funding_service" +``` + +The name makes `miden-validator genesis` write the account file to `/funding_service.mac` instead of +a name derived from the wallet's index, so the service can load it from a fixed path. The account must be public: the +service reads the account's vault and nonce back from the node, which only stores the full state of a public account. + +The amount is in base units of the native asset, which has six decimals. The example is one million MIDEN. Size it for +the lifetime of the network: on a development or test network a pre-funded balance large enough to last for years avoids +any manual top-up. Note that the total issuance of all genesis accounts must stay within the native faucet's maximum +supply. + +## Start + +```bash +miden-funding-service start \ + --listen 0.0.0.0:50401 \ + --rpc.url http://rpc-node:57291 \ + --tx-prover.url http://tx-prover:50051 \ + --account-file /opt/miden-funding-service/funding_service.mac \ + --validator-signing-public-key +``` + +| Option | Default | Purpose | +| -------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--listen` | required | Socket address of the gRPC API. | +| `--rpc.url` | required | The node RPC API the service reads from and submits to. | +| `--account-file` | required | Path to the funding account's `.mac` file. | +| `--validator-signing-public-key` | required | Hex-encoded validator signing public key trusted to attest the transaction encryption key. Repeat the flag, or pass a comma separated list, to trust more than one key. | +| `--tx-prover.url` | none | Remote transaction prover. Without it the service proves in process. | +| `--max-amount` | `1000000000` | Largest amount one request may ask for, in base units. | +| `--max-notes-per-tx` | `16` | Largest number of notes one transaction creates. Must not exceed 100. | +| `--tx-expiration-delta` | `50` | Blocks after its reference block at which a funding transaction expires. | +| `--poll-interval` | `1s` | How often the service asks the node whether its notes are committed. | +| `--grpc.timeout` | `5m` | Largest duration allocated to one gRPC request. | +| `--rpc.timeout` | `10s` | Timeout of a request to the node. | +| `--tx-prover.timeout` | `1m` | Timeout of a request to the remote prover. | + +A `RequestFunds` call blocks until the note is committed, so `--grpc.timeout` must exceed the proving time plus the +expiration window (`--tx-expiration-delta` multiplied by the chain's block interval). Raise it where proving is slow. A +client must set a matching deadline of its own. + +Every option also reads from an environment variable named `MIDEN_FUNDING_