diff --git a/node/Cargo.toml b/node/Cargo.toml index 9dac4b287..7264013cd 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -28,6 +28,14 @@ serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.29", features = ["rt-multi-thread", "net", "macros"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +miden-client = { git = "https://github.com/0xPolygonMiden/miden-client", branch = "main", features = [ + "testing", + "concurrent", +] } +actix-web = "4" +actix-files = "0.6.5" +actix-cors = "0.7.0" +derive_more = "0.99.17" [dev-dependencies] figment = { version = "0.10", features = ["toml", "env", "test"] } diff --git a/node/src/commands/start.rs b/node/src/commands/start.rs index 3e403a6d8..d6c7b2974 100644 --- a/node/src/commands/start.rs +++ b/node/src/commands/start.rs @@ -8,6 +8,8 @@ use miden_node_utils::config::load_config; use serde::{Deserialize, Serialize}; use tokio::task::JoinSet; +// use crate::faucet; + // Top-level config // ================================================================================================ @@ -22,7 +24,10 @@ pub struct StartCommandConfig { // START // =================================================================================================== -pub async fn start_node(config_filepath: &Path) -> Result<()> { +pub async fn start_node( + config_filepath: &Path, + with_faucet: &bool, +) -> Result<()> { let config: StartCommandConfig = load_config(config_filepath).extract().map_err(|err| { anyhow!("failed to load config file `{}`: {err}", config_filepath.display()) })?; @@ -39,6 +44,12 @@ pub async fn start_node(config_filepath: &Path) -> Result<()> { tokio::time::sleep(Duration::from_secs(1)).await; join_set.spawn(rpc_server::serve(config.rpc)); + // if with_faucet { + // // start miden-faucet + // tokio::time::sleep(Duration::from_secs(1)).await; + // join_set.spawn(faucet::serve()); + // } + // block on all tasks while let Some(res) = join_set.join_next().await { // For now, if one of the components fails, crash the node diff --git a/node/src/faucet/mod.rs b/node/src/faucet/mod.rs new file mode 100644 index 000000000..1eef5eecb --- /dev/null +++ b/node/src/faucet/mod.rs @@ -0,0 +1,190 @@ +use std::sync::Arc; + +use actix_cors::Cors; +use actix_files::{self}; +use actix_web::{http::header, post, web, App, HttpResponse, HttpServer, ResponseError}; +use anyhow::Result; +use derive_more::Display; +use miden_client::{ + client::{transactions::TransactionTemplate, Client}, + config::ClientConfig, +}; +use miden_objects::{accounts::AccountId, assets::FungibleAsset, utils::serde::Serializable}; +use serde::Deserialize; +use tokio::sync::Mutex; + +mod utils; + +#[derive(Debug, Display)] +enum FaucetError { + #[display(fmt = "Internal server error")] + InternalError(String), + + #[display(fmt = "Bad client request data")] + BadClientData(String), +} + +impl ResponseError for FaucetError {} + +#[derive(Deserialize)] +struct UserId { + account_id: String, +} + +struct FaucetState { + client: Arc>, + asset: FungibleAsset, +} + +#[post("/get_tokens")] +async fn get_tokens( + state: web::Data, + req: web::Json, +) -> Result { + println!("Received request from account_id: {}", req.account_id); + + // get account id from user + let target_account_id = AccountId::from_hex(&req.account_id) + .map_err(|e| FaucetError::BadClientData(e.to_string()))?; + + // Sync client and drop the lock before await + let block = { + let mut client = state.client.lock().await; + client.sync_state().await.map_err(|e| { + eprintln!("Failed to sync"); + FaucetError::InternalError(e.to_string()) + })? + }; + + println!("Synced to block: {block}"); + + // create transaction template from data + let template = TransactionTemplate::MintFungibleAsset { + asset: state.asset, + target_account_id, + }; + + // Execute, prove and submit tx + let transaction = { + let mut client = state.client.lock().await; + client.new_transaction(template).map_err(|e| { + eprintln!("Error: {}", e); + FaucetError::InternalError(e.to_string()) + })? + }; + + println!("Transaction has been executed"); + + let note_id = transaction + .created_notes() + .first() + .ok_or_else(|| { + FaucetError::InternalError("Transaction has not created a note.".to_string()) + })? + .id(); + + { + let mut client = state.client.lock().await; + client.send_transaction(transaction).await.map_err(|e| { + println!("error {e}"); + FaucetError::InternalError(e.to_string()) + })?; + } + + println!("Transaction has been proven and sent!"); + + // let mut is_input_note = false; + + // for _ in 0..10 { + // // sync client after submitting tx to get input_note + // let block = state.client.lock().unwrap().sync_state().await.map_err(|e| { + // eprintln!("Failed to sync"); + // FaucetError::InternalError(e.to_string()) + // })?; + + // println!("Synced to block: {block}"); + + // if let Ok(note) = state.client.lock().unwrap().get_input_note(note_id) { + // let input_note_result: Result = note.try_into(); + + // if let Ok(_input_note) = input_note_result { + // is_input_note = true; + // break; + // } + // } + // sleep(Duration::from_secs(1)).await; + // } + + let note = state + .client + .lock() + .await + .get_input_note(note_id) + .map_err(|e| FaucetError::InternalError(e.to_string()))?; + + // if is_input_note { + let bytes = note.to_bytes(); + println!("Transaction has been turned to bytes"); + Ok(HttpResponse::Ok() + .content_type("application/octet-stream") + .append_header(header::ContentDisposition { + disposition: actix_web::http::header::DispositionType::Attachment, + parameters: vec![actix_web::http::header::DispositionParam::Filename( + "note.mno".to_string(), + )], + }) + .body(bytes)) + // } else { + // Err(FaucetError::InternalError("Failed to return note".to_string())) + // } +} + +pub async fn serve() -> Result<()> { + // import faucet + let faucet = match utils::import_account_from_args() { + Ok(account_data) => account_data, + Err(e) => panic!("Failed importing faucet account: {e}"), + }; + + // init asset + let asset = FungibleAsset::new(faucet.account.id(), 100) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + // init client & Arc> to enable safe thread passing and mutability + let config = ClientConfig::default(); + let client = Arc::new(Mutex::new( + Client::new(config) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?, + )); + + // load faucet into client + client + .lock() + .await + .import_account(faucet.clone()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + println!("Faucet: {} has been loaded into client", faucet.account.id()); + + let server = Arc::new(Mutex::new( + HttpServer::new(move || { + let cors = Cors::default().allow_any_origin().allow_any_method().allow_any_header(); + App::new() + .app_data(web::Data::new(FaucetState { + client: client.clone(), + asset, + })) + .wrap(cors) + .service(get_tokens) + .service( + actix_files::Files::new("/", "faucet/src/static/").index_file("index.html"), + ) + }) + .bind("127.0.0.1:8080")? + .run(), + )); + + let _ = server.lock().await; + + Ok(()) +} diff --git a/node/src/faucet/static/background.png b/node/src/faucet/static/background.png new file mode 100644 index 000000000..dbf2e4269 Binary files /dev/null and b/node/src/faucet/static/background.png differ diff --git a/node/src/faucet/static/index.css b/node/src/faucet/static/index.css new file mode 100644 index 000000000..3b4374c38 --- /dev/null +++ b/node/src/faucet/static/index.css @@ -0,0 +1,82 @@ +input:focus { + outline: none; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background-image: url(./background.png); + background-repeat: repeat; +} + +#error-message { + display: none; + color: red; + text-align: center; + margin-bottom: 5px; +} + +#navbar { + position: fixed; + top: 0; + left: 0; + background-color: rgb(17, 24, 39); + width: 100%; + padding: 20px; +} + +#title { + font-size: x-large; + text-align: center; + font-weight: bold; + color: white; + margin: 0; +} + +#center-container { + background-color: rgb(17, 24, 39); + border-radius: 10px; + display: flex; + flex-direction: column; + padding: 30px; +} + +#subtitle { + font-size: large; + text-align: center; + font-weight: bold; + color: white; + margin: 0; + margin-bottom: 20px; +} + +#account-id { + padding: 10px; + border-radius: 10px; + border: 1px solid #ccc; + margin-bottom: 30px; + width: 300px; +} + +#button { + color: white; + border-radius: 10px; + font-weight: bold; + padding: 10px 20px; + background-color: rgb(124, 58, 237); + width: 300px; +} \ No newline at end of file diff --git a/node/src/faucet/static/index.html b/node/src/faucet/static/index.html new file mode 100644 index 000000000..53857dc21 --- /dev/null +++ b/node/src/faucet/static/index.html @@ -0,0 +1,24 @@ + + + + + + + Miden Faucet + + + + + +
+

