From 7e423e1f7f9d54464cd057510c7c8835a4016933 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Wed, 9 Sep 2026 16:13:53 -0300 Subject: [PATCH 1/7] 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 | 10 +- bin/network-monitor/src/deploy/mod.rs | 66 ++--- bin/network-monitor/src/faucet.rs | 2 + bin/network-monitor/src/funding.rs | 267 ++++++++++-------- bin/network-monitor/src/monitor/tasks.rs | 10 +- bin/network-monitor/src/remote_prover.rs | 12 +- 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, 451 insertions(+), 169 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 8236c1e488..a8225e2aff 100644 --- a/bin/network-monitor/README.md +++ b/bin/network-monitor/README.md @@ -30,9 +30,9 @@ unless it can verify the advertised encryption key. The monitor obtains the acti configuration returned by RPC and verifies it against the transaction's reference block. 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 fbb252bc97..135a63d8e4 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -52,7 +52,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, FundingClient, wallet_funding_amount, wallet_topup_threshold}; use crate::service::Service; use crate::status::{ CounterTrackingDetails, @@ -195,8 +195,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, } @@ -213,7 +213,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; @@ -487,7 +487,7 @@ impl IncrementService { account.id = self.tx.wallet_account.id(), asset.balance = balance ); - let mut funder = FeeFunder::new(funding, self.rpc_client.clone(), fee_faucet_id); + let mut funder = FeeFunder::new(funding, 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 e5905844f1..59e726341a 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -87,7 +87,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, FundingClient, counter_funding_amount, wallet_funding_amount}; use crate::{COMPONENT, LOG_TARGET}; pub mod counter; @@ -324,7 +324,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"); @@ -333,8 +333,7 @@ pub async fn create_and_deploy_accounts( let funding_anchor = fetch_tip_chain_state(&mut rpc_client, submission_client.genesis_commitment).await?; let fee_faucet_id = funding_anchor.protocol_config.fee_asset_id().faucet_id(); - let mut funder = - active_fee_funder(&funding_anchor.block_header, funding, &rpc_client, fee_faucet_id)?; + let mut funder = active_fee_funder(&funding_anchor.block_header, funding, fee_faucet_id)?; let verification_base_fee = funding_anchor.block_header.fee_parameters().verification_base_fee(); @@ -407,27 +406,29 @@ 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. +/// +/// The fee parameters belong to `reference_header`, so the caller decides which block answers +/// whether the chain charges fees. pub fn active_fee_funding<'a>( - genesis_header: &BlockHeader, - funding: Option<&'a FaucetClient>, -) -> Result> { - if genesis_header.fee_parameters().verification_base_fee() == 0 { + reference_header: &BlockHeader, + funding: Option<&'a FundingClient>, +) -> Result> { + if reference_header.fee_parameters().verification_base_fee() == 0 { return Ok(None); } funding.map(Some).context(UnsupportedChainError) @@ -435,15 +436,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 active fee faucet ID. +/// The funder binds the funding service client to the chain's active fee faucet ID. pub fn active_fee_funder( - genesis_header: &BlockHeader, - funding: Option<&FaucetClient>, - rpc_client: &RpcClient, + reference_header: &BlockHeader, + funding: Option<&FundingClient>, fee_faucet_id: AccountId, ) -> Result> { - let funder = active_fee_funding(genesis_header, funding)? - .map(|faucet| FeeFunder::new(faucet.clone(), rpc_client.clone(), fee_faucet_id)); + let funder = active_fee_funding(reference_header, funding)? + .map(|client| FeeFunder::new(client.clone(), fee_faucet_id)); Ok(funder) } @@ -894,7 +894,7 @@ fn counter_creation_tx_args(counter_account: &Account) -> Result, + funding: Option<&FundingClient>, ) -> Result { let (wallet_account, _secret_key) = create_wallet_account()?; @@ -902,7 +902,7 @@ pub async fn build_probe_transaction_inputs( create_genesis_aware_rpc_client(rpc_url, Duration::from_secs(10)).await?; let anchor = fetch_tip_chain_state(&mut rpc_client, genesis_commitment).await?; let fee_faucet_id = anchor.protocol_config.fee_asset_id().faucet_id(); - let mut funder = active_fee_funder(&anchor.block_header, funding, &rpc_client, fee_faucet_id)?; + let mut funder = active_fee_funder(&anchor.block_header, funding, fee_faucet_id)?; let verification_base_fee = anchor.block_header.fee_parameters().verification_base_fee(); let counter_account = create_counter_account(wallet_account.id(), fee_faucet_id, verification_base_fee)?; @@ -1182,7 +1182,7 @@ mod tests { use super::{ DataStore, - FaucetClient, + FundingClient, MonitorDataStore, active_fee_funding, decode_chain_state, @@ -1190,12 +1190,12 @@ mod tests { }; use crate::deploy::wallet::create_wallet_account; - /// 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 = FundingClient::new( + url::Url::parse("http://funding.invalid").expect("static URL is valid"), Duration::from_secs(1), ); @@ -1211,19 +1211,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 3da1b6f25a..96202bdd50 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 50a9878b9f..0e492d830e 100644 --- a/bin/network-monitor/src/funding.rs +++ b/bin/network-monitor/src/funding.rs @@ -2,20 +2,22 @@ //! //! 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::rpc::NotesByIdRequest; -use miden_node_proto::{DecodeMessage, Verify}; -use miden_node_tracing::{info, warn}; +use miden_node_tracing::info; use miden_protocol::account::AccountId; -use miden_protocol::note::{Note, NoteId}; +use miden_protocol::note::Note; +use miden_protocol::utils::serde::Deserializable; use reqwest::Client; +use serde::{Deserialize, Serialize}; use url::Url; use crate::LOG_TARGET; @@ -34,12 +36,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 +49,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 +59,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 +91,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,50 +124,118 @@ impl FaucetClient { } } +/// Builds the funding service client when its URL is configured. +pub fn funding_client_from_config(config: &MonitorConfig) -> Option { + let url = config.funding_service_url.clone()?; + + Some(FundingClient::new(url, config.funding_request_timeout)) +} + +// FUNDING CLIENT +// ================================================================================================ + +/// The path of the funding service's funding endpoint. +const REQUEST_FUNDS_PATH: &str = "request-funds"; + +/// The body of a funding request. +#[derive(Debug, Deserialize, Serialize)] +struct RequestFundsRequest { + /// The account which the note targets, in hexadecimal. + account_id: String, + /// The amount of the native asset, in base units. + amount: u64, +} + +/// The body of a successful funding response. +#[derive(Debug, Deserialize, Serialize)] +struct RequestFundsResponse { + /// The serialized note, in hexadecimal. + note: String, + /// The serialized proof that the note is in a block, in hexadecimal. + inclusion_proof: String, + /// The transaction which created the note, in hexadecimal. + transaction_id: String, +} + +/// Requests the chain's fee asset from the funding service over its JSON HTTP API. +#[derive(Clone, Debug)] +pub struct FundingClient { + service_url: Url, + client: Client, +} + +impl FundingClient { + pub fn new(service_url: Url, request_timeout: Duration) -> Self { + let client = Client::builder() + .timeout(request_timeout) + .build() + .expect("Failed to create HTTP client with timeout"); + + Self { service_url, client } + } + + /// Requests `amount` base units for `account_id` and returns the committed note. + /// + /// The service answers only once the note is committed, so the caller needs no lookup. + async fn request_funds(&self, account_id: AccountId, amount: u64) -> Result { + let url = self + .service_url + .join(REQUEST_FUNDS_PATH) + .context("failed to build the funding service URL")?; + + let response = self + .client + .post(url) + .json(&RequestFundsRequest { account_id: account_id.to_hex(), amount }) + .send() + .await + .context("failed to reach the funding service")? + .error_for_status() + .context("the funding service rejected the request")? + .json::() + .await + .context("failed to read the response of the funding service")?; + + // The response carries the note in full, so the monitor does not look it up at the node. + let bytes = hex::decode(&response.note) + .context("the funding service returned a note which is not hexadecimal")?; + + Note::read_from_bytes(&bytes) + .context("failed to deserialize the note of the funding service") + } +} + /// 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 -/// active fee faucet ID, so callers fund an account from just an ID and an amount. +/// Binds the funding service client to the chain's fee asset, so callers fund an account from just +/// an ID and an amount. The fee faucet comes from the protocol configuration which the node serves. 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. pub async fn fund(&mut self, account_id: AccountId, amount: u64) -> Result { - let tokens = self - .faucet - .request_tokens(&account_id.to_string(), amount) - .await - .context("faucet token request failed")?; + let note = self.client.request_funds(account_id, amount).await?; - 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,60 +255,12 @@ pub(crate) fn ensure_note_carries_fee_asset(note: &Note, fee_faucet_id: AccountI 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(NotesByIdRequest { note_ids: vec![(¬e_id).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")? - .decode_fields() - .context("failed to decode the funding note")? - .verify() - .context("failed to verify 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; @@ -258,13 +269,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 \ @@ -278,10 +296,33 @@ 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. + #[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 c9a0e6d392..9e2b611e1f 100644 --- a/bin/network-monitor/src/remote_prover.rs +++ b/bin/network-monitor/src/remote_prover.rs @@ -24,7 +24,7 @@ use url::Url; use crate::COMPONENT; use crate::deploy::UnsupportedChainError; -use crate::funding::FaucetClient; +use crate::funding::FundingClient; use crate::service::{Service, build_tls_client}; use crate::service_status::{ ProverTestOutcome, @@ -91,8 +91,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 +136,7 @@ impl ProverStatusService { name: String, prover_url: Url, rpc_url: Url, - funding: Option, + funding: Option, interval: Duration, request_timeout: Duration, probe_interval: Duration, @@ -387,7 +387,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, @@ -538,7 +538,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 ccfe864952..9642d1e565 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -241,3 +241,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 786d9122b6..f36a629274 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,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. @@ -30,6 +31,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_