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..d092c4e8e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3972,6 +3972,37 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miden-funding-service" +version = "0.16.0-rc.5" +dependencies = [ + "anyhow", + "backon", + "clap", + "futures", + "hex", + "humantime", + "miden-node-proto", + "miden-node-proto-build", + "miden-node-tracing", + "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", + "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/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/funding-service/Cargo.toml b/bin/funding-service/Cargo.toml new file mode 100644 index 0000000000..29916fab58 --- /dev/null +++ b/bin/funding-service/Cargo.toml @@ -0,0 +1,49 @@ +[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 } +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 } +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-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 new file mode 100644 index 0000000000..f4df522f76 --- /dev/null +++ b/bin/funding-service/README.md @@ -0,0 +1,24 @@ +# 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 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 account is refilled by sending it a public pay-to-ID note that holds the native asset. The service scans for those +notes and consumes them on its own. + +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 never mints. + +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..04a16de15a --- /dev/null +++ b/bin/funding-service/src/account.rs @@ -0,0 +1,143 @@ +//! Loading of the funding account. + +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 +// ================================================================================================ + +/// The identity of the funding account, loaded from its account file. +#[derive(Clone, Debug)] +pub struct FunderKey { + account_id: AccountId, + secret_key: SecretKey, + code_commitment: Word, +} + +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()))?; + + let secret_key = account_file + .auth_secret_keys + .iter() + .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", + 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(), + 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)] +mod tests { + use miden_protocol::ONE; + use miden_protocol::account::auth::AuthScheme; + use miden_protocol::account::{Account, AccountType}; + 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.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 + /// 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..679c62b5c4 --- /dev/null +++ b/bin/funding-service/src/commands/mod.rs @@ -0,0 +1,230 @@ +use std::net::SocketAddr; +use std::num::{NonZeroU16, NonZeroUsize}; +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_MAX_NOTES_PER_TX, + DEFAULT_POLL_INTERVAL, + DEFAULT_RPC_TIMEOUT, + DEFAULT_TOP_UP_INTERVAL, + 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; + +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_TOP_UP_INTERVAL: &str = "MIDEN_FUNDING_TOP_UP_INTERVAL"; +const ENV_VALIDATOR_SIGNING_PUBLIC_KEYS: &str = "MIDEN_FUNDING_VALIDATOR_SIGNING_PUBLIC_KEYS"; + +#[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, + + /// 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, + + /// 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, + + /// 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, + + /// Interval at which the service collects deposits sent to the funding account. + #[arg( + long = "top-up-interval", + env = ENV_TOP_UP_INTERVAL, + default_value = duration_to_human_readable_string(DEFAULT_TOP_UP_INTERVAL), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + top_up_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, + }, +} + +impl FundingServiceCommand { + pub async fn handle(self, shutdown: CancellationToken) -> Result<()> { + let Self::Start { + listen, + 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, + top_up_interval, + validator_signing_public_keys, + } = 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(), + 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_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(), + funding_service.top_up_interval = + humantime::Duration::from(top_up_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, 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) + .with_top_up_interval(top_up_interval) + .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") + } +} + +/// 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 new file mode 100644 index 0000000000..6b92cff4bd --- /dev/null +++ b/bin/funding-service/src/lib.rs @@ -0,0 +1,352 @@ +//! The service owns one wallet account which holds the chain's native asset, and sends that asset +//! 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; + +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 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::top_up::TopUp; +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 top_up; +mod tx; +mod worker; + +// 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 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 interval between two collections of deposits sent to the funding account. +pub const DEFAULT_TOP_UP_INTERVAL: Duration = Duration::from_secs(60); + +/// 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 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 +// ================================================================================================= + +/// The configuration of the funding service. +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, + top_up_interval: Duration, +} + +impl FundingServiceConfig { + /// Creates a configuration with default timeouts and limits. + 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, + top_up_interval: DEFAULT_TOP_UP_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; + self + } + + #[must_use] + pub fn with_rpc_timeout(mut self, timeout: Duration) -> Self { + self.rpc_timeout = timeout; + 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 + } + + #[must_use] + pub fn with_top_up_interval(mut self, interval: Duration) -> Self { + self.top_up_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, + 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 = 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, + top_up_interval: self.top_up_interval, + }) + } +} + +// FUNDING SERVICE +// ================================================================================================= + +/// 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, + top_up_interval: Duration, +} + +impl FundingService { + /// Runs the gRPC server, the status refresher and the funding worker 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 (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(requests, 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.clone(), + self.funder_key.account_id(), + self.fee_faucet_id, + status.clone(), + 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") + }); + + let top_up = TopUp::new( + self.node.clone(), + self.prover.clone(), + self.funder_key.clone(), + self.fee_faucet_id, + self.worker_config.expiration_delta, + self.top_up_interval, + ); + let top_up_shutdown = shutdown.clone(); + tasks.spawn("top-up", async move { + top_up.run(top_up_shutdown).await.context("the funding account top-up 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 + .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..b8dfee4925 --- /dev/null +++ b/bin/funding-service/src/node.rs @@ -0,0 +1,645 @@ +//! Node access. The RPC handling is copied from the network monitor. + +use std::collections::{HashMap, HashSet}; +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, + BlockRange, + FinalityLevel, + SyncChainMmrRequest, + SyncNotesRequest, + SyncNullifiersRequest, +}; +use miden_node_proto::generated::transaction::ProvenTransaction as ProtoProvenTransaction; +use miden_node_tracing::warn; +use miden_node_utils::retry::{self, 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::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; +use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta, MmrPeaks, PartialMmr}; +use miden_protocol::note::{Note, NoteId, NoteInclusionProof, NoteTag, Nullifier}; +use miden_protocol::transaction::{PartialBlockchain, ProvenTransaction}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use tokio::sync::Mutex; +use url::Url; + +use crate::COMPONENT; + +// RPC NODE CLIENT +// ================================================================================================ + +/// 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 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?; + + 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. + 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 + } + + /// 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() + } + + /// The IDs of the committed notes which carry `tag`, from `from_block` up to the chain tip. + pub async fn sync_note_ids( + &self, + tag: NoteTag, + from_block: BlockNumber, + ) -> Result { + let tip = self.chain_tip().await?; + // An empty range is rejected, and there is nothing to scan past the tip anyway. + if from_block > tip { + return Ok(SyncedNotes { + note_ids: Vec::new(), + last_checked_block: tip, + }); + } + + let response = self + .rpc_client + .clone() + .sync_notes(SyncNotesRequest { + block_range: Some(BlockRange { + block_from: from_block.as_u32(), + block_to: tip.as_u32(), + }), + note_tags: vec![u32::from(tag)], + }) + .await + .context("failed to synchronize notes")? + .into_inner(); + + let last_checked_block = response + .pagination_info + .context("the sync_notes response did not include pagination information")? + .block_num + .into(); + + let mut note_ids = Vec::new(); + for block in response.blocks { + for record in block.notes { + let proof = record + .inclusion_proof + .context("a note sync record did not include an inclusion proof")?; + let note_id = + proof.note_id.context("a note inclusion proof did not include a note ID")?; + note_ids + .push(NoteId::try_from(note_id).context("failed to convert a synced note ID")?); + } + } + + Ok(SyncedNotes { note_ids, last_checked_block }) + } + + /// The notes among `note_ids` whose details the node stores, with their inclusion proofs. + pub async fn public_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 notes from RPC")? + .into_inner(); + + let mut notes = Vec::new(); + for committed in response.notes { + let holds_details = committed.note.as_ref().is_some_and(|note| note.details.is_some()); + if !holds_details { + continue; + } + + notes.push( + <(Note, NoteInclusionProof)>::try_from(committed) + .context("failed to convert a committed note")?, + ); + } + + Ok(notes) + } + + /// The nullifiers among `nullifiers` which are already spent. + pub async fn spent_nullifiers(&self, nullifiers: &[Nullifier]) -> Result> { + if nullifiers.is_empty() { + return Ok(HashSet::new()); + } + + let tip = self.chain_tip().await?; + // The node matches on prefixes, so the response holds every nullifier which shares a prefix + // with one of ours. The exact matches are picked out below. + let prefixes = nullifiers + .iter() + .map(|nullifier| u32::from(nullifier.prefix())) + .collect::>() + .into_iter() + .collect(); + + let response = self + .rpc_client + .clone() + .sync_nullifiers(SyncNullifiersRequest { + block_range: Some(BlockRange { + block_from: BlockNumber::GENESIS.as_u32(), + block_to: tip.as_u32(), + }), + prefix_len: NULLIFIER_PREFIX_LEN, + nullifiers: prefixes, + }) + .await + .context("failed to synchronize nullifiers")? + .into_inner(); + + let requested: HashSet = nullifiers.iter().copied().collect(); + let mut spent = HashSet::new(); + for update in response.nullifiers { + let nullifier = + update.nullifier.context("a nullifier update did not include a nullifier")?; + let nullifier: Nullifier = + nullifier.try_into().context("failed to convert a nullifier")?; + if requested.contains(&nullifier) { + spent.insert(nullifier); + } + } + + Ok(spent) + } + + /// 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) + } +} + +/// The only nullifier prefix length the node supports. +const NULLIFIER_PREFIX_LEN: u32 = 16; + +/// The result of one note synchronization. +pub struct SyncedNotes { + /// The notes which carry the requested tag. + pub note_ids: Vec, + /// The last block the node checked. A scan resumes from the next block. + pub last_checked_block: BlockNumber, +} + +// 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 +// ================================================================================================ + +/// 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/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 new file mode 100644 index 0000000000..53f64eb6a4 --- /dev/null +++ b/bin/funding-service/src/server.rs @@ -0,0 +1,119 @@ +//! 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::sync::mpsc; +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; +use crate::worker::FundingRequest; + +mod request_funds; +mod status; + +// FUNDING SERVICE RPC SERVER +// ================================================================================================ + +/// The gRPC service of the funding service. +/// +/// 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( + requests: mpsc::Sender, + status: StatusSnapshot, + request_timeout: Duration, + ) -> Self { + Self { requests, 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 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(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 new file mode 100644 index 0000000000..561f50e50f --- /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, _rx) = 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, _rx) = 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/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/top_up.rs b/bin/funding-service/src/top_up.rs new file mode 100644 index 0000000000..db61e9ccf5 --- /dev/null +++ b/bin/funding-service/src/top_up.rs @@ -0,0 +1,426 @@ +//! Top-up of the funding account. +//! +//! The service spends from one account and never mints, so that account has to be refilled. An +//! operator refills it by sending a public pay-to-ID note which holds the native asset to the +//! funding account. + +use std::num::NonZeroU16; +use std::time::Duration; + +use anyhow::{Context, Result}; +use miden_node_tracing::spawn::spawn_blocking_in_current_span; +use miden_node_tracing::{info, warn}; +use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::Word; +use miden_protocol::account::AccountId; +use miden_protocol::account::auth::AuthSecretKey; +use miden_protocol::asset::Asset; +use miden_protocol::block::BlockNumber; +use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; +use miden_protocol::note::{Note, NoteTag, NoteType}; +use miden_protocol::transaction::{ExecutedTransaction, InputNote, InputNotes, TransactionArgs}; +use miden_protocol::utils::serde::Serializable; +use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info}; +use miden_standards::note::{P2idNote, P2idNoteStorage}; +use miden_standards::tx_script::ExpirationTransactionScript; +use miden_tx::TransactionExecutor; +use miden_tx::auth::BasicAuthenticator; + +use crate::LOG_TARGET; +use crate::account::FunderKey; +use crate::data_store::InMemoryDataStore; +use crate::node::RpcNodeClient; +use crate::prover::Prover; +use crate::tx::ExecutionInputs; + +// DEPOSIT COLLECTION +// ================================================================================================ + +/// Finds deposits addressed to the funding account and consumes them. +pub struct TopUp { + node: RpcNodeClient, + prover: Prover, + key: FunderKey, + fee_faucet_id: AccountId, + expiration_delta: NonZeroU16, + interval: Duration, + /// The block the next scan starts at. + next_block: BlockNumber, + rng: RandomCoin, +} + +impl TopUp { + pub fn new( + node: RpcNodeClient, + prover: Prover, + key: FunderKey, + fee_faucet_id: AccountId, + expiration_delta: NonZeroU16, + interval: Duration, + ) -> Self { + Self { + node, + prover, + key, + fee_faucet_id, + expiration_delta, + interval, + next_block: BlockNumber::GENESIS, + rng: RandomCoin::new(Word::from(rand::random::<[u32; 4]>())), + } + } + + /// Collects deposits until the service shuts down. + pub async fn run(mut self, shutdown: CancellationToken) -> Result<()> { + loop { + tokio::select! { + () = tokio::time::sleep(self.interval) => {}, + () = shutdown.cancelled() => return Ok(()), + } + + if let Err(err) = self.collect().await { + warn!( + &err, + target: LOG_TARGET, + "Failed to collect deposits for the funding account" + ); + } + } + } + + /// Runs one collection: find the deposits, and consume them if there are any. + async fn collect(&mut self) -> Result<()> { + let deposits = self.find_deposits().await?; + if deposits.is_empty() { + return Ok(()); + } + + let total: u64 = deposits.iter().map(|note| native_amount(note, self.fee_faucet_id)).sum(); + info!( + target: LOG_TARGET, + "Collecting deposits for the funding account", + account.id = self.key.account_id(), + note.count = deposits.len(), + asset.amount = total + ); + + self.consume(deposits).await + } + + /// Returns the deposits which the funding account can consume. + async fn find_deposits(&mut self) -> Result> { + let tag = NoteTag::with_account_target(self.key.account_id()); + let synced = self.node.sync_note_ids(tag, self.next_block).await?; + + if synced.note_ids.is_empty() { + self.next_block = synced.last_checked_block + 1; + return Ok(Vec::new()); + } + + let notes = self.node.public_notes(&synced.note_ids).await?; + let candidates: Vec = notes + .into_iter() + .map(|(note, _proof)| note) + .filter(|note| is_deposit(note, self.key.account_id(), self.fee_faucet_id)) + .collect(); + + if candidates.is_empty() { + self.next_block = synced.last_checked_block + 1; + return Ok(Vec::new()); + } + + // A rescan sees deposits which an earlier collection already consumed. Consuming one twice + // the transaction, so the spent ones are dropped here. + let nullifiers: Vec<_> = candidates.iter().map(Note::nullifier).collect(); + let spent = self.node.spent_nullifiers(&nullifiers).await?; + let unspent: Vec = candidates + .into_iter() + .filter(|note| !spent.contains(¬e.nullifier())) + .collect(); + + // The cursor only advances once the notes of that range have been collected, so a failure + // to read them does not skip a deposit. + self.next_block = synced.last_checked_block + 1; + + Ok(unspent) + } + + /// Consumes the deposits in one transaction. + async fn consume(&mut self, deposits: Vec) -> Result<()> { + let (reference_header, blockchain) = self.node.tip_chain_state().await?; + let reference_block = reference_header.block_num(); + let account_id = self.key.account_id(); + + let (funder, _witness) = self.node.public_account(account_id, reference_block).await?; + let fee_faucet = self.node.public_account(self.fee_faucet_id, reference_block).await?; + + let inputs = ExecutionInputs { + funder, + secret_key: self.key.secret_key().clone(), + fee_faucet, + reference_header, + blockchain, + expiration_delta: self.expiration_delta, + }; + + let executed_tx = execute_deposit_tx(inputs, deposits, &mut self.rng) + .await + .context("failed to execute the deposit transaction")?; + let transaction_inputs = executed_tx.tx_inputs().to_bytes(); + + let proven_tx = self + .prover + .prove(executed_tx) + .await + .context("failed to prove the deposit transaction")?; + let transaction_id = proven_tx.id(); + + self.node + .submit(&proven_tx, &transaction_inputs) + .await + .context("failed to submit the deposit transaction")?; + + info!( + target: LOG_TARGET, + "Submitted a deposit transaction", + transaction.id = transaction_id, + block.number = reference_block + ); + + Ok(()) + } +} + +// TRANSACTION +// ================================================================================================ + +/// Executes the transaction which consumes `deposits`. +/// +/// The transaction pays its own fee. That works even when the funding account's balance is zero, +/// because the assets of an input note land before the kernel withdraws the fee, which is exactly +/// the case this collection has to recover from. +pub async fn execute_deposit_tx( + inputs: ExecutionInputs, + deposits: Vec, + rng: &mut RandomCoin, +) -> Result { + let ExecutionInputs { + funder, + secret_key, + fee_faucet, + reference_header, + blockchain, + expiration_delta, + } = inputs; + + let account_id = funder.id(); + let reference_block = reference_header.block_num(); + let fee_faucet_id = reference_header.fee_parameters().fee_faucet_id(); + let tx_args = build_tx_args(fee_faucet_id, expiration_delta, rng); + + let input_notes = + InputNotes::new(deposits.into_iter().map(InputNote::unauthenticated).collect()) + .context("failed to build the deposit input notes")?; + + 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, + input_notes, + tx_args, + )) + .context("failed to execute the deposit transaction") + }) + .await + .context("the deposit transaction task failed")? +} + +/// Builds the arguments of the deposit transaction. +/// +/// The transaction runs no script of its own: consuming a note runs the note's script. The +/// expiration script is attached so the transaction cannot sit in the mempool indefinitely, and the +/// auth args tell the account's auth procedure how to convert the fee. +fn build_tx_args( + fee_faucet_id: AccountId, + expiration_delta: NonZeroU16, + rng: &mut RandomCoin, +) -> TransactionArgs { + let script = ExpirationTransactionScript::new(expiration_delta); + let script_args = script.tx_script_args(); + let mut tx_args = + TransactionArgs::default().with_tx_script_and_args(script.into(), script_args); + + 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)]); + + tx_args +} + +// DEPOSIT FILTER +// ================================================================================================ + +/// Returns `true` when the note is a deposit which `funder` can consume. +/// +/// A deposit is a public pay-to-ID note which targets `funder` and holds nothing but the native +/// asset. Only the native asset is collected, because a note holding anything else would put an +/// asset the service cannot spend into the vault. +fn is_deposit(note: &Note, funder: AccountId, fee_faucet_id: AccountId) -> bool { + if note.metadata().note_type() != NoteType::Public { + return false; + } + if note.recipient().script().root() != P2idNote::script_root() { + return false; + } + + let targets_funder = + P2idNoteStorage::try_from(note.recipient().storage().to_elements().as_slice()) + .is_ok_and(|storage| storage.target() == funder); + if !targets_funder { + return false; + } + + note.assets().num_assets() == 1 && native_amount(note, fee_faucet_id) > 0 +} + +/// The amount of the native asset the note holds. +fn native_amount(note: &Note, fee_faucet_id: AccountId) -> u64 { + note.assets() + .iter() + .filter_map(|asset| match asset { + Asset::Fungible(asset) if asset.faucet_id() == fee_faucet_id => { + Some(asset.amount().as_u64()) + }, + Asset::Fungible(_) | Asset::NonFungible(_) => None, + }) + .sum() +} + +#[cfg(test)] +mod tests { + use miden_protocol::asset::{AssetId, FungibleAsset}; + + use super::*; + use crate::test_utils::{Fixture, TEST_BASE_FEE, genesis_style_wallet}; + + const EXPIRATION: NonZeroU16 = NonZeroU16::new(20).expect("literal is non-zero"); + + /// Builds a public P2ID note which holds `amount` of `faucet_id` and targets `target`. + fn deposit_note( + target: AccountId, + faucet_id: AccountId, + amount: u64, + serial: u32, + note_type: NoteType, + ) -> Note { + P2idNote::builder() + .sender(target) + .target(target) + .asset(FungibleAsset::new(faucet_id, amount).expect("valid asset")) + .note_type(note_type) + .serial_number(Word::from([serial; 4])) + .build() + .expect("the note should build") + .into() + } + + /// A public P2ID note holding the native asset and targeting the funder is a deposit. + #[test] + fn a_native_asset_note_for_the_funder_is_a_deposit() { + let funder = FungibleAsset::mock_issuer(); + let note = deposit_note(funder, funder, 5_000, 1, NoteType::Public); + + assert!(is_deposit(¬e, funder, funder)); + assert_eq!(native_amount(¬e, funder), 5_000); + } + + /// Only the native asset is collected: anything else would leave an asset in the vault which + /// the service cannot spend. + #[test] + fn a_note_holding_another_asset_is_skipped() { + let funder = FungibleAsset::mock_issuer(); + let (other, _) = genesis_style_wallet(funder, 0, [3; 32]).expect("wallet should build"); + let note = deposit_note(funder, other.id(), 5_000, 2, NoteType::Public); + + assert!(!is_deposit(¬e, funder, funder)); + assert_eq!(native_amount(¬e, funder), 0); + } + + /// The note tag only encodes the leading bits of an account ID, so notes for other accounts + /// reach the collection and must be filtered by their target. + #[test] + fn a_note_for_another_account_is_skipped() { + let funder = FungibleAsset::mock_issuer(); + let (other, _) = genesis_style_wallet(funder, 0, [5; 32]).expect("wallet should build"); + let note = deposit_note(other.id(), funder, 5_000, 3, NoteType::Public); + + assert!(!is_deposit(¬e, funder, funder)); + } + + /// The node stores no details for a private note, so it cannot be consumed. + #[test] + fn a_private_note_is_skipped() { + let funder = FungibleAsset::mock_issuer(); + let note = deposit_note(funder, funder, 5_000, 4, NoteType::Private); + + assert!(!is_deposit(¬e, funder, funder)); + } + + /// The deposit transaction pays its own fee from the assets the deposit brings in, so it works + /// at a zero balance. That is the state the collection exists to recover from. + #[tokio::test] + async fn a_deposit_is_consumed_at_a_zero_balance() -> Result<()> { + let fixture = Fixture::new(0, TEST_BASE_FEE)?; + let mut rng = RandomCoin::new(Word::from([29u32; 4])); + let deposit = deposit_note( + fixture.funder.id(), + fixture.fee_faucet_id, + 5_000_000, + 7, + NoteType::Public, + ); + + let inputs = { + let chain = fixture.chain.lock().await; + let reference_header = chain.latest_block_header(); + let witness = chain + .account_witnesses([fixture.fee_faucet_id]) + .remove(&fixture.fee_faucet_id) + .context("a witness was requested for the faucet")?; + + ExecutionInputs { + funder: chain.committed_account(fixture.funder.id())?.clone(), + secret_key: fixture.funder_key.secret_key().clone(), + fee_faucet: (chain.committed_account(fixture.fee_faucet_id)?.clone(), witness), + reference_header, + blockchain: chain.latest_partial_blockchain(), + expiration_delta: EXPIRATION, + } + }; + + let executed_tx = execute_deposit_tx(inputs, vec![deposit], &mut rng).await?; + + // The deposit's assets land in the vault, less the fee the transaction pays from them. + let mut updated = fixture.funder.clone(); + updated.apply_patch(executed_tx.account_patch())?; + let balance = updated + .vault() + .get_balance(AssetId::new_fungible(fixture.fee_faucet_id))? + .as_u64(); + + assert!(balance > 0, "the deposit must raise the balance above zero"); + assert!(balance < 5_000_000, "the transaction must pay its fee from the deposit"); + + Ok(()) + } +} 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/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/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/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/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..bc033ae162 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,9 @@ const STRING_FIELD_NAMES: &[&str] = &[ "block.interval", "dependency.endpoint", "dependency.name", + "funding_service.listen", + "funding_service.poll_interval", + "funding_service.top_up_interval", "genesis.source", "genesis.source.kind", "grpc.timeout", @@ -130,6 +141,7 @@ const STRING_FIELD_NAMES: &[&str] = &[ "rpc.timeout", "sequencer.endpoint", "service.name", + "service.readiness.reason", "service.version", "shutdown.signal", "sync.block_source.endpoint", @@ -296,7 +308,8 @@ impl RecordAttribute for Option { } impl RecordAttribute for Path { - const FIELD_NAMES: &'static [&'static str] = &["data.directory", "genesis.file", "path"]; + const FIELD_NAMES: &'static [&'static str] = + &["account.file", "data.directory", "genesis.file", "path"]; fn record_attribute(&self) -> impl Value + '_ { tracing::field::display(self.display()) @@ -352,6 +365,7 @@ impl_display_attribute!( AccountId, &[ "account.id", + "asset.faucet_id", "counter.account.id.new", "counter.account.id.old", "note.sender", diff --git a/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..99dddb9d20 --- /dev/null +++ b/docs/external/src/network-operator/funding-service.md @@ -0,0 +1,131 @@ +--- +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. | +| `--top-up-interval` | `1m` | How often the service scans for deposits sent to the funding account. | +| `--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_