Request test POL tokens

+ + + +
+ + + + \ No newline at end of file diff --git a/node/src/faucet/static/index.js b/node/src/faucet/static/index.js new file mode 100644 index 000000000..033e8c066 --- /dev/null +++ b/node/src/faucet/static/index.js @@ -0,0 +1,55 @@ +document.addEventListener('DOMContentLoaded', function () { + let button = document.getElementById('button'); + let accountIdInput = document.getElementById('account-id'); + let errorMessage = document.getElementById('error-message'); + + button.addEventListener('click', function () { + let accountId = accountIdInput.value; + errorMessage.style.display = 'none'; + + let isValidAccountId = /^0x[0-9a-fA-F]{16}$/i.test(accountId); + + if (!accountId) { + // Display the error message and prevent the fetch call + errorMessage.textContent = "Account ID is required." + errorMessage.style.display = 'block'; + } else if (!isValidAccountId) { + // Display the error message and prevent the fetch call + errorMessage.textContent = "Invalid Account ID." + errorMessage.style.display = 'block'; + } else { + let requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ account_id: accountId }), + }; + + fetch('http://127.0.0.1:8080/get_tokens', requestOptions) + .then(response => { + if (!response.ok) { + console.log(response.text) + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.blob(); // Handle the response as a blob instead of JSON + }) + .then(blob => { + // Create a URL for the blob + const url = window.URL.createObjectURL(blob); + // Create a link element + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = 'note.bin'; // Provide a filename for the download + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); // Clean up the URL object + }) + .catch(error => { + console.log(error); + console.error('Error:', error); + }); + } + }); +}); \ No newline at end of file diff --git a/node/src/faucet/utils.rs b/node/src/faucet/utils.rs new file mode 100644 index 000000000..93a2b93a2 --- /dev/null +++ b/node/src/faucet/utils.rs @@ -0,0 +1,23 @@ +use std::{env, fs, path::PathBuf, str::FromStr}; + +use anyhow::{anyhow, Result}; +use miden_objects::{accounts::AccountData, utils::serde::Deserializable}; + +pub fn import_account_from_args() -> Result { + let args: Vec = env::args().collect(); + + let path = match args.get(1) { + Some(s) => match PathBuf::from_str(s) { + Ok(path) => path, + Err(e) => return Err(anyhow!("Failed to turn string to path. {e}")), + }, + None => return Err(anyhow!("Invalid file path")), + }; + + let account_data_file_contents = + fs::read(path).map_err(|e| anyhow!("Failed to read file. {e}"))?; + let account_data = AccountData::read_from_bytes(&account_data_file_contents) + .map_err(|e| anyhow!("Failed to deserialize file. {e}"))?; + + Ok(account_data) +} diff --git a/node/src/main.rs b/node/src/main.rs index c365e49c7..362c7b5f8 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; mod commands; +mod faucet; // CONSTANTS // ================================================================================================ @@ -28,6 +29,9 @@ pub enum Command { Start { #[arg(short, long, value_name = "FILE", default_value = NODE_CONFIG_FILE_PATH)] config: PathBuf, + + #[arg(short, long)] + with_faucet: bool, }, /// Generates a genesis file and associated account files based on a specified genesis input @@ -57,7 +61,10 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match &cli.command { - Command::Start { config } => commands::start_node(config).await, + Command::Start { + config, + with_faucet, + } => commands::start_node(config, with_faucet).await, Command::MakeGenesis { output_path, force,