diff --git a/CHANGELOG.md b/CHANGELOG.md index 61eb3ad4..ca837905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.12.5 (TBD) + +- [BREAKING] Added a new `miden-faucet-client` binary with the `mint` command ([#195](https://github.com/0xMiden/miden-faucet/pull/195)). + ## 0.12.4 (2025-12-04) - Added version to the metadata endpoint ([#169](https://github.com/0xMiden/miden-faucet/pull/169)). @@ -76,7 +80,7 @@ - Redesigned the home frontend ([#20](https://github.com/0xMiden/miden-faucet/pull/20)). - Redesigned the tokens request flows ([#25](https://github.com/0xMiden/miden-faucet/pull/25)). - Added faucet supply amounts to the metadata ([#30](https://github.com/0xMiden/miden-faucet/pull/30)). -- Added supply exceeded check ([#31](https://github.com/0xMiden/miden-faucet/pull/31)). +- Added supply exceeded check ([#31](https://github.com/0xMiden/miden-faucet/pull/31)). - Use HTTP 429 status code for rate limited error ([#51](https://github.com/0xMiden/miden-faucet/pull/51)). - Replace amount options validation for maximum claimable amount ([#52](https://github.com/0xMiden/miden-faucet/pull/52)). - Added `mdbook` documentation ([#61](https://github.com/0xMiden/miden-faucet/pull/61)). diff --git a/Cargo.lock b/Cargo.lock index 4942ec6e..66e84e1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,9 +285,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "bech32" @@ -370,9 +370,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -386,6 +386,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -1338,6 +1344,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -1487,9 +1510,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections 2.1.1", "icu_locale_core", @@ -1501,9 +1524,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1910,6 +1933,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -2067,9 +2096,9 @@ dependencies = [ [[package]] name = "miden-crypto" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0048d2d987f215bc9633ced499a8c488d0e2474350c765f904b87cae3462acb7" +checksum = "395e5cc76b64e24533ee55c8d1ff90305b8cad372bdbea4f4f324239e36a895f" dependencies = [ "blake3", "cc", @@ -2099,9 +2128,9 @@ dependencies = [ [[package]] name = "miden-crypto-derive" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3b38aace84e157fb02aba8f8ae85bbf8c3afdcdbdf8190fbe7476f3be7ef44" +checksum = "c89641b257eb395cf03105ac1c6cbdf3fd9a5450749696af9835c3c47fc6806e" dependencies = [ "quote", "syn", @@ -2166,6 +2195,26 @@ dependencies = [ "url", ] +[[package]] +name = "miden-faucet-client" +version = "0.12.4" +dependencies = [ + "anyhow", + "axum", + "clap", + "hex", + "miden-client", + "miden-faucet-lib", + "miden-pow-rate-limiter", + "rand", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "url", +] + [[package]] name = "miden-faucet-lib" version = "0.12.4" @@ -2174,6 +2223,7 @@ dependencies = [ "miden-client", "miden-client-sqlite-store", "rand", + "serde", "thiserror 2.0.17", "tokio", "tracing", @@ -3309,6 +3359,61 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -3449,9 +3554,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" dependencies = [ "base64", "bytes", @@ -3460,16 +3565,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3477,6 +3587,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -3533,6 +3644,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.2.3" @@ -3610,6 +3727,7 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ + "web-time", "zeroize", ] @@ -4045,9 +4163,9 @@ dependencies = [ [[package]] name = "term" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2111ef44dae28680ae9752bb89409e7310ca33a8c621ebe7b106cf5c928b3ac0" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ "windows-sys 0.61.2", ] @@ -4191,6 +4309,21 @@ dependencies = [ "zerovec 0.11.5", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" @@ -4490,9 +4623,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -4917,6 +5050,15 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index d005a77e..3a0a1787 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bin/faucet", "crates/faucet"] +members = ["bin/faucet", "bin/faucet-client", "crates/faucet"] resolver = "2" diff --git a/Makefile b/Makefile index c27c2c6a..555351e6 100644 --- a/Makefile +++ b/Makefile @@ -81,6 +81,7 @@ check: ## Check all targets and features for errors without code generation .PHONY: install-faucet install-faucet: ## Installs faucet ${BUILD_PROTO} cargo install --path bin/faucet --locked + ${BUILD_PROTO} cargo install --path bin/faucet-client --locked .PHONY: check-tools check-tools: ## Checks if development tools are installed diff --git a/README.md b/README.md index 1909cb8d..8ed2097e 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,16 @@ For comprehensive guides, API reference, and examples, see the [Miden Faucet Doc ## Running the faucet -1. Install the faucet: +The faucet comes with two CLI tools: +- **miden-faucet**: Runs the faucet, used for initializing and starting the faucet. +- **miden-faucet-client**: Used for interacting with a live faucet, i.e. for requesting tokens from a running faucet. + +1. Install both faucet binaries: ```bash make install-faucet ``` -2. Initialize the faucet. This will generate a new account with the specified token configuration and save the account data to a local SQLite store: +2. Initialize the faucet server. This will generate a new account with the specified token configuration and save the account data to a local SQLite store: ```bash miden-faucet init \ @@ -25,6 +29,8 @@ miden-faucet init \ > [!TIP] > This account will not be created on chain yet, creation on chain will happen on the first minting transaction. +> You can also run the legacy alias `miden-faucet` for backwards compatibility; it runs the same `miden-faucet` binary. + 3. Start the faucet: ```bash miden-faucet start \ @@ -34,6 +40,13 @@ miden-faucet start \ --network testnet ``` +## Requesting tokens from a live faucet + +You can use the `miden-faucet-client` binary to request tokens from any running faucet instance, whether it's your local faucet or the remote testnet faucet: +```bash +miden-faucet-client mint --url --target-account --amount +``` + After a few seconds you may go to `http://localhost:8080` and see the faucet UI. ## Faucet security features: diff --git a/bin/faucet-client/Cargo.toml b/bin/faucet-client/Cargo.toml new file mode 100644 index 00000000..d66b90fb --- /dev/null +++ b/bin/faucet-client/Cargo.toml @@ -0,0 +1,33 @@ +[package] +authors.workspace = true +description = "Client CLI to request tokens from a remote Miden faucet (PoW mint)" +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "miden-faucet-client" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { features = ["derive", "env", "string"], version = "4.5" } +hex = { version = "0.4" } +miden-client = { workspace = true } +miden-faucet-lib = { workspace = true } +miden-pow-rate-limiter = { workspace = true } +rand = { features = ["thread_rng"], workspace = true } +reqwest = { default-features = false, features = ["json", "rustls-tls"], version = "0.12" } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { features = ["macros", "net", "rt-multi-thread", "sync", "time"], workspace = true } +url = { workspace = true } + +[dev-dependencies] +axum = { features = ["tokio"], version = "0.8" } +serde = { workspace = true } +tokio = { features = ["macros", "net", "rt-multi-thread", "time"], workspace = true } diff --git a/bin/faucet-client/README.md b/bin/faucet-client/README.md new file mode 100644 index 00000000..d13d1d43 --- /dev/null +++ b/bin/faucet-client/README.md @@ -0,0 +1,13 @@ +# miden-faucet-client + +Command-line tool for interacting with a live Miden faucet. + +## Commands + +### `mint` + +Requests tokens from a faucet by solving its PoW challenge and minting a public P2ID note. + +```bash +miden-faucet-client mint --url --target-account --amount +``` diff --git a/bin/faucet-client/src/lib.rs b/bin/faucet-client/src/lib.rs new file mode 100644 index 00000000..7e5e8b22 --- /dev/null +++ b/bin/faucet-client/src/lib.rs @@ -0,0 +1 @@ +pub mod mint; diff --git a/bin/faucet-client/src/main.rs b/bin/faucet-client/src/main.rs new file mode 100644 index 00000000..adf44cce --- /dev/null +++ b/bin/faucet-client/src/main.rs @@ -0,0 +1,29 @@ +use clap::{Parser, Subcommand}; +use miden_faucet_client::mint; + +/// Client CLI for interacting with a live faucet. +#[derive(Parser)] +#[command(version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Request tokens from a remote faucet (does not consume the resulting note). + Mint(mint::MintCmd), +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Command::Mint(cmd) => { + cmd.execute().await.map_err(anyhow::Error::from)?; + }, + } + + Ok(()) +} diff --git a/bin/faucet-client/src/mint.rs b/bin/faucet-client/src/mint.rs new file mode 100644 index 00000000..3df0868b --- /dev/null +++ b/bin/faucet-client/src/mint.rs @@ -0,0 +1,298 @@ +//! CLI command to request a public P2ID note from a remote faucet by solving its `PoW` challenge. + +use std::time::Duration; + +use clap::Parser; +use miden_client::Word; +use miden_client::account::{AccountId, Address}; +use miden_client::address::AddressId; +use miden_client::note::NoteId; +use miden_client::transaction::TransactionId; +use miden_faucet_lib::requests::{ + GetPowResponse, + GetTokensQueryParams, + GetTokensResponse, + MintResponse, + PowQueryParams, +}; +use miden_pow_rate_limiter::{Challenge, ChallengeError}; +use rand::Rng; +use reqwest::{Client as HttpClient, Url}; +use tokio::task; + +// CONSTANTS +// ================================================================================================ + +const DEFAULT_FAUCET_URL: &str = "https://faucet-api.testnet.miden.io"; +const REQUEST_TIMEOUT_MS: u64 = 30_000; + +// CLI +// ================================================================================================ + +/// Mint tokens from a remote faucet by solving its `PoW` challenge and requesting a **public** +/// P2ID note. +#[derive(Debug, Parser, Clone)] +pub struct MintCmd { + /// Faucet API base URL. Defaults to the public testnet faucet. + #[arg(long = "url", default_value = DEFAULT_FAUCET_URL, value_name = "URL")] + api_url: String, + + /// Account ID or address to receive the minted tokens. + #[arg(short = 't', long = "target-account", value_name = "ACCOUNT")] + target_account: String, + + /// Amount to mint (in base units). + #[arg(short = 'a', long = "amount", value_name = "U64")] + amount: u64, + + /// Optional faucet API key. + #[arg(long = "api-key", value_name = "STRING")] + api_key: Option, +} + +impl MintCmd { + /// Executes the mint command. + pub async fn execute(&self) -> Result<(), MintClientError> { + if self.amount == 0 { + return Err(MintClientError::AmountZero); + } + + let account_id = parse_account_id(&self.target_account)?; + let faucet_client = + FaucetHttpClient::new(&self.api_url, REQUEST_TIMEOUT_MS, self.api_key.clone())?; + + println!( + "Requesting PoW challenge for account {} from faucet at {}...", + account_id.to_hex(), + faucet_client.base_url + ); + + let (challenge, target) = faucet_client.request_pow(&account_id, self.amount).await?; + + println!("Solving faucet PoW challenge, this can take some time..."); + let nonce = solve_challenge(&challenge, target).await?; + + println!("Submitting mint request for a public P2ID note..."); + let mint_response = faucet_client + .request_tokens(&challenge, nonce, &account_id, self.amount) + .await?; + + println!("Mint request accepted. Transaction: {}", mint_response.tx_id.to_hex()); + println!("Public P2ID note commitment: {}", mint_response.note_id.to_hex()); + + Ok(()) + } +} + +// HTTP CLIENT +// ================================================================================================ + +/// HTTP client for interacting with the faucet API. +#[derive(Clone)] +struct FaucetHttpClient { + http_client: HttpClient, + base_url: Url, + api_key: Option, +} + +impl FaucetHttpClient { + /// Creates a new `FaucetHttpClient` instance. + fn new( + endpoint: &str, + timeout_ms: u64, + api_key: Option, + ) -> Result { + let base_url = Url::parse(endpoint) + .map_err(|err| MintClientError::InvalidUrl(endpoint.to_owned(), err))?; + + let http_client = HttpClient::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(MintClientError::HttpClient)?; + + Ok(Self { http_client, base_url, api_key }) + } + + /// Requests a `PoW` challenge from the faucet API. + async fn request_pow( + &self, + account_id: &AccountId, + amount: u64, + ) -> Result<(String, u64), MintClientError> { + let pow_url = self + .base_url + .join("pow") + .map_err(|err| MintClientError::InvalidUrl(self.base_url.to_string(), err))?; + + let params = PowQueryParams { + account_id: account_id.to_hex(), + amount, + api_key: self.api_key.clone(), + }; + + let response = self + .http_client + .get(pow_url) + .query(¶ms) + .send() + .await + .map_err(|err| MintClientError::Request("PoW", err))?; + + if !response.status().is_success() { + let status = response.status(); + let body = + response.text().await.map_err(|err| MintClientError::ResponseBody("pow", err))?; + return Err(MintClientError::UnexpectedStatus("pow", status, body)); + } + + let body = + response.text().await.map_err(|err| MintClientError::ResponseBody("pow", err))?; + let parsed = serde_json::from_str::(&body) + .map_err(|err| MintClientError::ParseResponse("PoW", err, body.clone()))?; + + Ok((parsed.challenge, parsed.target)) + } + + /// Requests tokens from the faucet API. + async fn request_tokens( + &self, + challenge: &str, + nonce: u64, + account_id: &AccountId, + amount: u64, + ) -> Result { + let url = self + .base_url + .join("get_tokens") + .map_err(|err| MintClientError::InvalidUrl(self.base_url.to_string(), err))?; + + let params = GetTokensQueryParams { + account_id: account_id.to_hex(), + asset_amount: amount, + is_private_note: false, + challenge: challenge.to_owned(), + nonce, + api_key: self.api_key.clone(), + }; + + let response = self + .http_client + .get(url) + .query(¶ms) + .send() + .await + .map_err(|err| MintClientError::Request("get_tokens", err))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .map_err(|err| MintClientError::ResponseBody("get_tokens", err))?; + return Err(MintClientError::UnexpectedStatus("get_tokens", status, body)); + } + + let body = response + .text() + .await + .map_err(|err| MintClientError::ResponseBody("get_tokens", err))?; + let parsed = serde_json::from_str::(&body) + .map_err(|err| MintClientError::ParseResponse("get_tokens", err, body.clone()))?; + + let note_id = NoteId::try_from_hex(&parsed.note_id).map_err(|err| { + MintClientError::InvalidNoteId(parsed.note_id.clone(), err.to_string()) + })?; + + let tx_id = + Word::try_from(parsed.tx_id.as_str()).map(TransactionId::from).map_err(|err| { + MintClientError::InvalidTransactionId(parsed.tx_id.clone(), err.to_string()) + })?; + + Ok(MintResponse { note_id, tx_id }) + } +} + +// ERRORS +// ================================================================================================ + +/// Errors that can occur while interacting with the faucet API. +#[derive(Debug, thiserror::Error)] +pub enum MintClientError { + #[error("amount must be greater than zero")] + AmountZero, + #[error("invalid account `{0}`: {1}")] + InvalidAccount(String, String), + #[error("invalid faucet URL `{0}`: {1}")] + InvalidUrl(String, url::ParseError), + #[error("failed to build HTTP client: {0}")] + HttpClient(#[source] reqwest::Error), + #[error("{0} request failed: {1}")] + Request(&'static str, #[source] reqwest::Error), + #[error("{0} request failed with status {1}: {2}")] + UnexpectedStatus(&'static str, reqwest::StatusCode, String), + #[error("failed to parse {0} response: {1}. Body: {2}")] + ParseResponse(&'static str, #[source] serde_json::Error, String), + #[error("failed to read {0} response body: {1}")] + ResponseBody(&'static str, #[source] reqwest::Error), + #[error("faucet returned a PoW target of 0")] + ZeroTarget, + #[error("invalid challenge hex returned by faucet: {0}")] + InvalidChallengeHex(#[source] hex::FromHexError), + #[error("invalid challenge returned by faucet: {0}")] + InvalidChallenge(#[source] ChallengeError), + #[error("PoW solving task failed: {0}")] + PowTask(String), + #[error("invalid note id `{0}`: {1}")] + InvalidNoteId(String, String), + #[error("invalid transaction id `{0}`: {1}")] + InvalidTransactionId(String, String), +} + +// HELPERS +// ================================================================================================ + +/// Parses a user provided account ID string and returns the corresponding `AccountId` +fn parse_account_id(input: &str) -> Result { + if input.starts_with("0x") { + AccountId::from_hex(input) + .map_err(|err| MintClientError::InvalidAccount(input.to_owned(), err.to_string())) + } else { + Address::decode(input) + .map_err(|err| MintClientError::InvalidAccount(input.to_owned(), err.to_string())) + .and_then(|(_, address)| match address.id() { + AddressId::AccountId(account_id) => Ok(account_id), + _ => Err(MintClientError::InvalidAccount( + input.to_owned(), + "address is not account-based".to_owned(), + )), + }) + } +} + +/// 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 { + if target == 0 { + return Err(MintClientError::ZeroTarget); + } + + let challenge_bytes = + hex::decode(challenge_hex).map_err(MintClientError::InvalidChallengeHex)?; + let challenge = Challenge::try_from(challenge_bytes.as_slice()) + .map_err(MintClientError::InvalidChallenge)?; + + task::spawn_blocking(move || { + let mut rng = rand::rng(); + + loop { + let nonce: u64 = rng.random(); + + if challenge.validate_pow(nonce) { + return nonce; + } + } + }) + .await + .map_err(|err| MintClientError::PowTask(err.to_string())) +} diff --git a/bin/faucet-client/tests/mint.rs b/bin/faucet-client/tests/mint.rs new file mode 100644 index 00000000..e34cce14 --- /dev/null +++ b/bin/faucet-client/tests/mint.rs @@ -0,0 +1,134 @@ +use std::sync::Arc; + +use axum::extract::{Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use clap::Parser; +use miden_client::account::AccountId; +use miden_client::note::NoteId; +use miden_client::utils::ToHex; +use miden_faucet_client::mint::MintCmd; +use miden_faucet_lib::requests::{ + GetPowResponse, + GetTokensQueryParams, + GetTokensResponse, + PowQueryParams, +}; +use miden_pow_rate_limiter::Challenge; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +#[derive(Clone, Default)] +struct RecordedRequest { + pow_params: Option, + tokens_params: Option, +} + +#[derive(Clone)] +struct AppState { + pow_response: GetPowResponse, + note_id_hex: String, + tx_id: String, + recorded: Arc>, + challenge_hex: String, +} + +#[tokio::test] +async fn mint_command_requests_public_note() { + let account_hex = "0xca8203e8e58cf72049b061afca78ce"; + let account_id = AccountId::from_hex(account_hex).unwrap(); + let expected_amount = 123_000; + + // Create a valid Challenge with target = u64::MAX so any nonce will solve it + let challenge = Challenge::from_parts( + u64::MAX, // target - any nonce will pass + 0, // timestamp + 1, // request_complexity + [0u8; 32], // requestor + [0u8; 32], // domain + [0u8; 32], // signature (doesn't matter for client-side validation) + ); + let challenge_hex = challenge.to_bytes().to_hex(); + + let pow_response = GetPowResponse { + challenge: challenge_hex.clone(), + target: u64::MAX, + timestamp: 0, + }; + let note_id_hex = format!("0x{}", "00".repeat(32)); + let _note_id = NoteId::try_from_hex(¬e_id_hex).expect("hex string should produce a note id"); + // TransactionId requires a valid 32-byte Word (64 hex chars) + let tx_id_hex = format!("0x{}", "ab".repeat(32)); + let app_state = AppState { + pow_response, + note_id_hex, + tx_id: tx_id_hex, + recorded: Arc::new(Mutex::new(RecordedRequest::default())), + challenge_hex, + }; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new() + .route("/pow", get(pow_handler)) + .route("/get_tokens", get(tokens_handler)) + .with_state(app_state.clone()); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let cli = MintCmd::parse_from([ + "mint", + "--url", + format!("http://{addr}").as_str(), + "--target-account", + account_id.to_hex().as_str(), + "--amount", + &expected_amount.to_string(), + "--api-key", + "test-key", + ]); + + cli.execute().await.unwrap(); + + let recorded = app_state.recorded.lock().await.clone(); + + // Verify PoW request params + let pow_params = recorded.pow_params.expect("pow_params should be recorded"); + assert_eq!(pow_params.account_id, account_id.to_hex()); + assert_eq!(pow_params.amount, expected_amount); + assert_eq!(pow_params.api_key.as_deref(), Some("test-key")); + + // Verify get_tokens request params + let tokens_params = recorded.tokens_params.expect("tokens_params should be recorded"); + assert_eq!(tokens_params.account_id, account_id.to_hex()); + assert_eq!(tokens_params.asset_amount, expected_amount); + assert!(!tokens_params.is_private_note); + assert_eq!(tokens_params.api_key.as_deref(), Some("test-key")); + assert_eq!(tokens_params.challenge, app_state.challenge_hex); +} + +async fn pow_handler( + State(state): State, + Query(params): Query, +) -> Json { + { + let mut recorded = state.recorded.lock().await; + recorded.pow_params = Some(params); + } + Json(state.pow_response.clone()) +} + +async fn tokens_handler( + State(state): State, + Query(params): Query, +) -> Json { + { + let mut recorded = state.recorded.lock().await; + recorded.tokens_params = Some(params); + } + Json(GetTokensResponse { + note_id: state.note_id_hex.clone(), + tx_id: state.tx_id.clone(), + }) +} diff --git a/bin/faucet/src/api/get_pow.rs b/bin/faucet/src/api/get_pow.rs index 73cd3cb1..cf555b32 100644 --- a/bin/faucet/src/api/get_pow.rs +++ b/bin/faucet/src/api/get_pow.rs @@ -5,7 +5,7 @@ use http::StatusCode; use miden_client::account::{AccountId, Address}; use miden_client::address::AddressId; use miden_client::utils::ToHex; -use serde::{Deserialize, Serialize}; +use miden_faucet_lib::requests::{GetPowResponse, PowQueryParams}; use tracing::{info_span, instrument}; use crate::COMPONENT; @@ -21,9 +21,9 @@ use crate::api_key::ApiKey; )] pub async fn get_pow( State(server): State, - Query(params): Query, + Query(params): Query, ) -> Result, PowRequestError> { - let request = params.validate()?; + let request = validate_pow_params(params)?; let account_id_bytes: [u8; AccountId::SERIALIZED_SIZE] = request.account_id.into(); let mut requestor = [0u8; 32]; requestor[..AccountId::SERIALIZED_SIZE].copy_from_slice(&account_id_bytes); @@ -48,13 +48,6 @@ pub async fn get_pow( })) } -#[derive(Serialize, Debug)] -pub struct GetPowResponse { - challenge: String, - target: u64, - timestamp: u64, -} - // REQUEST VALIDATION // ================================================================================================ @@ -65,38 +58,32 @@ pub struct PowRequest { pub api_key: ApiKey, } -/// Used to receive the initial `get_pow` request from the user. -#[derive(Deserialize)] -pub struct RawPowRequest { - amount: u64, - account_id: String, - api_key: Option, -} - -impl RawPowRequest { - pub fn validate(self) -> Result { - let account_id = if self.account_id.starts_with("0x") { - AccountId::from_hex(&self.account_id).map_err(AccountError::ParseId) - } else { - Address::decode(&self.account_id).map_err(AccountError::ParseAddress).and_then( - |(_, address)| match address.id() { - AddressId::AccountId(account_id) => Ok(account_id), - _ => Err(AccountError::AddressNotIdBased), - }, - ) - } - .map_err(PowRequestError::AccountError)?; +fn validate_pow_params(params: PowQueryParams) -> Result { + let account_id = if params.account_id.starts_with("0x") { + AccountId::from_hex(¶ms.account_id).map_err(AccountError::ParseId) + } else { + Address::decode(¶ms.account_id) + .map_err(AccountError::ParseAddress) + .and_then(|(_, address)| match address.id() { + AddressId::AccountId(account_id) => Ok(account_id), + _ => Err(AccountError::AddressNotIdBased), + }) + } + .map_err(PowRequestError::AccountError)?; - let api_key = self - .api_key - .as_deref() - .map(ApiKey::decode) - .transpose() - .map_err(|_| PowRequestError::InvalidApiKey(self.api_key.unwrap_or_default()))? - .unwrap_or_default(); + let api_key = params + .api_key + .as_deref() + .map(ApiKey::decode) + .transpose() + .map_err(|_| PowRequestError::InvalidApiKey(params.api_key.unwrap_or_default()))? + .unwrap_or_default(); - Ok(PowRequest { amount: self.amount, account_id, api_key }) - } + Ok(PowRequest { + amount: params.amount, + account_id, + api_key, + }) } #[derive(Debug, thiserror::Error)] diff --git a/bin/faucet/src/api/get_tokens.rs b/bin/faucet/src/api/get_tokens.rs index 9e6fb71b..4009dbeb 100644 --- a/bin/faucet/src/api/get_tokens.rs +++ b/bin/faucet/src/api/get_tokens.rs @@ -4,10 +4,15 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use miden_client::account::{AccountId, Address}; use miden_client::address::AddressId; -use miden_faucet_lib::requests::{MintError, MintRequest, MintRequestSender}; +use miden_faucet_lib::requests::{ + GetTokensQueryParams, + GetTokensResponse, + MintError, + MintRequest, + MintRequestSender, +}; use miden_faucet_lib::types::{AssetAmount, AssetAmountError, NoteType}; use miden_pow_rate_limiter::ChallengeError; -use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::oneshot; use tracing::{Instrument, info_span, instrument}; @@ -29,11 +34,12 @@ use crate::api_key::ApiKey; )] pub async fn get_tokens( State(server): State, - Query(request): Query, + Query(request): Query, ) -> Result, GetTokenError> { let (mint_response_sender, mint_response_receiver) = oneshot::channel(); - let validated_request = request.validate(&server).map_err(GetTokenError::InvalidRequest)?; + let validated_request = + validate_get_tokens_params(&request, &server).map_err(GetTokenError::InvalidRequest)?; let enqueue_span = info_span!(target: COMPONENT, "server.get_tokens.enqueue"); { @@ -60,12 +66,6 @@ pub async fn get_tokens( })) } -#[derive(Serialize, Debug)] -pub struct GetTokensResponse { - tx_id: String, - note_id: String, -} - // STATE // ================================================================================================ @@ -84,19 +84,6 @@ impl GetTokensState { // REQUEST VALIDATION // ================================================================================================ -/// Used to receive the initial request from the user. -/// -/// Further parsing is done to get the expected [`MintRequest`] expected by the faucet client. -#[derive(Deserialize)] -pub struct RawMintRequest { - pub account_id: String, - pub is_private_note: bool, - pub asset_amount: u64, - pub challenge: Option, - pub nonce: Option, - pub api_key: Option, -} - #[derive(Debug, thiserror::Error)] pub enum MintRequestError { #[error(transparent)] @@ -109,8 +96,6 @@ pub enum MintRequestError { PowError(#[from] ChallengeError), #[error("API key {0} is invalid")] InvalidApiKey(String), - #[error("PoW parameters are missing")] - MissingPowParameters, } #[derive(Debug, thiserror::Error)] @@ -189,69 +174,68 @@ impl IntoResponse for GetTokenError { } } -impl RawMintRequest { - /// Further validates a raw request, turning it into a valid [`MintRequest`] which can be - /// submitted to the faucet client. - /// - /// # Errors - /// - /// Returns an error if: - /// - the account ID is not a valid hex string - /// - the asset amount is not one of the provided options - /// - the API key is invalid - /// - the challenge is missing or invalid - /// - the nonce is missing or doesn't solve the challenge - /// - the challenge timestamp is expired - /// - the challenge has already been used - fn validate(self, server: &ApiServer) -> Result { - let note_type = if self.is_private_note { - NoteType::Private - } else { - NoteType::Public - }; - - let account_id = if self.account_id.starts_with("0x") { - AccountId::from_hex(&self.account_id).map_err(AccountError::ParseId) - } else { - Address::decode(&self.account_id).map_err(AccountError::ParseAddress).and_then( - |(_, address)| match address.id() { - AddressId::AccountId(account_id) => Ok(account_id), - _ => Err(AccountError::AddressNotIdBased), - }, - ) - } - .map_err(MintRequestError::AccountError)?; - - let asset_amount = - AssetAmount::new(self.asset_amount).map_err(MintRequestError::InvalidAssetAmount)?; - if asset_amount > server.mint_state.max_claimable_amount { - return Err(MintRequestError::AssetAmountTooBig( - asset_amount, - server.mint_state.max_claimable_amount, - )); - } +/// Further validates a raw request, turning it into a valid [`MintRequest`] which can be +/// submitted to the faucet client. +/// +/// # Errors +/// +/// Returns an error if: +/// - the account ID is not a valid hex string +/// - the asset amount is not one of the provided options +/// - the API key is invalid +/// - the challenge is invalid +/// - the nonce doesn't solve the challenge +/// - the challenge timestamp is expired +/// - the challenge has already been used +fn validate_get_tokens_params( + params: &GetTokensQueryParams, + server: &ApiServer, +) -> Result { + let note_type = if params.is_private_note { + NoteType::Private + } else { + NoteType::Public + }; + + let account_id = if params.account_id.starts_with("0x") { + AccountId::from_hex(¶ms.account_id).map_err(AccountError::ParseId) + } else { + Address::decode(¶ms.account_id) + .map_err(AccountError::ParseAddress) + .and_then(|(_, address)| match address.id() { + AddressId::AccountId(account_id) => Ok(account_id), + _ => Err(AccountError::AddressNotIdBased), + }) + } + .map_err(MintRequestError::AccountError)?; + + let asset_amount = + AssetAmount::new(params.asset_amount).map_err(MintRequestError::InvalidAssetAmount)?; + if asset_amount > server.mint_state.max_claimable_amount { + return Err(MintRequestError::AssetAmountTooBig( + asset_amount, + server.mint_state.max_claimable_amount, + )); + } - // Check the API key, if provided - let api_key = self.api_key.as_deref().map(ApiKey::decode).transpose()?; - if let Some(api_key) = &api_key - && !server.api_keys.contains(api_key) - { - return Err(MintRequestError::InvalidApiKey(api_key.encode())); - } + // Check the API key, if provided + let api_key = params.api_key.as_deref().map(ApiKey::decode).transpose()?; + if let Some(api_key) = &api_key + && !server.api_keys.contains(api_key) + { + return Err(MintRequestError::InvalidApiKey(api_key.encode())); + } - // Validate Challenge and nonce - let challenge_str = self.challenge.ok_or(MintRequestError::MissingPowParameters)?; - let nonce = self.nonce.ok_or(MintRequestError::MissingPowParameters)?; - let request_complexity = server.compute_request_complexity(asset_amount.base_units()); + // Validate Challenge and nonce + let request_complexity = server.compute_request_complexity(asset_amount.base_units()); - server.submit_challenge( - &challenge_str, - nonce, - account_id, - api_key.unwrap_or_default(), - request_complexity, - )?; + server.submit_challenge( + ¶ms.challenge, + params.nonce, + account_id, + api_key.unwrap_or_default(), + request_complexity, + )?; - Ok(MintRequest { account_id, note_type, asset_amount }) - } + Ok(MintRequest { account_id, note_type, asset_amount }) } diff --git a/crates/faucet/Cargo.toml b/crates/faucet/Cargo.toml index 34da090a..77ae9299 100644 --- a/crates/faucet/Cargo.toml +++ b/crates/faucet/Cargo.toml @@ -22,6 +22,7 @@ miden-client-sqlite-store = { workspace = true } # External dependencies. anyhow = { workspace = true } rand = { features = ["thread_rng"], workspace = true } +serde = { workspace = true } thiserror = { workspace = true } tokio = { features = ["fs"], workspace = true } tracing = { workspace = true } diff --git a/crates/faucet/src/requests.rs b/crates/faucet/src/requests.rs index 4a7a9ada..7ec0b59f 100644 --- a/crates/faucet/src/requests.rs +++ b/crates/faucet/src/requests.rs @@ -1,6 +1,7 @@ use miden_client::account::AccountId; use miden_client::note::NoteId; use miden_client::transaction::TransactionId; +use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, oneshot}; use crate::types::{AssetAmount, NoteType}; @@ -8,6 +9,52 @@ use crate::types::{AssetAmount, NoteType}; pub type MintResponseSender = oneshot::Sender>; pub type MintRequestSender = mpsc::Sender<(MintRequest, MintResponseSender)>; +// QUERY PARAMETERS +// ================================================================================================ + +/// Query parameters for the `/pow` endpoint. +/// +/// Used by both the client (to serialize) and the server (to deserialize). +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PowQueryParams { + pub account_id: String, + pub amount: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +/// Query parameters for the `/get_tokens` endpoint. +/// +/// Used by both the client (to serialize) and the server (to deserialize). +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct GetTokensQueryParams { + pub account_id: String, + pub asset_amount: u64, + pub is_private_note: bool, + pub challenge: String, + pub nonce: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +// RESPONSES +// ================================================================================================ + +/// Response from the `/pow` endpoint. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct GetPowResponse { + pub challenge: String, + pub target: u64, + pub timestamp: u64, +} + +/// Response from the `/get_tokens` endpoint. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct GetTokensResponse { + pub tx_id: String, + pub note_id: String, +} + /// A request for minting to the Faucet. pub struct MintRequest { /// Destination account. diff --git a/docs/src/getting-started/cli.md b/docs/src/getting-started/cli.md index 47349558..a431d7f9 100644 --- a/docs/src/getting-started/cli.md +++ b/docs/src/getting-started/cli.md @@ -2,6 +2,11 @@ This guide shows the available commands and their configuration options to run with the Miden Faucet CLI. +The faucet comes with two CLI tools: + +- **miden-faucet**: Runs the faucet, used for initializing and starting the faucet. +- **miden-faucet-client**: Used for interacting with a live faucet, i.e. for requesting tokens from a running faucet. + ## Available Commands | Command | Description | @@ -223,4 +228,18 @@ miden-faucet start \ --network localhost ``` -For detailed options, run `miden-faucet [COMMAND] --help`. +For detailed options, run `miden-faucet [COMMAND] --help`. The legacy alias `miden-faucet` is still available for backwards compatibility. + +## Requesting tokens from a live faucet + +You can use the `miden-faucet-client` binary to request tokens from any running faucet instance, whether it's your local faucet or the remote testnet faucet: +```bash +miden-faucet-client mint --url --target-account --amount +``` + +Although the command is named `mint`, in technical terms it makes a request to the faucet to request a public P2ID note. + +To see available options: +```bash +miden-faucet-client mint --help +``` diff --git a/docs/src/getting-started/installation.md b/docs/src/getting-started/installation.md index 473387c6..a4fc802b 100644 --- a/docs/src/getting-started/installation.md +++ b/docs/src/getting-started/installation.md @@ -39,12 +39,14 @@ Install the latest faucet binary: ```sh cargo install miden-faucet --locked +cargo install miden-faucet-client --locked ``` This will install the latest official version of the faucet. You can install a specific version `x.y.z` using ```sh cargo install miden-faucet --locked --version x.y.z +cargo install miden-faucet-client --locked --version x.y.z ``` You can also use `cargo` to compile the node from the source code if for some reason you need a specific git revision. @@ -54,12 +56,17 @@ this for advanced use only. The incantation is a little different as you'll be t ```sh # Install from a specific branch cargo install --locked --git https://github.com/0xMiden/miden-faucet miden-faucet --branch +cargo install --locked --git https://github.com/0xMiden/miden-faucet miden-faucet-client --branch # Install a specific tag cargo install --locked --git https://github.com/0xMiden/miden-faucet miden-faucet --tag +cargo install --locked --git https://github.com/0xMiden/miden-faucet miden-faucet-client --tag # Install a specific git revision cargo install --locked --git https://github.com/0xMiden/miden-faucet miden-faucet --rev +cargo install --locked --git https://github.com/0xMiden/miden-faucet miden-faucet-client --rev + +> Use `miden-faucet` to initialize/start the faucet service, and `miden-faucet-client` to mint from a running faucet. ``` More information on the various `cargo install` options can be found diff --git a/docs/src/getting-started/quick-start.md b/docs/src/getting-started/quick-start.md index e3fdbac7..9c9d4804 100644 --- a/docs/src/getting-started/quick-start.md +++ b/docs/src/getting-started/quick-start.md @@ -28,12 +28,26 @@ miden-faucet start \ --frontend-url http://localhost:8080 \ --api-bind-url http://localhost:8000 \ --node-url https://rpc.testnet.miden.io \ + --explorer-url https://testnet.midenscan.com \ --network testnet ``` ## Step 3: Request Test Tokens -Once the faucet is running, you can request test tokens through either the web interface or the REST API. +Once the faucet is running, you can request test tokens through either the web interface, the client CLI, or the REST API. + +### Via Client CLI + +Use the dedicated mint command: + +```bash +miden-faucet-client mint \ + --url http://localhost:8000 \ + --target-account \ + --amount 1000 +``` + +Although the command is named `mint`, in technical terms it makes a request to the faucet, solves the PoW challenge and creates a public P2ID note. ### Via Web Interface (if frontend is enabled) @@ -47,6 +61,7 @@ Open `http://localhost:8080` in your browser to access the web interface for gen ### Via API You can also programmatically interact with the REST API to mint tokens. Check out the complete working examples below. Make sure the faucet REST API is running at `http://localhost:8000` before using them. + - [Rust](../examples/rust/request_tokens.rs) - [TypeScript](../examples/typescript/request_tokens.ts) @@ -100,7 +115,7 @@ miden-faucet start \ --api-bind-url http://localhost:8000 \ --explorer-url https://testnet.midenscan.com \ --network testnet -``` +``` ### Faucet API Only (No Frontend)