diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 00000000..175b7a8d --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,49 @@ +# Runs the faucet against a real node and funding service. + +name: test-e2e + +on: + push: + branches: [main, next] + pull_request: + types: [opened, reopened, synchronize] + +# Limits workflow concurrency to only the latest commit in the PR. +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test-e2e: + name: test-e2e + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@main + - name: Rustup + run: rustup update --no-self-update + - uses: Swatinem/rust-cache@v2 + with: + # `make test` builds the whole workspace in release, which covers everything this job + # compiles, so both share one cache instead of each filling its own. + shared-key: release + # Saving on a pull request too means a second push to the same branch does not recompile. + # GitHub scopes a branch's cache to that branch, so this does not disturb other runs. + save-if: true + + - name: Start the node and the funding service + run: scripts/e2e/network.sh up + + - name: Run the test + run: cargo test --release --locked -p miden-faucet-client --test e2e -- --ignored --nocapture + + - name: Network logs + if: failure() + run: scripts/e2e/network.sh logs + + - name: Stop everything + if: always() + run: scripts/e2e/network.sh down diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4624fd0..49619042 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,9 @@ jobs: run: rustup update --no-self-update - uses: Swatinem/rust-cache@v2 with: + # Shared with the end-to-end job, which builds the same release artifacts. + shared-key: release save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/next' }} - - uses: taiki-e/install-action@nextest + - uses: taiki-e/install-action@nextest - name: Run tests run: make test diff --git a/Cargo.lock b/Cargo.lock index c4a75c83..3c6dcb13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3441,6 +3441,8 @@ dependencies = [ "hex", "miden-client", "miden-client-cli", + "miden-client-sqlite-store", + "miden-faucet", "miden-faucet-lib", "miden-pow-rate-limiter", "rand 0.10.3", diff --git a/Cargo.toml b/Cargo.toml index a1af96b8..0cc7328f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ version = "0.17.1" opt-level = 2 [workspace.dependencies] +miden-faucet = { path = "bin/faucet", version = "0.17" } miden-faucet-lib = { path = "crates/faucet", version = "0.17" } miden-pow-rate-limiter = { path = "crates/pow", version = "0.17" } diff --git a/Makefile b/Makefile index 25aeda96..5bc3b465 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: help help: - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' # -- variables ------------------------------------------------------------------------------------ @@ -78,6 +78,16 @@ book: ## Builds the book & serves documentation site test: ## Runs all tests cargo nextest run --release --all-features --workspace +# The end-to-end test starts a real node, funding service and faucet, requests tokens and consumes +# the resulting note. +.PHONY: test-e2e +test-e2e: ## Runs the end-to-end test against a real node and funding service + scripts/e2e/network.sh up + cargo test --release --locked -p miden-faucet-client --test e2e -- --ignored --nocapture; \ + status=$$?; \ + scripts/e2e/network.sh down; \ + exit $$status + # --- checking ------------------------------------------------------------------------------------ .PHONY: check diff --git a/bin/faucet-client/Cargo.toml b/bin/faucet-client/Cargo.toml index 2f4cd26d..e5c5f937 100644 --- a/bin/faucet-client/Cargo.toml +++ b/bin/faucet-client/Cargo.toml @@ -29,7 +29,9 @@ tokio = { features = ["macros", "net", "rt-multi-thread", "sync url = { workspace = true } [dev-dependencies] -axum = { features = ["tokio"], version = "0.8" } -miden-client = { features = ["testing"], workspace = true } -serde = { workspace = true } -tokio = { features = ["macros", "net", "rt-multi-thread", "time"], workspace = true } +axum = { features = ["tokio"], version = "0.8" } +miden-client = { features = ["testing"], workspace = true } +miden-client-sqlite-store = { workspace = true } +miden-faucet = { workspace = true } +serde = { workspace = true } +tokio = { features = ["macros", "net", "rt-multi-thread", "time"], workspace = true } diff --git a/bin/faucet-client/src/mint.rs b/bin/faucet-client/src/mint.rs index e1ec5889..e259c2eb 100644 --- a/bin/faucet-client/src/mint.rs +++ b/bin/faucet-client/src/mint.rs @@ -26,7 +26,7 @@ use tokio::task; const DEFAULT_FAUCET_URL: &str = "https://faucet-api.testnet.miden.io"; const REQUEST_TIMEOUT_MS: u64 = 30_000; -const MAX_SYNC_RETRIES: u32 = 3; +const MAX_SYNC_RETRIES: u32 = 10; const SYNC_RETRY_DELAY_SECS: u64 = 5; // CLI @@ -166,7 +166,7 @@ impl MintCmd { /// HTTP client for interacting with the faucet API. #[derive(Clone)] -struct FaucetHttpClient { +pub struct FaucetHttpClient { http_client: HttpClient, base_url: Url, api_key: Option, @@ -174,7 +174,7 @@ struct FaucetHttpClient { impl FaucetHttpClient { /// Creates a new `FaucetHttpClient` instance. - fn new( + pub fn new( endpoint: &str, timeout_ms: u64, api_key: Option, @@ -191,7 +191,7 @@ impl FaucetHttpClient { } /// Requests a `PoW` challenge from the faucet API. - async fn request_pow( + pub async fn request_pow( &self, account_id: &AccountId, amount: u64, @@ -231,7 +231,7 @@ impl FaucetHttpClient { } /// Requests tokens from the faucet API. - async fn request_tokens( + pub async fn request_tokens( &self, challenge: &str, nonce: u64, @@ -349,7 +349,7 @@ fn parse_account_id(input: &str) -> Result { /// Solves the `PoW` challenge and returns the nonce that satisfies the target. /// /// Heavy work runs on a blocking thread so we don't stall the async runtime. -async fn solve_challenge(challenge_hex: &str, target: u64) -> Result { +pub async fn solve_challenge(challenge_hex: &str, target: u64) -> Result { if target == 0 { return Err(MintClientError::ZeroTarget); } diff --git a/bin/faucet-client/tests/e2e.rs b/bin/faucet-client/tests/e2e.rs new file mode 100644 index 00000000..c0b8fedf --- /dev/null +++ b/bin/faucet-client/tests/e2e.rs @@ -0,0 +1,233 @@ +//! End-to-end test against a real node, funding service and faucet. +//! +//! Starts a faucet, creates an account, requests tokens for it, waits for the note to be committed, +//! consumes it and checks the balance. Ignored by default because it needs a node and a funding +//! service: `make test-e2e` starts them and runs it. + +use std::env::temp_dir; +use std::time::Duration; + +use miden_client::account::{AccountId, AccountType}; +use miden_client::builder::ClientBuilder; +use miden_client::keystore::FilesystemKeyStore; +use miden_client::note::{Note, NoteId}; +use miden_client::rpc::Endpoint; +use miden_client::testing::common::{AccountSetup, TestClient}; +use miden_client_sqlite_store::ClientBuilderSqliteExt; +use miden_faucet::network::FaucetNetwork; +use miden_faucet::{Cli, Command, FaucetConfig, run_faucet_command}; +use miden_faucet_client::mint::{FaucetHttpClient, solve_challenge}; + +const DEFAULT_FAUCET_URL: &str = "http://127.0.0.1:18000"; +const DEFAULT_FUNDING_SERVICE_URL: &str = "http://127.0.0.1:50401"; + +/// How long the faucet gets to reach the funding service and bind its API. +const FAUCET_ATTEMPTS: u32 = 60; +const FAUCET_DELAY: Duration = Duration::from_secs(1); + +const REQUEST_TIMEOUT_MS: u64 = 30_000; + +/// Consuming the note pays a fee out of the note itself, so a request too small to cover the fee +/// fails inside the transaction kernel. +const AMOUNT: u64 = 1_000_000; + +/// The funding service answers before the note is committed, so the note takes a few blocks to +/// arrive and a loaded network takes longer than a couple of them. +const NOTE_MAX_BLOCKS: u32 = 60; + +#[tokio::test] +#[ignore = "needs a node, funding service and faucet, run it with `make test-e2e`"] +async fn request_tokens_and_consume_the_note() { + let faucet_url = env_or("FAUCET_URL", DEFAULT_FAUCET_URL); + let node_url = env_or("NODE_URL", &Endpoint::localhost().to_string()); + let funding_service_url = env_or("FUNDING_SERVICE_URL", DEFAULT_FUNDING_SERVICE_URL); + + start_faucet(&faucet_url, &node_url, &funding_service_url).await; + + let mut client = build_client(&node_url).await; + + // Unfunded: the note the faucet sends is what pays for the transaction that consumes it, and + // funding would need a fee funder this client does not have. + let (account, _) = client + .insert_account(AccountSetup::wallet(AccountType::Public).unfunded()) + .await + .expect("the account should be created"); + let account_id = account.id(); + println!("Recipient: {}", account_id.to_hex()); + + let note_id = request_tokens(&faucet_url, account_id).await; + println!("Note: {}", note_id.to_hex()); + + let note = wait_for_note(&mut client, account_id, note_id).await; + // The note carries the asset of the faucet the funding service pays from, which is the balance + // to check once the note is consumed. + let faucet_id = note + .assets() + .iter_fungible() + .next() + .expect("the note should carry a fungible asset") + .faucet_id(); + + consume_note(&mut client, account_id, note).await; + + let balance = client + .account_reader(account_id) + .get_balance(faucet_id) + .await + .expect("the balance should be readable") + .as_u64(); + + // The consume transaction pays its fee out of the note, so the balance is the requested amount + // less that fee. + assert!(balance > 0, "the recipient holds no asset after consuming the note"); + assert!( + balance <= AMOUNT, + "the recipient holds {balance}, more than the {AMOUNT} requested" + ); + println!("Balance: {balance} of the {AMOUNT} requested, the rest paid the fee"); +} + +/// Starts a faucet against the node and the funding service, and waits for it to serve. +async fn start_faucet(faucet_url: &str, node_url: &str, funding_service_url: &str) { + let port = faucet_url + .rsplit(':') + .next() + .and_then(|port| port.parse().ok()) + .expect("the faucet url should carry a port"); + + let command = Command::Start { + config: FaucetConfig { + node_url: Some(node_url.parse().expect("the node url should be a url")), + timeout: Duration::from_secs(5), + network: FaucetNetwork::Localhost, + api_keys_path: None, + }, + funding_service_url: funding_service_url + .parse() + .expect("the funding service url should be a url"), + decimals: 6, + api_bind_port: port, + api_public_url: faucet_url.parse().expect("the faucet url should be a url"), + frontend_bind_port: 0, + no_frontend: true, + max_claimable_amount: 1_000_000_000, + pow_secret: Some("e2e".to_owned()), + pow_challenge_lifetime: Duration::from_secs(30), + pow_cleanup_interval: Duration::from_secs(1), + pow_growth_rate: 1.0, + pow_baseline: 12, + base_amount: 100_000, + open_telemetry: false, + explorer_url: None, + token_amounts: vec![1, 10, 100], + }; + + let faucet = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("the faucet runtime should build"); + + runtime.block_on(Box::pin(run_faucet_command(Cli { command }))) + }); + + let metadata_url = format!("{faucet_url}/get_metadata"); + for attempt in 1..=FAUCET_ATTEMPTS { + // A faucet that fails to start, e.g. because the funding service is unreachable, stops + // right away, so there is no point in waiting for it to answer. + if faucet.is_finished() { + let result = faucet.join().expect("the faucet thread should not panic"); + panic!("the faucet stopped before serving: {result:?}"); + } + + if reqwest::get(&metadata_url) + .await + .is_ok_and(|response| response.status().is_success()) + { + return; + } + + println!("Faucet not serving yet, attempt {attempt}/{FAUCET_ATTEMPTS}"); + tokio::time::sleep(FAUCET_DELAY).await; + } + + panic!("the faucet did not answer on {faucet_url}"); +} + +/// Builds a client with its own store and keystore, pointed at the test network. +async fn build_client(node_url: &str) -> TestClient { + // Emptied first, so no account or note from an earlier run can take part in the test. + let work_dir = temp_dir().join("miden-faucet-e2e"); + let _ = std::fs::remove_dir_all(&work_dir); + std::fs::create_dir_all(&work_dir).expect("the work directory should be created"); + + let endpoint = Endpoint::try_from(node_url).expect("the node url should be an endpoint"); + + let client = ClientBuilder::::new() + .grpc_client(&endpoint, Some(REQUEST_TIMEOUT_MS)) + .filesystem_keystore(work_dir.join("keys")) + .expect("the keystore should be created") + .sqlite_store(work_dir.join("store.sqlite3")) + .build() + .await + .expect("the client should connect to the node"); + + TestClient::new(client) +} + +/// Requests tokens the way the faucet client does: a challenge, a nonce that solves it, and the +/// request itself. +async fn request_tokens(faucet_url: &str, account_id: AccountId) -> NoteId { + let faucet = FaucetHttpClient::new(faucet_url, REQUEST_TIMEOUT_MS, None) + .expect("the faucet url should be valid"); + + let (challenge, target) = faucet + .request_pow(&account_id, AMOUNT) + .await + .expect("the faucet should answer with a challenge"); + let nonce = solve_challenge(&challenge, target) + .await + .expect("the challenge should be solvable"); + + faucet + .request_tokens(&challenge, nonce, &account_id, AMOUNT) + .await + .expect("the faucet should answer with a note") + .note_id +} + +/// Syncs until the note is committed and consumable by the account. +async fn wait_for_note(client: &mut TestClient, account_id: AccountId, note_id: NoteId) -> Note { + client.sync_state().await.expect("the client should sync"); + + let consumable = client + .wait_for_consumable_notes(account_id, NOTE_MAX_BLOCKS) + .await + .expect("the note should be committed"); + + let (record, _) = consumable + .into_iter() + .find(|(record, _)| record.id() == Some(note_id)) + .unwrap_or_else(|| { + panic!("the faucet's note {} is not the one that arrived", note_id.to_hex()) + }); + + record.try_into().expect("a committed note record holds its note") +} + +/// Consumes the note, which also creates the account on chain, and waits for the block holding it. +async fn consume_note(client: &mut TestClient, account_id: AccountId, note: Note) { + let transaction_id = client + .consume_notes(account_id, &[note]) + .await + .expect("the node should accept the consume transaction"); + + client + .wait_for_tx(transaction_id) + .await + .expect("the consume transaction should be committed"); +} + +fn env_or(variable: &str, default: &str) -> String { + std::env::var(variable).unwrap_or_else(|_| default.to_owned()) +} diff --git a/bin/faucet/src/lib.rs b/bin/faucet/src/lib.rs new file mode 100644 index 00000000..bc746247 --- /dev/null +++ b/bin/faucet/src/lib.rs @@ -0,0 +1,879 @@ +mod api; +mod api_key; +mod frontend; +mod funding_service_client; +pub mod logging; +pub mod network; +#[cfg(test)] +mod testing; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Context; +use clap::{Parser, Subcommand}; +use miden_faucet_lib::FaucetId; +use miden_faucet_lib::types::AssetAmount; +use miden_pow_rate_limiter::PoWRateLimiterConfig; +use miden_protocol::account::AccountId; +use rand::SeedableRng; +use rand::rngs::ChaCha20Rng; +use sha2::{Digest, Sha256}; +use tokio::task::JoinSet; +use url::Url; + +use crate::api::{ApiServer, Metadata}; +use crate::api_key::ApiKey; +use crate::frontend::serve_frontend; +use crate::funding_service_client::FundingServiceClient; +use crate::logging::OpenTelemetry; +use crate::network::FaucetNetwork; + +// CONSTANTS +// ================================================================================================= + +const COMPONENT: &str = "miden-faucet-server"; +const DEFAULT_API_KEYS_PATH: &str = "api_keys.txt"; + +const ENV_API_BIND_PORT: &str = "MIDEN_FAUCET_API_BIND_PORT"; +const ENV_API_PUBLIC_URL: &str = "MIDEN_FAUCET_API_PUBLIC_URL"; +const ENV_FRONTEND_BIND_PORT: &str = "MIDEN_FAUCET_FRONTEND_BIND_PORT"; +const ENV_NO_FRONTEND: &str = "MIDEN_FAUCET_NO_FRONTEND"; +const ENV_NETWORK: &str = "MIDEN_FAUCET_NETWORK"; +const ENV_NODE_URL: &str = "MIDEN_FAUCET_NODE_URL"; +const ENV_TIMEOUT: &str = "MIDEN_FAUCET_TIMEOUT"; +const ENV_MAX_CLAIMABLE_AMOUNT: &str = "MIDEN_FAUCET_MAX_CLAIMABLE_AMOUNT"; +const ENV_POW_SECRET: &str = "MIDEN_FAUCET_POW_SECRET"; +const ENV_POW_CHALLENGE_LIFETIME: &str = "MIDEN_FAUCET_POW_CHALLENGE_LIFETIME"; +const ENV_POW_CLEANUP_INTERVAL: &str = "MIDEN_FAUCET_POW_CLEANUP_INTERVAL"; +const ENV_POW_GROWTH_RATE: &str = "MIDEN_FAUCET_POW_GROWTH_RATE"; +const ENV_POW_BASELINE: &str = "MIDEN_FAUCET_POW_BASELINE"; +const ENV_BASE_AMOUNT: &str = "MIDEN_FAUCET_BASE_AMOUNT"; +const ENV_ENABLE_OTEL: &str = "MIDEN_FAUCET_ENABLE_OTEL"; +const ENV_API_KEYS: &str = "MIDEN_FAUCET_API_KEYS"; +const ENV_DECIMALS: &str = "MIDEN_FAUCET_DECIMALS"; +const ENV_EXPLORER_URL: &str = "MIDEN_FAUCET_EXPLORER_URL"; +const ENV_FUNDING_SERVICE_URL: &str = "MIDEN_FAUCET_FUNDING_SERVICE_URL"; +const ENV_TOKEN_AMOUNTS: &str = "MIDEN_FAUCET_TOKEN_AMOUNTS"; + +// COMMANDS +// ================================================================================================ + +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Subcommand)] +pub enum Command { + /// Manage API keys. + ApiKey { + #[command(subcommand)] + command: ApiKeyCommand, + }, + + /// Start the faucet server + Start { + #[clap(flatten)] + config: FaucetConfig, + + /// Base URL of the funding service that emits the notes. + #[arg(long = "funding-service-url", value_name = "URL", env = ENV_FUNDING_SERVICE_URL)] + funding_service_url: Url, + + /// Decimals of the token the funding service hands out, used by the frontend to convert + /// base units into token amounts. + #[arg(long = "decimals", value_name = "U8", env = ENV_DECIMALS)] + decimals: u8, + + /// Port to bind the API server. The server will be started on `0.0.0.0:`. + #[arg(long = "api-bind-port", value_name = "PORT", env = ENV_API_BIND_PORT, default_value = "8000")] + api_bind_port: u16, + + /// Public URL to access the API server. + #[arg(long = "api-public-url", value_name = "URL", env = ENV_API_PUBLIC_URL, default_value = "http://localhost:8000")] + api_public_url: Url, + + /// Port to bind the frontend server. The server will be started on + /// `0.0.0.0:`. + #[arg(long = "frontend-bind-port", value_name = "PORT", env = ENV_FRONTEND_BIND_PORT, default_value = "8080")] + frontend_bind_port: u16, + + /// Optionally disable the frontend server. + #[arg(long = "no-frontend", value_name = "BOOL", default_value_t = false, env = ENV_NO_FRONTEND)] + no_frontend: bool, + + /// The maximum amount of assets' base units that can be dispersed on each request. + #[arg(long = "max-claimable-amount", value_name = "U64", env = ENV_MAX_CLAIMABLE_AMOUNT, default_value = "1000000000")] + max_claimable_amount: u64, + + /// The secret to be used by the server to sign the `PoW` challenges. This should NOT be + /// shared. + /// + /// If not provided, a random secret is generated at startup. + #[arg(long = "pow-secret", value_name = "STRING", env = ENV_POW_SECRET)] + pow_secret: Option, + + /// The duration during which the `PoW` challenges are valid. Changing this will affect the + /// rate limiting, since it works by rejecting new submissions while the previous submitted + /// challenge is still valid. + #[arg(long = "pow-challenge-lifetime", value_name = "DURATION", env = ENV_POW_CHALLENGE_LIFETIME, default_value = "30s", value_parser = humantime::parse_duration)] + pow_challenge_lifetime: Duration, + + /// Defines how quickly the `PoW` difficulty grows with the number of requests. The number + /// of active challenges gets multiplied by the growth rate to compute the load + /// difficulty. + /// + /// Meaning, the difficulty bits of the challenge will increase approximately by + /// `log2(growth_rate * num_active_challenges)`. + #[arg(long = "pow-growth-rate", value_name = "F64", env = ENV_POW_GROWTH_RATE, default_value = "0.1")] + pow_growth_rate: f64, + + /// The interval at which the `PoW` challenge cache is cleaned up. + #[arg(long = "pow-cleanup-interval", value_name = "DURATION", env = ENV_POW_CLEANUP_INTERVAL, default_value = "2s", value_parser = humantime::parse_duration)] + pow_cleanup_interval: Duration, + + /// The baseline for the `PoW` challenges. This sets the `PoW` difficulty (in bits) that a + /// a challenge will have when there are no requests against the faucet. It must be between + /// 0 and 32. + #[arg(value_parser = clap::value_parser!(u8).range(0..=32))] + #[arg(long = "pow-baseline", value_name = "U8", env = ENV_POW_BASELINE, default_value = "16")] + pow_baseline: u8, + + /// The baseline amount for token requests (in base units). Requests for greater amounts + /// would require higher level of `PoW`. + /// + /// The request complexity for challenges is computed as: `request_complexity = (amount / + /// base_amount) + 1` + #[arg(long = "base-amount", value_name = "U64", env = ENV_BASE_AMOUNT, default_value = "100000000")] + base_amount: u64, + + /// The token amounts offered in the frontend, in whole tokens (not base units), separated + /// by commas. Each one must be within the maximum claimable amount. + #[arg(long = "token-amounts", value_name = "U64", env = ENV_TOKEN_AMOUNTS, value_delimiter = ',', default_value = "1,10,100")] + token_amounts: Vec, + + /// Enables the exporting of traces for OpenTelemetry. + /// + /// This can be further configured using environment variables as defined in the official + /// OpenTelemetry documentation. See our operator manual for further details. + #[arg(long = "enable-otel", value_name = "BOOL", default_value_t = false, env = ENV_ENABLE_OTEL)] + open_telemetry: bool, + + /// Explorer URL. + #[arg(long = "explorer-url", value_name = "URL", env = ENV_EXPLORER_URL)] + explorer_url: Option, + }, +} + +#[derive(Subcommand)] +pub enum ApiKeyCommand { + /// Generate an API key and persist it to the store. + /// + /// Prints out the generated API key to stdout. The key is also stored in the faucet's + /// database so that it is automatically loaded when the faucet starts. + Create { + /// Path to the file holding the API keys, one key per line. + #[arg(long = "api-keys-file", value_name = "FILE", default_value = DEFAULT_API_KEYS_PATH, env = ENV_API_KEYS)] + api_keys_path: PathBuf, + }, + + /// Remove an API key from the store. + Remove { + /// Path to the file holding the API keys, one key per line. + #[arg(long = "api-keys-file", value_name = "FILE", default_value = DEFAULT_API_KEYS_PATH, env = ENV_API_KEYS)] + api_keys_path: PathBuf, + + /// The API key to remove (encoded string). + api_key: String, + }, + + /// List all API keys in the store. + List { + /// Path to the file holding the API keys, one key per line. + #[arg(long = "api-keys-file", value_name = "FILE", default_value = DEFAULT_API_KEYS_PATH, env = ENV_API_KEYS)] + api_keys_path: PathBuf, + }, +} + +/// Configuration for the faucet. +#[derive(Parser, Debug, Clone)] +pub struct FaucetConfig { + /// Path to the file holding the API keys, one key per line. + #[arg(long = "api-keys-file", value_name = "FILE", env = ENV_API_KEYS)] + pub api_keys_path: Option, + + /// Timeout for attempting to connect to the node. + #[arg(long = "timeout", value_name = "DURATION", default_value = "5s", env = ENV_TIMEOUT, value_parser = humantime::parse_duration)] + pub timeout: Duration, + + /// Network configuration to use. Options are `devnet`, `testnet`, `localhost` or a custom + /// network. It is used to display the correct bech32 addresses in the UI. + #[arg(long = "network", value_name = "NETWORK", default_value = "localhost", env = ENV_NETWORK)] + pub network: FaucetNetwork, + + /// Node RPC gRPC endpoint in the format `http://[:]`. If not set, the url is derived + /// from the specified network. + #[arg(long = "node-url", value_name = "URL", env = ENV_NODE_URL)] + pub node_url: Option, +} + +impl Command { + pub fn open_telemetry(&self) -> OpenTelemetry { + if matches!(*self, Command::Start { open_telemetry: true, .. }) { + OpenTelemetry::Enabled + } else { + OpenTelemetry::Disabled + } + } +} + +// COMMANDS +// ================================================================================================= + +#[allow(clippy::too_many_lines)] +pub async fn run_faucet_command(cli: Cli) -> anyhow::Result<()> { + // Note: open-telemetry is handled in main. + match cli.command { + Command::ApiKey { command } => match command { + ApiKeyCommand::Create { api_keys_path } => { + let mut rng = ChaCha20Rng::from_seed(rand::random()); + let key = ApiKey::generate(&mut rng); + + add_api_key_to_file(&api_keys_path, &key).await?; + + println!("{}", key.encode()); + }, + + ApiKeyCommand::Remove { api_keys_path, api_key } => { + let key = ApiKey::decode(&api_key).context("failed to decode API key")?; + + remove_api_key_from_file(&api_keys_path, &key).await?; + + println!("API key removed"); + }, + + ApiKeyCommand::List { api_keys_path } => { + let encoded_keys = list_api_keys_from_file(&api_keys_path).await?; + if encoded_keys.is_empty() { + println!("No API keys found."); + } else { + for key in encoded_keys { + println!("{key}"); + } + } + }, + }, + + Command::Start { + funding_service_url, + config: + FaucetConfig { + node_url, + timeout, + network, + api_keys_path, + }, + api_bind_port, + api_public_url, + no_frontend, + frontend_bind_port, + max_claimable_amount, + pow_secret, + pow_challenge_lifetime, + pow_cleanup_interval, + pow_growth_rate, + pow_baseline, + base_amount, + token_amounts, + open_telemetry: _, + explorer_url, + decimals, + } => { + let node_url = parse_node_url(node_url, &network)?; + + let api_keys = match api_keys_path { + Some(path) => { + load_api_keys_from_file(&path).await.context("failed to load the API keys")? + }, + None => Vec::new(), + }; + + // The funding service is the only source of notes, so the faucet refuses to serve + // without it. Its status also bounds what the faucet may hand out. + let funding_service = FundingServiceClient::new(funding_service_url.clone(), timeout)?; + let funding_status = funding_service.status().await.with_context(|| { + format!("failed to reach the funding service at {funding_service_url}") + })?; + + let max_claimable_amount = AssetAmount::new(max_claimable_amount)?; + anyhow::ensure!( + max_claimable_amount.base_units() <= funding_status.max_amount, + "the maximum claimable amount {} exceeds the funding service's maximum of {}", + max_claimable_amount, + funding_status.max_amount, + ); + validate_token_amounts(&token_amounts, decimals, max_claimable_amount)?; + + tracing::info!( + target: COMPONENT, + { + funding_service.url = %funding_service_url, + funding_service.version = funding_status.version, + funding.account.id = funding_status.account_id, + funding.balance = funding_status.balance, + funding.max_amount = funding_status.max_amount, + }, + "Connected to the funding service", + ); + + let rate_limiter_config = PoWRateLimiterConfig { + challenge_lifetime: pow_challenge_lifetime, + cleanup_interval: pow_cleanup_interval, + growth_rate: pow_growth_rate, + baseline: pow_baseline, + }; + // The funder account is what the notes are sent from, so its address is the one the + // frontend shows. + let (funder_account_id, _) = AccountId::parse(&funding_status.account_id) + .context("the funding service reported an unparsable account ID")?; + + let metadata = Metadata { + funder_account_id: FaucetId::new(funder_account_id, network.to_network_id()?), + decimals, + explorer_url, + base_amount, + token_amounts, + }; + + // Use a random secret if not explicitly provided. + let pow_secret = match pow_secret { + Some(secret) => Sha256::digest(secret.as_bytes()).into(), + None => rand::random(), + }; + + let api_server = ApiServer::new( + metadata, + max_claimable_amount, + funding_service, + pow_secret, + rate_limiter_config, + &api_keys, + ); + + // Run the API server and, unless disabled, the frontend server, and fail as soon as + // either of them stops. + let mut tasks = JoinSet::new(); + let mut tasks_ids = HashMap::new(); + + let api_url = Url::parse(&format!("http://0.0.0.0:{api_bind_port}"))?; + let api_id = tasks.spawn(api_server.serve(api_url.clone())).id(); + tasks_ids.insert(api_id, "api"); + + if !no_frontend { + let frontend_url = Url::parse(&format!("http://0.0.0.0:{frontend_bind_port}"))?; + let frontend_id = tasks + .spawn(serve_frontend(frontend_url, api_public_url, node_url.to_string())) + .id(); + tasks_ids.insert(frontend_id, "frontend"); + } + + let (id, result) = match tasks.join_next_with_id().await.expect("a task was spawned") { + Ok((id, Ok(()))) => (id, Err(anyhow::anyhow!("completed unexpectedly"))), + Ok((id, Err(err))) => (id, Err(err)), + Err(join_err) => (join_err.id(), Err(join_err).context("failed to join task")), + }; + let component = tasks_ids.get(&id).unwrap_or(&"unknown"); + result.context(format!("{component} server failed"))?; + }, + } + + Ok(()) +} + +// UTILITIES +// ================================================================================================= + +/// Loads all API keys from the file at `path`. +async fn load_api_keys_from_file(path: &Path) -> anyhow::Result> { + list_api_keys_from_file(path) + .await? + .iter() + .map(|encoded| ApiKey::decode(encoded).map_err(|e| anyhow::anyhow!(e))) + .collect() +} + +/// Lists all API keys in the file at `path` as encoded strings. +async fn list_api_keys_from_file(path: &Path) -> anyhow::Result> { + let contents = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("failed to read {}", path.display()))?; + + Ok(contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(String::from) + .collect()) +} + +/// Adds a single API key to the file at `path`, creating the file if it does not exist. +async fn add_api_key_to_file(path: &Path, key: &ApiKey) -> anyhow::Result<()> { + // The first key is created before the file exists. + let mut keys = if path.exists() { + list_api_keys_from_file(path).await? + } else { + Vec::new() + }; + let encoded = key.encode(); + if !keys.contains(&encoded) { + keys.push(encoded); + } + + write_api_keys_to_file(path, &keys).await +} + +/// Removes a single API key from the file at `path`. +async fn remove_api_key_from_file(path: &Path, key: &ApiKey) -> anyhow::Result<()> { + let mut keys = list_api_keys_from_file(path).await?; + let encoded = key.encode(); + let before = keys.len(); + keys.retain(|existing| existing != &encoded); + anyhow::ensure!(keys.len() < before, "API key not found in {}", path.display()); + + write_api_keys_to_file(path, &keys).await +} + +async fn write_api_keys_to_file(path: &Path, keys: &[String]) -> anyhow::Result<()> { + let mut contents = keys.join("\n"); + contents.push('\n'); + tokio::fs::write(path, contents) + .await + .with_context(|| format!("failed to write {}", path.display())) +} + +/// Parses the node url from the cli arguments. If an explicit url is provided, it is used. +/// Otherwise, it is derived from the specified network. +fn parse_node_url(node_url: Option, network: &FaucetNetwork) -> anyhow::Result { + if let Some(node_url) = node_url { + return Ok(node_url); + } + + let url = network + .to_rpc_endpoint() + .context("no node url provided for the custom network")?; + + Url::parse(&url).with_context(|| format!("failed to parse node url: {url}")) +} + +/// Checks that every token amount offered in the frontend is non zero and within the maximum +/// claimable amount. +fn validate_token_amounts( + token_amounts: &[u64], + decimals: u8, + max_claimable_amount: AssetAmount, +) -> anyhow::Result<()> { + anyhow::ensure!(!token_amounts.is_empty(), "at least one token amount is required"); + + let base_units_per_token = 10u64 + .checked_pow(u32::from(decimals)) + .with_context(|| format!("{decimals} decimals overflow a u64"))?; + for &amount in token_amounts { + anyhow::ensure!(amount > 0, "token amounts must be greater than zero"); + let base_units = amount.checked_mul(base_units_per_token); + anyhow::ensure!( + base_units.is_some_and(|base_units| base_units <= max_claimable_amount.base_units()), + "the token amount {amount} exceeds the maximum claimable amount of {max_claimable_amount} base units", + ); + } + + Ok(()) +} + +// TESTS +// ================================================================================================= + +#[cfg(test)] +mod tests { + use std::env::temp_dir; + use std::process::Stdio; + use std::str::FromStr; + use std::time::Duration; + + use clap::Parser; + use clap::error::ErrorKind; + use fantoccini::ClientBuilder; + use miden_faucet_lib::types::AssetAmount; + use miden_protocol::account::AccountId; + use miden_protocol::address::{Address, NetworkId}; + use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; + use rand::SeedableRng; + use serde_json::{Map, Value, json}; + use tokio::io::AsyncBufReadExt; + use tokio::net::TcpListener; + use url::Url; + use uuid::Uuid; + + use crate::api_key::ApiKey; + use crate::funding_service_client::FundingServiceClient; + use crate::network::FaucetNetwork; + use crate::testing::stub_funding_service::{STUB_MAX_AMOUNT, serve_stub_funding_service}; + use crate::testing::stub_rpc_api::serve_stub; + use crate::{Cli, FaucetConfig, run_faucet_command, validate_token_amounts}; + + // CLI TESTS + // --------------------------------------------------------------------------------------------- + + /// The funding service is the only source of notes, so `start` cannot run without its URL. + #[test] + fn start_requires_a_funding_service_url() { + let Err(error) = Cli::try_parse_from(["miden-faucet", "start"]) else { + panic!("--funding-service-url should be required") + }; + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + assert!(error.to_string().contains("--funding-service-url")); + } + + /// The token's decimals no longer come from a faucet account, so they must be configured. + #[test] + fn start_requires_the_token_decimals() { + let Err(error) = Cli::try_parse_from([ + "miden-faucet", + "start", + "--funding-service-url", + "http://localhost:50401", + ]) else { + panic!("--decimals should be required") + }; + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + assert!(error.to_string().contains("--decimals")); + } + + #[test] + fn token_amounts_must_be_within_the_maximum_claimable_amount() { + let max_claimable_amount = AssetAmount::new(100_000_000).unwrap(); + let decimals = 6; + + assert!(validate_token_amounts(&[1, 10, 100], decimals, max_claimable_amount).is_ok()); + assert!(validate_token_amounts(&[1, 101], decimals, max_claimable_amount).is_err()); + assert!(validate_token_amounts(&[0, 1], decimals, max_claimable_amount).is_err()); + assert!(validate_token_amounts(&[], decimals, max_claimable_amount).is_err()); + } + + // FUNDING SERVICE TESTS + // --------------------------------------------------------------------------------------------- + + /// A token request is answered with the note the funding service created. + #[tokio::test] + async fn get_tokens_returns_the_funding_services_note() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = Url::from_str(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + tokio::spawn(async move { serve_stub_funding_service(listener).await.unwrap() }); + + let funding_service = FundingServiceClient::new(url, Duration::from_secs(5)).unwrap(); + + let status = funding_service.status().await.expect("the stub serves a status"); + assert_eq!(status.max_amount, STUB_MAX_AMOUNT); + + let target = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let funding_response = + funding_service.request_funds(target, 1_000).await.expect("the stub funds it"); + + assert_eq!( + funding_response + .note + .assets() + .iter() + .next() + .unwrap() + .unwrap_fungible() + .amount() + .as_u64(), + 1_000 + ); + } + + // API KEY TESTS + // --------------------------------------------------------------------------------------------- + + #[tokio::test] + async fn create_api_key_persists_to_file() { + let file_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); + + // Create an API key via the CLI command. + let result = Box::pin(run_faucet_command(Cli::parse_from([ + "miden-faucet", + "api-key", + "create", + "--api-keys-file", + file_path.to_str().unwrap(), + ]))) + .await; + assert!(result.is_ok()); + + // Verify the key is present in the file. + let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); + assert_eq!(keys.len(), 1); + } + + #[tokio::test] + async fn list_api_keys_shows_persisted_keys() { + let file_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); + + // Create two API keys. + for _ in 0..2 { + Box::pin(run_faucet_command(Cli::parse_from([ + "miden-faucet", + "api-key", + "create", + "--api-keys-file", + file_path.to_str().unwrap(), + ]))) + .await + .unwrap(); + } + + // Verify both keys can be loaded. + let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); + assert_eq!(keys.len(), 2); + + // Also verify the list-api-keys command runs without error. + let result = Box::pin(run_faucet_command(Cli::parse_from([ + "miden-faucet", + "api-key", + "list", + "--api-keys-file", + file_path.to_str().unwrap(), + ]))) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn remove_api_key_deletes_from_file() { + let file_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); + + // Create an API key. + let mut rng = rand::rngs::ChaCha20Rng::from_seed(rand::random()); + let key = crate::api_key::ApiKey::generate(&mut rng); + crate::add_api_key_to_file(&file_path, &key).await.unwrap(); + + // Verify the key exists. + let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); + assert_eq!(keys.len(), 1); + + // Remove the key via the CLI command. + let result = Box::pin(run_faucet_command(Cli::parse_from([ + "miden-faucet", + "api-key", + "remove", + "--api-keys-file", + file_path.to_str().unwrap(), + &key.encode(), + ]))) + .await; + assert!(result.is_ok()); + + // Verify the key was removed. + let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); + assert_eq!(keys, [] as [ApiKey; 0]); + } + + // INTEGRATION TEST + // --------------------------------------------------------------------------------------------- + + /// Starts a stub node, a stub funding service, a faucet connected to both, and a chromedriver + /// to drive the faucet website. It loads the page, requests tokens, and checks that every + /// request returned a successful status. + #[tokio::test] + async fn frontend_request_tokens() { + let stub_node_url = run_stub_node().await; + let funding_service_url = run_stub_funding_service().await; + let website_url = run_faucet_server(stub_node_url, funding_service_url); + let client = start_fantoccini_client().await; + + // Open the website + client.goto(website_url.as_str()).await.unwrap(); + + let title = client.title().await.unwrap(); + assert_eq!(title, "Miden Faucet"); + + let network_id = NetworkId::Testnet; + let account_id = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let address = Address::new(account_id); + let address_bech32 = address.encode(network_id); + + // Wait for the website to be fully loaded + client + .wait() + .at_most(Duration::from_secs(10)) + .for_element(fantoccini::Locator::Css("#token-amount option")) + .await + .unwrap(); + + // Fill in the account address + client + .find(fantoccini::Locator::Css("#recipient-address")) + .await + .unwrap() + .send_keys(&address_bech32) + .await + .unwrap(); + + // Select the first asset amount option + client + .find(fantoccini::Locator::Css("#token-amount")) + .await + .unwrap() + .click() + .await + .unwrap(); + client + .find(fantoccini::Locator::Css("#token-amount option")) + .await + .unwrap() + .click() + .await + .unwrap(); + + // Click the public note button + client + .find(fantoccini::Locator::Css("#send-button")) + .await + .unwrap() + .click() + .await + .unwrap(); + + // Execute a script to get all the failed requests + let script = r" + let errors = []; + performance.getEntriesByType('resource').forEach(entry => { + if (entry.responseStatus && entry.responseStatus >= 400) { + errors.push({url: entry.name, status: entry.responseStatus}); + } + }); + return errors; + "; + let failed_requests = client.execute(script, vec![]).await.unwrap(); + + // Verify all requests are successful + assert_eq!(failed_requests.as_array().unwrap().as_slice(), [] as [Value; 0]); + + client.close().await.unwrap(); + } + + // TESTING HELPERS + // --------------------------------------------------------------------------------------------- + + pub async fn run_stub_node() -> Url { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listener_addr = listener.local_addr().unwrap(); + let stub_node_url = Url::from_str(&format!("http://{listener_addr}")).unwrap(); + tokio::spawn(async move { serve_stub(listener).await.unwrap() }); + stub_node_url + } + + /// Starts a faucet against the given stubs and returns its frontend URL. + fn run_faucet_server(stub_node_url: Url, funding_service_url: Url) -> String { + // The faucet reads the file on startup, so it has to exist even with no keys in it. + let api_keys_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); + std::fs::write(&api_keys_path, "").unwrap(); + + let config = FaucetConfig { + node_url: Some(stub_node_url), + timeout: Duration::from_secs(5), + network: FaucetNetwork::Localhost, + api_keys_path: Some(api_keys_path), + }; + let api_bind_port = 8000; + let frontend_url = "http://localhost:8080"; + + // Use std::thread to launch faucet - avoids Send requirements + std::thread::spawn(move || { + // Create a new runtime for this thread + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to build runtime"); + + // Run the faucet on this thread's runtime + rt.block_on(async { + Box::pin(run_faucet_command(Cli { + command: crate::Command::Start { + config, + funding_service_url, + decimals: 6, + api_bind_port, + api_public_url: Url::parse(&format!("http://localhost:{api_bind_port}")) + .unwrap(), + frontend_bind_port: 8080, + no_frontend: false, + max_claimable_amount: 1_000_000_000, + pow_secret: Some("test".to_string()), + pow_challenge_lifetime: Duration::from_secs(30), + pow_cleanup_interval: Duration::from_secs(1), + pow_growth_rate: 1.0, + pow_baseline: 12, + base_amount: 100_000, + token_amounts: vec![1, 10, 100], + open_telemetry: false, + explorer_url: None, + }, + })) + .await + .expect("failed to start faucet"); + }); + }); + + frontend_url.to_string() + } + + async fn start_fantoccini_client() -> fantoccini::Client { + // Start chromedriver. This requires having chromedriver and chrome installed + let chromedriver_port = "57708"; + let mut chromedriver = tokio::process::Command::new("chromedriver") + .arg(format!("--port={chromedriver_port}")) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("failed to start chromedriver"); + let stdout = chromedriver.stdout.take().unwrap(); + tokio::spawn( + async move { chromedriver.wait().await.expect("chromedriver process failed") }, + ); + // Wait for chromedriver to be running + let mut reader = tokio::io::BufReader::new(stdout).lines(); + while let Some(line) = reader.next_line().await.unwrap() { + if line.contains("ChromeDriver was started successfully") { + break; + } + } + + // Start fantoccini client + ClientBuilder::native() + .capabilities( + [( + "goog:chromeOptions".to_string(), + json!({"args": ["--headless", "--disable-gpu", "--no-sandbox"]}), + )] + .into_iter() + .collect::>(), + ) + .connect(&format!("http://localhost:{chromedriver_port}")) + .await + .expect("failed to connect to WebDriver") + } + + async fn run_stub_funding_service() -> Url { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = Url::from_str(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + tokio::spawn(async move { serve_stub_funding_service(listener).await.unwrap() }); + url + } +} diff --git a/bin/faucet/src/main.rs b/bin/faucet/src/main.rs index 5329ce6f..f220dc9c 100644 --- a/bin/faucet/src/main.rs +++ b/bin/faucet/src/main.rs @@ -1,239 +1,6 @@ -mod api; -mod api_key; -mod frontend; -mod funding_service_client; -mod logging; -mod network; -#[cfg(test)] -mod testing; - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::time::Duration; - use anyhow::Context; -use clap::{Parser, Subcommand}; -use miden_faucet_lib::FaucetId; -use miden_faucet_lib::types::AssetAmount; -use miden_pow_rate_limiter::PoWRateLimiterConfig; -use miden_protocol::account::AccountId; -use rand::SeedableRng; -use rand::rngs::ChaCha20Rng; -use sha2::{Digest, Sha256}; -use tokio::task::JoinSet; -use url::Url; - -use crate::api::{ApiServer, Metadata}; -use crate::api_key::ApiKey; -use crate::frontend::serve_frontend; -use crate::funding_service_client::FundingServiceClient; -use crate::logging::OpenTelemetry; -use crate::network::FaucetNetwork; - -// CONSTANTS -// ================================================================================================= - -const COMPONENT: &str = "miden-faucet-server"; -const DEFAULT_API_KEYS_PATH: &str = "api_keys.txt"; - -const ENV_API_BIND_PORT: &str = "MIDEN_FAUCET_API_BIND_PORT"; -const ENV_API_PUBLIC_URL: &str = "MIDEN_FAUCET_API_PUBLIC_URL"; -const ENV_FRONTEND_BIND_PORT: &str = "MIDEN_FAUCET_FRONTEND_BIND_PORT"; -const ENV_NO_FRONTEND: &str = "MIDEN_FAUCET_NO_FRONTEND"; -const ENV_NETWORK: &str = "MIDEN_FAUCET_NETWORK"; -const ENV_NODE_URL: &str = "MIDEN_FAUCET_NODE_URL"; -const ENV_TIMEOUT: &str = "MIDEN_FAUCET_TIMEOUT"; -const ENV_MAX_CLAIMABLE_AMOUNT: &str = "MIDEN_FAUCET_MAX_CLAIMABLE_AMOUNT"; -const ENV_POW_SECRET: &str = "MIDEN_FAUCET_POW_SECRET"; -const ENV_POW_CHALLENGE_LIFETIME: &str = "MIDEN_FAUCET_POW_CHALLENGE_LIFETIME"; -const ENV_POW_CLEANUP_INTERVAL: &str = "MIDEN_FAUCET_POW_CLEANUP_INTERVAL"; -const ENV_POW_GROWTH_RATE: &str = "MIDEN_FAUCET_POW_GROWTH_RATE"; -const ENV_POW_BASELINE: &str = "MIDEN_FAUCET_POW_BASELINE"; -const ENV_BASE_AMOUNT: &str = "MIDEN_FAUCET_BASE_AMOUNT"; -const ENV_ENABLE_OTEL: &str = "MIDEN_FAUCET_ENABLE_OTEL"; -const ENV_API_KEYS: &str = "MIDEN_FAUCET_API_KEYS"; -const ENV_DECIMALS: &str = "MIDEN_FAUCET_DECIMALS"; -const ENV_EXPLORER_URL: &str = "MIDEN_FAUCET_EXPLORER_URL"; -const ENV_FUNDING_SERVICE_URL: &str = "MIDEN_FAUCET_FUNDING_SERVICE_URL"; -const ENV_TOKEN_AMOUNTS: &str = "MIDEN_FAUCET_TOKEN_AMOUNTS"; - -// COMMANDS -// ================================================================================================ - -#[derive(Parser)] -#[command(version, about, long_about = None)] -pub struct Cli { - #[command(subcommand)] - pub command: Command, -} - -#[allow(clippy::large_enum_variant)] -#[derive(Subcommand)] -pub enum Command { - /// Manage API keys. - ApiKey { - #[command(subcommand)] - command: ApiKeyCommand, - }, - - /// Start the faucet server - Start { - #[clap(flatten)] - config: FaucetConfig, - - /// Base URL of the funding service that emits the notes. - #[arg(long = "funding-service-url", value_name = "URL", env = ENV_FUNDING_SERVICE_URL)] - funding_service_url: Url, - - /// Decimals of the token the funding service hands out, used by the frontend to convert - /// base units into token amounts. - #[arg(long = "decimals", value_name = "U8", env = ENV_DECIMALS)] - decimals: u8, - - /// Port to bind the API server. The server will be started on `0.0.0.0:`. - #[arg(long = "api-bind-port", value_name = "PORT", env = ENV_API_BIND_PORT, default_value = "8000")] - api_bind_port: u16, - - /// Public URL to access the API server. - #[arg(long = "api-public-url", value_name = "URL", env = ENV_API_PUBLIC_URL, default_value = "http://localhost:8000")] - api_public_url: Url, - - /// Port to bind the frontend server. The server will be started on - /// `0.0.0.0:`. - #[arg(long = "frontend-bind-port", value_name = "PORT", env = ENV_FRONTEND_BIND_PORT, default_value = "8080")] - frontend_bind_port: u16, - - /// Optionally disable the frontend server. - #[arg(long = "no-frontend", value_name = "BOOL", default_value_t = false, env = ENV_NO_FRONTEND)] - no_frontend: bool, - - /// The maximum amount of assets' base units that can be dispersed on each request. - #[arg(long = "max-claimable-amount", value_name = "U64", env = ENV_MAX_CLAIMABLE_AMOUNT, default_value = "1000000000")] - max_claimable_amount: u64, - - /// The secret to be used by the server to sign the `PoW` challenges. This should NOT be - /// shared. - /// - /// If not provided, a random secret is generated at startup. - #[arg(long = "pow-secret", value_name = "STRING", env = ENV_POW_SECRET)] - pow_secret: Option, - - /// The duration during which the `PoW` challenges are valid. Changing this will affect the - /// rate limiting, since it works by rejecting new submissions while the previous submitted - /// challenge is still valid. - #[arg(long = "pow-challenge-lifetime", value_name = "DURATION", env = ENV_POW_CHALLENGE_LIFETIME, default_value = "30s", value_parser = humantime::parse_duration)] - pow_challenge_lifetime: Duration, - - /// Defines how quickly the `PoW` difficulty grows with the number of requests. The number - /// of active challenges gets multiplied by the growth rate to compute the load - /// difficulty. - /// - /// Meaning, the difficulty bits of the challenge will increase approximately by - /// `log2(growth_rate * num_active_challenges)`. - #[arg(long = "pow-growth-rate", value_name = "F64", env = ENV_POW_GROWTH_RATE, default_value = "0.1")] - pow_growth_rate: f64, - - /// The interval at which the `PoW` challenge cache is cleaned up. - #[arg(long = "pow-cleanup-interval", value_name = "DURATION", env = ENV_POW_CLEANUP_INTERVAL, default_value = "2s", value_parser = humantime::parse_duration)] - pow_cleanup_interval: Duration, - - /// The baseline for the `PoW` challenges. This sets the `PoW` difficulty (in bits) that a - /// a challenge will have when there are no requests against the faucet. It must be between - /// 0 and 32. - #[arg(value_parser = clap::value_parser!(u8).range(0..=32))] - #[arg(long = "pow-baseline", value_name = "U8", env = ENV_POW_BASELINE, default_value = "16")] - pow_baseline: u8, - - /// The baseline amount for token requests (in base units). Requests for greater amounts - /// would require higher level of `PoW`. - /// - /// The request complexity for challenges is computed as: `request_complexity = (amount / - /// base_amount) + 1` - #[arg(long = "base-amount", value_name = "U64", env = ENV_BASE_AMOUNT, default_value = "100000000")] - base_amount: u64, - - /// The token amounts offered in the frontend, in whole tokens (not base units), separated - /// by commas. Each one must be within the maximum claimable amount. - #[arg(long = "token-amounts", value_name = "U64", env = ENV_TOKEN_AMOUNTS, value_delimiter = ',', default_value = "1,10,100")] - token_amounts: Vec, - - /// Enables the exporting of traces for OpenTelemetry. - /// - /// This can be further configured using environment variables as defined in the official - /// OpenTelemetry documentation. See our operator manual for further details. - #[arg(long = "enable-otel", value_name = "BOOL", default_value_t = false, env = ENV_ENABLE_OTEL)] - open_telemetry: bool, - - /// Explorer URL. - #[arg(long = "explorer-url", value_name = "URL", env = ENV_EXPLORER_URL)] - explorer_url: Option, - }, -} - -#[derive(Subcommand)] -pub enum ApiKeyCommand { - /// Generate an API key and persist it to the store. - /// - /// Prints out the generated API key to stdout. The key is also stored in the faucet's - /// database so that it is automatically loaded when the faucet starts. - Create { - /// Path to the file holding the API keys, one key per line. - #[arg(long = "api-keys-file", value_name = "FILE", default_value = DEFAULT_API_KEYS_PATH, env = ENV_API_KEYS)] - api_keys_path: PathBuf, - }, - - /// Remove an API key from the store. - Remove { - /// Path to the file holding the API keys, one key per line. - #[arg(long = "api-keys-file", value_name = "FILE", default_value = DEFAULT_API_KEYS_PATH, env = ENV_API_KEYS)] - api_keys_path: PathBuf, - - /// The API key to remove (encoded string). - api_key: String, - }, - - /// List all API keys in the store. - List { - /// Path to the file holding the API keys, one key per line. - #[arg(long = "api-keys-file", value_name = "FILE", default_value = DEFAULT_API_KEYS_PATH, env = ENV_API_KEYS)] - api_keys_path: PathBuf, - }, -} - -/// Configuration for the faucet. -#[derive(Parser, Debug, Clone)] -pub struct FaucetConfig { - /// Path to the file holding the API keys, one key per line. - #[arg(long = "api-keys-file", value_name = "FILE", env = ENV_API_KEYS)] - api_keys_path: Option, - - /// Timeout for attempting to connect to the node. - #[arg(long = "timeout", value_name = "DURATION", default_value = "5s", env = ENV_TIMEOUT, value_parser = humantime::parse_duration)] - timeout: Duration, - - /// Network configuration to use. Options are `devnet`, `testnet`, `localhost` or a custom - /// network. It is used to display the correct bech32 addresses in the UI. - #[arg(long = "network", value_name = "NETWORK", default_value = "localhost", env = ENV_NETWORK)] - network: FaucetNetwork, - - /// Node RPC gRPC endpoint in the format `http://[:]`. If not set, the url is derived - /// from the specified network. - #[arg(long = "node-url", value_name = "URL", env = ENV_NODE_URL)] - node_url: Option, -} - -impl Command { - fn open_telemetry(&self) -> OpenTelemetry { - if matches!(*self, Command::Start { open_telemetry: true, .. }) { - OpenTelemetry::Enabled - } else { - OpenTelemetry::Disabled - } - } -} - -// MAIN -// ================================================================================================= +use clap::Parser; +use miden_faucet::{Cli, logging, run_faucet_command}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -245,646 +12,3 @@ async fn main() -> anyhow::Result<()> { Box::pin(run_faucet_command(cli)).await } - -#[allow(clippy::too_many_lines)] -async fn run_faucet_command(cli: Cli) -> anyhow::Result<()> { - // Note: open-telemetry is handled in main. - match cli.command { - Command::ApiKey { command } => match command { - ApiKeyCommand::Create { api_keys_path } => { - let mut rng = ChaCha20Rng::from_seed(rand::random()); - let key = ApiKey::generate(&mut rng); - - add_api_key_to_file(&api_keys_path, &key).await?; - - println!("{}", key.encode()); - }, - - ApiKeyCommand::Remove { api_keys_path, api_key } => { - let key = ApiKey::decode(&api_key).context("failed to decode API key")?; - - remove_api_key_from_file(&api_keys_path, &key).await?; - - println!("API key removed"); - }, - - ApiKeyCommand::List { api_keys_path } => { - let encoded_keys = list_api_keys_from_file(&api_keys_path).await?; - if encoded_keys.is_empty() { - println!("No API keys found."); - } else { - for key in encoded_keys { - println!("{key}"); - } - } - }, - }, - - Command::Start { - funding_service_url, - config: - FaucetConfig { - node_url, - timeout, - network, - api_keys_path, - }, - api_bind_port, - api_public_url, - no_frontend, - frontend_bind_port, - max_claimable_amount, - pow_secret, - pow_challenge_lifetime, - pow_cleanup_interval, - pow_growth_rate, - pow_baseline, - base_amount, - token_amounts, - open_telemetry: _, - explorer_url, - decimals, - } => { - let node_url = parse_node_url(node_url, &network)?; - - let api_keys = match api_keys_path { - Some(path) => { - load_api_keys_from_file(&path).await.context("failed to load the API keys")? - }, - None => Vec::new(), - }; - - // The funding service is the only source of notes, so the faucet refuses to serve - // without it. Its status also bounds what the faucet may hand out. - let funding_service = FundingServiceClient::new(funding_service_url.clone(), timeout)?; - let funding_status = funding_service.status().await.with_context(|| { - format!("failed to reach the funding service at {funding_service_url}") - })?; - - let max_claimable_amount = AssetAmount::new(max_claimable_amount)?; - anyhow::ensure!( - max_claimable_amount.base_units() <= funding_status.max_amount, - "the maximum claimable amount {} exceeds the funding service's maximum of {}", - max_claimable_amount, - funding_status.max_amount, - ); - validate_token_amounts(&token_amounts, decimals, max_claimable_amount)?; - - tracing::info!( - target: COMPONENT, - { - funding_service.url = %funding_service_url, - funding_service.version = funding_status.version, - funding.account.id = funding_status.account_id, - funding.balance = funding_status.balance, - funding.max_amount = funding_status.max_amount, - }, - "Connected to the funding service", - ); - - let rate_limiter_config = PoWRateLimiterConfig { - challenge_lifetime: pow_challenge_lifetime, - cleanup_interval: pow_cleanup_interval, - growth_rate: pow_growth_rate, - baseline: pow_baseline, - }; - // The funder account is what the notes are sent from, so its address is the one the - // frontend shows. - let (funder_account_id, _) = AccountId::parse(&funding_status.account_id) - .context("the funding service reported an unparsable account ID")?; - - let metadata = Metadata { - funder_account_id: FaucetId::new(funder_account_id, network.to_network_id()?), - decimals, - explorer_url, - base_amount, - token_amounts, - }; - - // Use a random secret if not explicitly provided. - let pow_secret = match pow_secret { - Some(secret) => Sha256::digest(secret.as_bytes()).into(), - None => rand::random(), - }; - - let api_server = ApiServer::new( - metadata, - max_claimable_amount, - funding_service, - pow_secret, - rate_limiter_config, - &api_keys, - ); - - // Run the API server and, unless disabled, the frontend server, and fail as soon as - // either of them stops. - let mut tasks = JoinSet::new(); - let mut tasks_ids = HashMap::new(); - - let api_url = Url::parse(&format!("http://0.0.0.0:{api_bind_port}"))?; - let api_id = tasks.spawn(api_server.serve(api_url.clone())).id(); - tasks_ids.insert(api_id, "api"); - - if !no_frontend { - let frontend_url = Url::parse(&format!("http://0.0.0.0:{frontend_bind_port}"))?; - let frontend_id = tasks - .spawn(serve_frontend(frontend_url, api_public_url, node_url.to_string())) - .id(); - tasks_ids.insert(frontend_id, "frontend"); - } - - let (id, result) = match tasks.join_next_with_id().await.expect("a task was spawned") { - Ok((id, Ok(()))) => (id, Err(anyhow::anyhow!("completed unexpectedly"))), - Ok((id, Err(err))) => (id, Err(err)), - Err(join_err) => (join_err.id(), Err(join_err).context("failed to join task")), - }; - let component = tasks_ids.get(&id).unwrap_or(&"unknown"); - result.context(format!("{component} server failed"))?; - }, - } - - Ok(()) -} - -// UTILITIES -// ================================================================================================= - -/// Loads all API keys from the file at `path`. -async fn load_api_keys_from_file(path: &Path) -> anyhow::Result> { - list_api_keys_from_file(path) - .await? - .iter() - .map(|encoded| ApiKey::decode(encoded).map_err(|e| anyhow::anyhow!(e))) - .collect() -} - -/// Lists all API keys in the file at `path` as encoded strings. -async fn list_api_keys_from_file(path: &Path) -> anyhow::Result> { - let contents = tokio::fs::read_to_string(path) - .await - .with_context(|| format!("failed to read {}", path.display()))?; - - Ok(contents - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(String::from) - .collect()) -} - -/// Adds a single API key to the file at `path`, creating the file if it does not exist. -async fn add_api_key_to_file(path: &Path, key: &ApiKey) -> anyhow::Result<()> { - // The first key is created before the file exists. - let mut keys = if path.exists() { - list_api_keys_from_file(path).await? - } else { - Vec::new() - }; - let encoded = key.encode(); - if !keys.contains(&encoded) { - keys.push(encoded); - } - - write_api_keys_to_file(path, &keys).await -} - -/// Removes a single API key from the file at `path`. -async fn remove_api_key_from_file(path: &Path, key: &ApiKey) -> anyhow::Result<()> { - let mut keys = list_api_keys_from_file(path).await?; - let encoded = key.encode(); - let before = keys.len(); - keys.retain(|existing| existing != &encoded); - anyhow::ensure!(keys.len() < before, "API key not found in {}", path.display()); - - write_api_keys_to_file(path, &keys).await -} - -async fn write_api_keys_to_file(path: &Path, keys: &[String]) -> anyhow::Result<()> { - let mut contents = keys.join("\n"); - contents.push('\n'); - tokio::fs::write(path, contents) - .await - .with_context(|| format!("failed to write {}", path.display())) -} - -/// Parses the node url from the cli arguments. If an explicit url is provided, it is used. -/// Otherwise, it is derived from the specified network. -fn parse_node_url(node_url: Option, network: &FaucetNetwork) -> anyhow::Result { - if let Some(node_url) = node_url { - return Ok(node_url); - } - - let url = network - .to_rpc_endpoint() - .context("no node url provided for the custom network")?; - - Url::parse(&url).with_context(|| format!("failed to parse node url: {url}")) -} - -/// Checks that every token amount offered in the frontend is non zero and within the maximum -/// claimable amount. -fn validate_token_amounts( - token_amounts: &[u64], - decimals: u8, - max_claimable_amount: AssetAmount, -) -> anyhow::Result<()> { - anyhow::ensure!(!token_amounts.is_empty(), "at least one token amount is required"); - - let base_units_per_token = 10u64 - .checked_pow(u32::from(decimals)) - .with_context(|| format!("{decimals} decimals overflow a u64"))?; - for &amount in token_amounts { - anyhow::ensure!(amount > 0, "token amounts must be greater than zero"); - let base_units = amount.checked_mul(base_units_per_token); - anyhow::ensure!( - base_units.is_some_and(|base_units| base_units <= max_claimable_amount.base_units()), - "the token amount {amount} exceeds the maximum claimable amount of {max_claimable_amount} base units", - ); - } - - Ok(()) -} - -// TESTS -// ================================================================================================= - -#[cfg(test)] -mod tests { - use std::env::temp_dir; - use std::process::Stdio; - use std::str::FromStr; - use std::time::Duration; - - use clap::Parser; - use clap::error::ErrorKind; - use fantoccini::ClientBuilder; - use miden_faucet_lib::types::AssetAmount; - use miden_protocol::account::AccountId; - use miden_protocol::address::{Address, NetworkId}; - use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; - use rand::SeedableRng; - use serde_json::{Map, Value, json}; - use tokio::io::AsyncBufReadExt; - use tokio::net::TcpListener; - use url::Url; - use uuid::Uuid; - - use crate::api_key::ApiKey; - use crate::funding_service_client::FundingServiceClient; - use crate::network::FaucetNetwork; - use crate::testing::stub_funding_service::{STUB_MAX_AMOUNT, serve_stub_funding_service}; - use crate::testing::stub_rpc_api::serve_stub; - use crate::{Cli, FaucetConfig, run_faucet_command, validate_token_amounts}; - - // CLI TESTS - // --------------------------------------------------------------------------------------------- - - /// The funding service is the only source of notes, so `start` cannot run without its URL. - #[test] - fn start_requires_a_funding_service_url() { - let Err(error) = Cli::try_parse_from(["miden-faucet", "start"]) else { - panic!("--funding-service-url should be required") - }; - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - assert!(error.to_string().contains("--funding-service-url")); - } - - /// The token's decimals no longer come from a faucet account, so they must be configured. - #[test] - fn start_requires_the_token_decimals() { - let Err(error) = Cli::try_parse_from([ - "miden-faucet", - "start", - "--funding-service-url", - "http://localhost:50401", - ]) else { - panic!("--decimals should be required") - }; - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - assert!(error.to_string().contains("--decimals")); - } - - #[test] - fn token_amounts_must_be_within_the_maximum_claimable_amount() { - let max_claimable_amount = AssetAmount::new(100_000_000).unwrap(); - let decimals = 6; - - assert!(validate_token_amounts(&[1, 10, 100], decimals, max_claimable_amount).is_ok()); - assert!(validate_token_amounts(&[1, 101], decimals, max_claimable_amount).is_err()); - assert!(validate_token_amounts(&[0, 1], decimals, max_claimable_amount).is_err()); - assert!(validate_token_amounts(&[], decimals, max_claimable_amount).is_err()); - } - - // FUNDING SERVICE TESTS - // --------------------------------------------------------------------------------------------- - - /// A token request is answered with the note the funding service created. - #[tokio::test] - async fn get_tokens_returns_the_funding_services_note() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = Url::from_str(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); - tokio::spawn(async move { serve_stub_funding_service(listener).await.unwrap() }); - - let funding_service = FundingServiceClient::new(url, Duration::from_secs(5)).unwrap(); - - let status = funding_service.status().await.expect("the stub serves a status"); - assert_eq!(status.max_amount, STUB_MAX_AMOUNT); - - let target = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); - let funding_response = - funding_service.request_funds(target, 1_000).await.expect("the stub funds it"); - - assert_eq!( - funding_response - .note - .assets() - .iter() - .next() - .unwrap() - .unwrap_fungible() - .amount() - .as_u64(), - 1_000 - ); - } - - // API KEY TESTS - // --------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn create_api_key_persists_to_file() { - let file_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); - - // Create an API key via the CLI command. - let result = Box::pin(run_faucet_command(Cli::parse_from([ - "miden-faucet", - "api-key", - "create", - "--api-keys-file", - file_path.to_str().unwrap(), - ]))) - .await; - assert!(result.is_ok()); - - // Verify the key is present in the file. - let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); - assert_eq!(keys.len(), 1); - } - - #[tokio::test] - async fn list_api_keys_shows_persisted_keys() { - let file_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); - - // Create two API keys. - for _ in 0..2 { - Box::pin(run_faucet_command(Cli::parse_from([ - "miden-faucet", - "api-key", - "create", - "--api-keys-file", - file_path.to_str().unwrap(), - ]))) - .await - .unwrap(); - } - - // Verify both keys can be loaded. - let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); - assert_eq!(keys.len(), 2); - - // Also verify the list-api-keys command runs without error. - let result = Box::pin(run_faucet_command(Cli::parse_from([ - "miden-faucet", - "api-key", - "list", - "--api-keys-file", - file_path.to_str().unwrap(), - ]))) - .await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn remove_api_key_deletes_from_file() { - let file_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); - - // Create an API key. - let mut rng = rand::rngs::ChaCha20Rng::from_seed(rand::random()); - let key = crate::api_key::ApiKey::generate(&mut rng); - crate::add_api_key_to_file(&file_path, &key).await.unwrap(); - - // Verify the key exists. - let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); - assert_eq!(keys.len(), 1); - - // Remove the key via the CLI command. - let result = Box::pin(run_faucet_command(Cli::parse_from([ - "miden-faucet", - "api-key", - "remove", - "--api-keys-file", - file_path.to_str().unwrap(), - &key.encode(), - ]))) - .await; - assert!(result.is_ok()); - - // Verify the key was removed. - let keys = crate::load_api_keys_from_file(&file_path).await.unwrap(); - assert_eq!(keys, [] as [ApiKey; 0]); - } - - // INTEGRATION TEST - // --------------------------------------------------------------------------------------------- - - /// Starts a stub node, a stub funding service, a faucet connected to both, and a chromedriver - /// to drive the faucet website. It loads the page, requests tokens, and checks that every - /// request returned a successful status. - #[tokio::test] - async fn frontend_request_tokens() { - let stub_node_url = run_stub_node().await; - let funding_service_url = run_stub_funding_service().await; - let website_url = run_faucet_server(stub_node_url, funding_service_url); - let client = start_fantoccini_client().await; - - // Open the website - client.goto(website_url.as_str()).await.unwrap(); - - let title = client.title().await.unwrap(); - assert_eq!(title, "Miden Faucet"); - - let network_id = NetworkId::Testnet; - let account_id = - AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); - let address = Address::new(account_id); - let address_bech32 = address.encode(network_id); - - // Wait for the website to be fully loaded - client - .wait() - .at_most(Duration::from_secs(10)) - .for_element(fantoccini::Locator::Css("#token-amount option")) - .await - .unwrap(); - - // Fill in the account address - client - .find(fantoccini::Locator::Css("#recipient-address")) - .await - .unwrap() - .send_keys(&address_bech32) - .await - .unwrap(); - - // Select the first asset amount option - client - .find(fantoccini::Locator::Css("#token-amount")) - .await - .unwrap() - .click() - .await - .unwrap(); - client - .find(fantoccini::Locator::Css("#token-amount option")) - .await - .unwrap() - .click() - .await - .unwrap(); - - // Click the public note button - client - .find(fantoccini::Locator::Css("#send-button")) - .await - .unwrap() - .click() - .await - .unwrap(); - - // Execute a script to get all the failed requests - let script = r" - let errors = []; - performance.getEntriesByType('resource').forEach(entry => { - if (entry.responseStatus && entry.responseStatus >= 400) { - errors.push({url: entry.name, status: entry.responseStatus}); - } - }); - return errors; - "; - let failed_requests = client.execute(script, vec![]).await.unwrap(); - - // Verify all requests are successful - assert_eq!(failed_requests.as_array().unwrap().as_slice(), [] as [Value; 0]); - - client.close().await.unwrap(); - } - - // TESTING HELPERS - // --------------------------------------------------------------------------------------------- - - pub async fn run_stub_node() -> Url { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let listener_addr = listener.local_addr().unwrap(); - let stub_node_url = Url::from_str(&format!("http://{listener_addr}")).unwrap(); - tokio::spawn(async move { serve_stub(listener).await.unwrap() }); - stub_node_url - } - - /// Starts a faucet against the given stubs and returns its frontend URL. - fn run_faucet_server(stub_node_url: Url, funding_service_url: Url) -> String { - // The faucet reads the file on startup, so it has to exist even with no keys in it. - let api_keys_path = temp_dir().join(format!("{}.keys", Uuid::new_v4())); - std::fs::write(&api_keys_path, "").unwrap(); - - let config = FaucetConfig { - node_url: Some(stub_node_url), - timeout: Duration::from_secs(5), - network: FaucetNetwork::Localhost, - api_keys_path: Some(api_keys_path), - }; - let api_bind_port = 8000; - let frontend_url = "http://localhost:8080"; - - // Use std::thread to launch faucet - avoids Send requirements - std::thread::spawn(move || { - // Create a new runtime for this thread - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to build runtime"); - - // Run the faucet on this thread's runtime - rt.block_on(async { - Box::pin(run_faucet_command(Cli { - command: crate::Command::Start { - config, - funding_service_url, - decimals: 6, - api_bind_port, - api_public_url: Url::parse(&format!("http://localhost:{api_bind_port}")) - .unwrap(), - frontend_bind_port: 8080, - no_frontend: false, - max_claimable_amount: 1_000_000_000, - pow_secret: Some("test".to_string()), - pow_challenge_lifetime: Duration::from_secs(30), - pow_cleanup_interval: Duration::from_secs(1), - pow_growth_rate: 1.0, - pow_baseline: 12, - base_amount: 100_000, - token_amounts: vec![1, 10, 100], - open_telemetry: false, - explorer_url: None, - }, - })) - .await - .expect("failed to start faucet"); - }); - }); - - frontend_url.to_string() - } - - async fn start_fantoccini_client() -> fantoccini::Client { - // Start chromedriver. This requires having chromedriver and chrome installed - let chromedriver_port = "57708"; - let mut chromedriver = tokio::process::Command::new("chromedriver") - .arg(format!("--port={chromedriver_port}")) - .stdout(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .expect("failed to start chromedriver"); - let stdout = chromedriver.stdout.take().unwrap(); - tokio::spawn( - async move { chromedriver.wait().await.expect("chromedriver process failed") }, - ); - // Wait for chromedriver to be running - let mut reader = tokio::io::BufReader::new(stdout).lines(); - while let Some(line) = reader.next_line().await.unwrap() { - if line.contains("ChromeDriver was started successfully") { - break; - } - } - - // Start fantoccini client - ClientBuilder::native() - .capabilities( - [( - "goog:chromeOptions".to_string(), - json!({"args": ["--headless", "--disable-gpu", "--no-sandbox"]}), - )] - .into_iter() - .collect::>(), - ) - .connect(&format!("http://localhost:{chromedriver_port}")) - .await - .expect("failed to connect to WebDriver") - } - - async fn run_stub_funding_service() -> Url { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = Url::from_str(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); - tokio::spawn(async move { serve_stub_funding_service(listener).await.unwrap() }); - url - } -} diff --git a/scripts/e2e/network.sh b/scripts/e2e/network.sh new file mode 100755 index 00000000..e41b9418 --- /dev/null +++ b/scripts/e2e/network.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Starts and stops the node and funding service the end-to-end test runs against. +# +# The node repo already has a compose stack for a complete local network, so it is cloned and used +# as is. The images are the published ones, so nothing is built. + +set -euo pipefail + +WORK_DIR="${WORK_DIR:-target/e2e}" +REGISTRY="${REGISTRY:-ghcr.io/0xmiden}" +PROJECT="${PROJECT:-miden-faucet-e2e}" +VERSION="${NODE_VERSION:-$(sed -n '/^name = "miden-node-proto-build"$/{n;s/^version = "\(.*\)"$/\1/p;}' Cargo.lock)}" + +if [[ -z "${VERSION}" ]]; then + echo "error: could not read the miden-node-proto-build version from Cargo.lock" >&2 + exit 1 +fi + +CHECKOUT="${WORK_DIR}/node-${VERSION}" + +export MIDEN_NODE_IMAGE="${REGISTRY}/miden-node:v${VERSION}" +export MIDEN_VALIDATOR_IMAGE="${REGISTRY}/miden-validator:v${VERSION}" +export MIDEN_NTX_BUILDER_IMAGE="${REGISTRY}/miden-ntx-builder:v${VERSION}" +export MIDEN_REMOTE_PROVER_IMAGE="${REGISTRY}/miden-remote-prover:v${VERSION}" +export MIDEN_FUNDING_SERVICE_IMAGE="${REGISTRY}/miden-funding-service:v${VERSION}" +export MIDEN_USDCX_GENESIS_IMAGE="${REGISTRY}/miden-usdcx-genesis:v${VERSION}" + +compose() { + docker compose \ + --project-name "${PROJECT}" \ + --file "${CHECKOUT}/docker-compose.yml" \ + "$@" +} + +case "${1:-}" in + up) + if [[ ! -f "${CHECKOUT}/docker-compose.yml" ]]; then + echo "Cloning the node repo at v${VERSION}" + rm -rf "${CHECKOUT}" + git clone --depth 1 --branch "v${VERSION}" https://github.com/0xMiden/node "${CHECKOUT}" + fi + # Starting the funding service pulls in everything it depends on: the genesis bootstrap, + # the validators, the sequencer and the prover. + compose up --detach funding-service + ;; + down) + if [[ ! -f "${CHECKOUT}/docker-compose.yml" ]]; then + echo "Nothing to stop: ${CHECKOUT} does not exist" + exit 0 + fi + compose down --volumes --remove-orphans + ;; + logs) + if [[ ! -f "${CHECKOUT}/docker-compose.yml" ]]; then + echo "No logs: ${CHECKOUT} does not exist" + exit 0 + fi + compose logs --no-color + ;; + *) + echo "usage: $0 up|down|logs" >&2 + exit 2 + ;; +esac