diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3a789a3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "singleQuote": false, + "printWidth": 80, + "overrides": [ + { + "files": ["**/*.html"], + "options": { + "printWidth": 120 + } + } + ] +} diff --git a/Cargo.lock b/Cargo.lock index 72b0988..4d8ea5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,15 +1494,19 @@ dependencies = [ "fuels-core", "futures", "handlebars", + "hex", "insta", "lazy_static", "memoize", "minify-html", + "num-bigint", "rand", "reqwest", "secrecy", "serde", "serde_json", + "sha2", + "sha256", "tokio", "tower", "tower-http 0.2.5", @@ -2320,6 +2324,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minify-html" version = "0.8.1" @@ -2379,6 +2393,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -3253,6 +3288,19 @@ dependencies = [ "digest", ] +[[package]] +name = "sha256" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7895c8ae88588ccead14ff438b939b0c569cd619116f14b4d13fdff7b8333386" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + [[package]] name = "sha3" version = "0.10.8" @@ -3710,7 +3758,13 @@ dependencies = [ "http", "http-body", "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3866,6 +3920,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" diff --git a/Cargo.toml b/Cargo.toml index 6d90457..0c5d129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,15 +18,20 @@ fuel-types = "0.43.0" fuels-accounts = "0.54.0" fuels-core = "0.54.0" handlebars = "4.2" +hex = "0.4.3" lazy_static = "1.4" memoize = "0.3.1" +num-bigint = "0.4" +rand = "0.8.5" reqwest = { version = "0.11", features = ["json", "rustls-tls-webpki-roots"], default-features = false } secrecy = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } +sha2 = "0.10" +sha256 = "1.1.4" tokio = { version = "1.0", features = ["full"] } tower = { version = "0.4", features = ["buffer", "limit", "load-shed", "util", "timeout"] } -tower-http = { version = "0.2.5", features = ["cors", "trace", "set-header"] } +tower-http = { version = "0.2.5", features = ["cors", "trace", "set-header", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/README.md b/README.md index 734503e..c8359d9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -Faucet App -=== +# Faucet App + [![build](https://github.com/FuelLabs/faucet/actions/workflows/ci.yml/badge.svg)](https://github.com/FuelLabs/faucet/actions/workflows/ci.yml) [![discord](https://img.shields.io/badge/chat%20on-discord-orange?&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/xfpK4Pe) @@ -7,20 +7,22 @@ A simple faucet app for dispensing tokens on a fuel network. It uses Google capt without requiring any social media based identification. ## Configuration + The faucet makes use of environment variables for configuration. -| Environment Variable | Description | -|----------------------|-------------------------------------------------------------------------| -| RUST_LOG | EnvFilter configuration for adjusting logging granularity. | -| HUMAN_LOGGING | If false, logs will be output as machine readable JSON. | -| CAPTCHA_SECRET | The secret key used for enabling Google captcha authentication. | -| CAPTCHA_KEY | The website key used for enabling Google captcha authentication. | -| WALLET_SECRET_KEY | A hex formatted string of the wallet private key that owns some tokens. | -| FUEL_NODE_URL | The GraphQL endpoint for connecting to fuel-core. | +| Environment Variable | Description | +| -------------------- | ----------------------------------------------------------------------------------------------- | +| RUST_LOG | EnvFilter configuration for adjusting logging granularity. | +| HUMAN_LOGGING | If false, logs will be output as machine readable JSON. | +| CAPTCHA_SECRET | The secret key used for enabling Google captcha authentication. | +| CAPTCHA_KEY | The website key used for enabling Google captcha authentication. | +| WALLET_SECRET_KEY | A hex formatted string of the wallet private key that owns some tokens. | +| FUEL_NODE_URL | The GraphQL endpoint for connecting to fuel-core. | | PUBLIC_FUEL_NODE_URL | The public GraphQL endpoint for connecting to fuel-core. Ex.: https://node.fuel.network/graphql | -| SERVICE_PORT | The port the service will listen for http connections on. | -| DISPENSE_AMOUNT | Dispense amount on each faucet | -| MIN_GAS_PRICE | The minimum gas price to use in each transfer | +| SERVICE_PORT | The port the service will listen for http connections on. | +| DISPENSE_AMOUNT | Dispense amount on each faucet | +| MIN_GAS_PRICE | The minimum gas price to use in each transfer | +| POW_DIFFICULTY | Number of leading zeroes that a valid proof of work hash must have | ## Build and Run @@ -29,3 +31,9 @@ To run locally, assuming environment variables have already been set: ```sh cargo run ``` + +You will need a fuel node running. You can run one with the default configuration to make the faucet work: + +```sh +fuel-core run --chain ./chain_config.json --db-type in-memory +``` diff --git a/build.rs b/build.rs index 9a6568f..2a3bc92 100644 --- a/build.rs +++ b/build.rs @@ -3,10 +3,14 @@ use std::{env, fs, path::Path}; fn main() { let out_dir = env::var("OUT_DIR").unwrap(); - let dest_path = Path::new(&out_dir).join("index.html"); + let html_dest_path = Path::new(&out_dir).join("index.html"); let page = include_bytes!("./static/index.html"); let minified = minify_html::minify(page, &Cfg::spec_compliant()); - fs::write(dest_path, minified).expect("failed to save minified index page"); + fs::write(html_dest_path, minified).expect("failed to save minified index page"); + + let worker_dest_path = Path::new(&out_dir).join("worker.js"); + let worker_script = include_bytes!("./static/worker.js"); + fs::write(worker_dest_path, worker_script).expect("failed to save worker script"); println!("cargo:rerun-if-changed=static"); } diff --git a/chain_config.json b/chain_config.json new file mode 100644 index 0000000..941055d --- /dev/null +++ b/chain_config.json @@ -0,0 +1,160 @@ +{ + "chain_name": "local", + "block_gas_limit": 1000000000, + "initial_state": { + "coins": [ + { + "owner": "0x5b96f1d44868f0fec7416a020fa4bbcdb511462e474b8d988f66210fd892468d", + "amount": "0xff00000000000000", + "asset_id": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + ] + }, + "consensus_parameters": { + "tx_params": { + "max_inputs": 255, + "max_outputs": 255, + "max_witnesses": 255, + "max_gas_per_tx": 100000000, + "max_size": 17825792 + }, + "predicate_params": { + "max_predicate_length": 1048576, + "max_predicate_data_length": 1048576, + "max_message_data_length": 1048576, + "max_gas_per_predicate": 100000000 + }, + "script_params": { + "max_script_length": 1048576, + "max_script_data_length": 1048576 + }, + "contract_params": { + "contract_max_size": 16777216, + "max_storage_slots": 255 + }, + "fee_params": { "gas_price_factor": 1000000000, "gas_per_byte": 4 }, + "chain_id": 0, + "gas_costs": { + "add": 1, + "addi": 1, + "aloc": 1, + "and": 1, + "andi": 1, + "bal": 13, + "bhei": 1, + "bhsh": 1, + "burn": 132, + "cb": 1, + "cfei": 1, + "cfsi": 1, + "croo": 16, + "div": 1, + "divi": 1, + "eck1": 951, + "ecr1": 3000, + "ed19": 3000, + "eq": 1, + "exp": 1, + "expi": 1, + "flag": 1, + "gm": 1, + "gt": 1, + "gtf": 1, + "ji": 1, + "jmp": 1, + "jne": 1, + "jnei": 1, + "jnzi": 1, + "jmpf": 1, + "jmpb": 1, + "jnzf": 1, + "jnzb": 1, + "jnef": 1, + "jneb": 1, + "lb": 1, + "log": 9, + "lt": 1, + "lw": 1, + "mint": 135, + "mlog": 1, + "mod": 1, + "modi": 1, + "move": 1, + "movi": 1, + "mroo": 2, + "mul": 1, + "muli": 1, + "mldv": 1, + "noop": 1, + "not": 1, + "or": 1, + "ori": 1, + "poph": 2, + "popl": 2, + "pshh": 2, + "pshl": 2, + "ret_contract": 13, + "rvrt_contract": 13, + "sb": 1, + "sll": 1, + "slli": 1, + "srl": 1, + "srli": 1, + "srw": 12, + "sub": 1, + "subi": 1, + "sw": 1, + "sww": 67, + "time": 1, + "tr": 105, + "tro": 60, + "wdcm": 1, + "wqcm": 1, + "wdop": 1, + "wqop": 1, + "wdml": 1, + "wqml": 1, + "wddv": 1, + "wqdv": 2, + "wdmd": 3, + "wqmd": 4, + "wdam": 2, + "wqam": 3, + "wdmm": 3, + "wqmm": 3, + "xor": 1, + "xori": 1, + "call": { "LightOperation": { "base": 144, "units_per_gas": 214 } }, + "ccp": { "LightOperation": { "base": 15, "units_per_gas": 103 } }, + "csiz": { "LightOperation": { "base": 17, "units_per_gas": 790 } }, + "k256": { "LightOperation": { "base": 11, "units_per_gas": 214 } }, + "ldc": { "LightOperation": { "base": 15, "units_per_gas": 272 } }, + "logd": { "LightOperation": { "base": 26, "units_per_gas": 64 } }, + "mcl": { "LightOperation": { "base": 1, "units_per_gas": 3333 } }, + "mcli": { "LightOperation": { "base": 1, "units_per_gas": 3333 } }, + "mcp": { "LightOperation": { "base": 1, "units_per_gas": 2000 } }, + "mcpi": { "LightOperation": { "base": 3, "units_per_gas": 2000 } }, + "meq": { "LightOperation": { "base": 1, "units_per_gas": 2500 } }, + "retd_contract": { + "LightOperation": { "base": 29, "units_per_gas": 62 } + }, + "s256": { "LightOperation": { "base": 2, "units_per_gas": 214 } }, + "scwq": { "LightOperation": { "base": 13, "units_per_gas": 5 } }, + "smo": { "LightOperation": { "base": 209, "units_per_gas": 55 } }, + "srwq": { "LightOperation": { "base": 47, "units_per_gas": 5 } }, + "swwq": { "LightOperation": { "base": 44, "units_per_gas": 5 } }, + "contract_root": { "LightOperation": { "base": 75, "units_per_gas": 1 } }, + "state_root": { "LightOperation": { "base": 412, "units_per_gas": 1 } }, + "new_storage_per_byte": 1, + "vm_initialization": { + "HeavyOperation": { "base": 2000, "gas_per_unit": 0 } + } + }, + "base_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "consensus": { + "PoA": { + "signing_key": "22ec92c3105c942a6640bdc4e4907286ec4728e8cfc0d8ac59aad4d8e1ccaefb" + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..59c06f1 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,20 @@ +# Proof of work + +The faucet dispenses funds with a CAPTCHA and SHA256 PoW (Proof of work) mechanism. The following diagram demonstrates the flow of messages exchanged between the served webpage and the server backend. + +![image](./POW.png) + +A high level explanation of the inner workings is offered now: + +- The backend offers two static files, one being an HTML document at the root directory, and a javascript script at `/worker`. Both can be found at [the static directory of the repository](/static/). + - The HTML document a single page application with a form that executes with the communication flow at the diagram. + - The javascript script contains a webworker that will be imported in the HTML document to execute the long running PoW task. This is necessary to avoid UI blocking. This worker will communicate with the main HTML document to report back valid nonces that can be used to obtain funds from the faucet +- The system works via salt based sessions. The client will request the server to generate a valid salt for the wallet address specified at the form through the `POST /session` endpoint. The request is accompanied by a captcha validation. The salt will be associated with the wallet address in an in-memory database (a simple hashmap). Then, the server will send a response to the client indicating the difficulty level that must be satisfied by the proofs, along with the salt. The salt is generated and kept by the server with the sole objective of avoiding replay attacks, and all salts are wiped out upon reset. +- The difficulty level is an u8 integer that signals how many leading zeroes bits a valid SHA256 hash must contain. The hash must be obtained by the concatenation of the `salt` and a `nonce` in a string. E.g. if the difficulty level is 6, it means that the hash must be of the form `000000[101010]..` (note this is binary, not hex). Difficulty is thus doubled with each level. +- Once the client has obtained its salt, it can begin iterating with a nonce. Everytime a valid nonce is found, the client sends the salt to the `POST /dispense` endpoint, which will retrieve the address associated with the salt, and check the SHA256 hash of the `salt` and `nonce` concatenation. If the proof of work is correct, it will craft a transaction to send the funds, and return an OK response containing the `txId` of the forwarded funds. + +# Improvement proposals + +- Change the SHA256 PoW for a CPU focused PoW algo (e.g. scrypt) +- Daily cap +- Group together valid nonces under a single transaction diff --git a/docs/POW.png b/docs/POW.png new file mode 100644 index 0000000..00393a8 Binary files /dev/null and b/docs/POW.png differ diff --git a/src/config.rs b/src/config.rs index 3051515..d84d2ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,8 @@ use crate::constants::{ CAPTCHA_KEY, CAPTCHA_SECRET, DEFAULT_DISPENSE_INTERVAL, DEFAULT_FAUCET_DISPENSE_AMOUNT, DEFAULT_NODE_URL, DEFAULT_PORT, DISPENSE_AMOUNT, DISPENSE_INTERVAL, FAUCET_ASSET_ID, - FUEL_NODE_URL, HUMAN_LOGGING, LOG_FILTER, MIN_GAS_PRICE, PUBLIC_FUEL_NODE_URL, SERVICE_PORT, - TIMEOUT_SECONDS, WALLET_SECRET_KEY, + FUEL_NODE_URL, HUMAN_LOGGING, LOG_FILTER, MIN_GAS_PRICE, POW_DIFFICULTY, PUBLIC_FUEL_NODE_URL, + SERVICE_PORT, TIMEOUT_SECONDS, WALLET_SECRET_KEY, }; use fuels_core::types::AssetId; use secrecy::Secret; @@ -23,6 +23,7 @@ pub struct Config { pub dispense_limit_interval: u64, pub min_gas_price: u64, pub timeout: u64, + pub pow_difficulty: u8, } impl Default for Config { @@ -58,6 +59,10 @@ impl Default for Config { .unwrap_or_else(|_| "30".to_string()) .parse::() .expect("expected a valid integer for TIMEOUT_SECONDS"), + pow_difficulty: env::var(POW_DIFFICULTY) + .unwrap_or_else(|_| "20".to_string()) + .parse::() + .expect("expected a valid integer [0, 255] for POW_DIFFICULTY"), } } } diff --git a/src/constants.rs b/src/constants.rs index 2228508..d1a0f87 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -20,6 +20,7 @@ pub const DEFAULT_PORT: u16 = 3000; pub const MIN_GAS_PRICE: &str = "MIN_GAS_PRICE"; pub const TIMEOUT_SECONDS: &str = "TIMEOUT_SECONDS"; +pub const POW_DIFFICULTY: &str = "POW_DIFFICULTY"; // HTTP config diff --git a/src/lib.rs b/src/lib.rs index 019b8bc..c162cfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use crate::{ constants::{MAX_CONCURRENT_REQUESTS, WALLET_SECRET_DEV_KEY}, dispense_tracker::DispenseTracker, routes::health, + session::SessionMap, }; use anyhow::anyhow; use axum::{ @@ -20,7 +21,9 @@ use fuels_core::types::node_info::NodeInfo; use fuels_core::types::transaction_builders::NetworkInfo; use secrecy::{ExposeSecret, Secret}; use serde_json::json; +use session::Salt; use std::{ + collections::HashMap, net::{SocketAddr, TcpListener}, sync::{Arc, Mutex}, time::Duration, @@ -36,6 +39,7 @@ use tracing::info; pub mod config; pub mod models; +pub mod session; mod constants; mod dispense_tracker; @@ -92,6 +96,7 @@ pub type SharedWallet = Arc; pub type SharedConfig = Arc; pub type SharedNetworkConfig = Arc; pub type SharedDispenseTracker = Arc>; +pub type SharedSessions = Arc>>; pub async fn start_server( service_config: Config, @@ -146,6 +151,9 @@ pub async fn start_server( info!("Faucet Account: {:#x}", Address::from(wallet.address())); info!("Faucet Balance: {}", balance); + let pow_difficulty = service_config.pow_difficulty; + let sessions: SharedSessions = Arc::new(tokio::sync::Mutex::new(SessionMap::new())); + // setup routes let app = Router::new() .route( @@ -155,6 +163,7 @@ pub async fn start_server( HeaderValue::from_static("public, max-age=3600, immutable"), )), ) + .nest("/worker.js", routes::serve_worker()) .route("/health", get(health)) .route("/dispense", get(routes::dispense_info)) .route( @@ -192,6 +201,22 @@ pub async fn start_server( .allow_headers(Any), ) .into_inner(), + ) + .route("/session", get(routes::get_session)) + .layer(Extension(sessions.clone())) + .route("/session", post(routes::create_session)) + .layer( + ServiceBuilder::new() + // Handle errors from middleware + .layer(HandleErrorLayer::new(handle_error)) + .load_shed() + .concurrency_limit(MAX_CONCURRENT_REQUESTS) + .timeout(Duration::from_secs(60)) + .layer(TraceLayer::new_for_http()) + .layer(Extension(sessions.clone())) + .layer(Extension(Arc::new(pow_difficulty))) + .layer(Extension(Arc::new(service_config.clone()))) + .into_inner(), ); // run the server diff --git a/src/models.rs b/src/models.rs index 42b4b33..9c2ea7f 100644 --- a/src/models.rs +++ b/src/models.rs @@ -9,8 +9,8 @@ pub struct DispenseInfoResponse { #[derive(Deserialize, Debug)] pub struct DispenseInput { - pub address: String, - pub captcha: String, + pub salt: String, + pub nonce: String, } #[derive(Serialize, Debug)] @@ -24,3 +24,22 @@ pub struct DispenseError { pub status: StatusCode, pub error: String, } + +#[derive(Deserialize, Debug)] +pub struct CreateSessionInput { + pub address: String, + pub captcha: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateSessionResponse { + pub status: String, + pub salt: String, + pub difficulty: u8, +} + +#[derive(Debug)] +pub struct CreateSessionError { + pub status: StatusCode, + pub error: String, +} diff --git a/src/routes.rs b/src/routes.rs index 5bd91ce..3d04eee 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,9 +1,11 @@ use crate::{ - models::*, recaptcha, CoinOutput, SharedConfig, SharedDispenseTracker, SharedFaucetState, - SharedNetworkConfig, SharedWallet, + models::*, recaptcha, session::Salt, CoinOutput, SharedConfig, SharedDispenseTracker, + SharedFaucetState, SharedNetworkConfig, SharedSessions, SharedWallet, }; use axum::{ + extract::Query, response::{Html, IntoResponse, Response}, + routing::{get_service, MethodRouter}, Extension, Json, }; @@ -20,16 +22,21 @@ use fuels_core::types::{ }; use fuels_core::types::{input::Input, transaction_builders::ScriptTransactionBuilder}; use handlebars::Handlebars; +use hex::FromHexError; +use num_bigint::BigUint; use reqwest::StatusCode; use secrecy::ExposeSecret; +use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; use std::sync::Arc; -use std::time::Duration; use std::{ collections::BTreeMap, + io, str::FromStr, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; +use tower_http::services::ServeFile; use tracing::{error, info}; // The amount to fetch the biggest input of the faucet. @@ -58,6 +65,19 @@ pub fn render_page(public_node_url: String, captcha_key: Option) -> Stri handlebars.render("index", &data).unwrap() } +#[memoize::memoize] +pub fn serve_worker() -> MethodRouter { + let template = concat!(env!("OUT_DIR"), "/worker.js"); + + async fn handle_error(_err: io::Error) -> impl IntoResponse { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Could not serve worker.js", + ) + } + get_service(ServeFile::new(template)).handle_error(handle_error) +} + pub async fn main(Extension(config): Extension) -> Html { let public_node_url = config.public_node_url.clone(); let captcha_key = config.captcha_key.clone(); @@ -210,31 +230,44 @@ pub async fn dispense_tokens( Extension(config): Extension, Extension(client): Extension>, Extension(network_config): Extension, + Extension(sessions): Extension, Extension(dispense_tracker): Extension, ) -> Result { - // parse deposit address - let address = if let Ok(address) = Address::from_str(input.address.as_str()) { - Ok(address) - } else if let Ok(address) = Bech32Address::from_str(input.address.as_str()) { - Ok(address.into()) - } else { - return Err(error( - "invalid address".to_string(), - StatusCode::BAD_REQUEST, - )); - }?; + let salt: [u8; 32] = hex::decode(&input.salt) + .and_then(|value| { + value + .try_into() + .map_err(|_| FromHexError::InvalidStringLength) + }) + .map_err(|_| DispenseError { + status: StatusCode::BAD_REQUEST, + error: "Invalid salt".to_string(), + })?; - // verify captcha - if let Some(s) = config.captcha_secret.clone() { - recaptcha::verify(s.expose_secret(), input.captcha.as_str(), None) - .await - .map_err(|e| { - tracing::error!("{}", e); - DispenseError { - error: "captcha failed".to_string(), - status: StatusCode::UNAUTHORIZED, - } - })?; + let address = match sessions.lock().await.get(&Salt::new(salt)) { + Some(value) => *value, + None => { + return Err(DispenseError { + status: StatusCode::NOT_FOUND, + error: "Salt does not exist".to_string(), + }) + } + }; + + let mut hasher = Sha256::new(); + hasher.update(input.salt.as_bytes()); + hasher.update(input.nonce.as_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let hash_uint = BigUint::from_bytes_be(&hash); + + let u256_max = BigUint::from(2u8).pow(256u32) - BigUint::from(1u8); + let target_difficulty = u256_max >> config.pow_difficulty; + + if hash_uint > target_difficulty { + return Err(DispenseError { + status: StatusCode::NOT_FOUND, + error: "Invalid proof of work".to_string(), + }); } check_and_mark_dispense_limit(&dispense_tracker, address, config.dispense_limit_interval)?; @@ -366,3 +399,102 @@ fn error(error: String, status: StatusCode) -> DispenseError { error!("{}", error); DispenseError { error, status } } + +impl IntoResponse for CreateSessionResponse { + fn into_response(self) -> Response { + (StatusCode::CREATED, Json(self)).into_response() + } +} + +impl IntoResponse for CreateSessionError { + fn into_response(self) -> Response { + ( + self.status, + Json(json!({ + "error": self.error + })), + ) + .into_response() + } +} + +pub async fn create_session( + Json(input): Json, + Extension(sessions): Extension, + Extension(pow_difficulty): Extension>, + Extension(config): Extension, +) -> Result { + // parse deposit address + let address = if let Ok(address) = Address::from_str(input.address.as_str()) { + Ok(address) + } else if let Ok(address) = Bech32Address::from_str(input.address.as_str()) { + Ok(address.into()) + } else { + return Err(CreateSessionError { + status: StatusCode::BAD_REQUEST, + error: "invalid address".to_string(), + }); + }?; + + // verify captcha + if let Some(s) = config.captcha_secret.clone() { + recaptcha::verify(s.expose_secret(), input.captcha.as_str(), None) + .await + .map_err(|e| { + tracing::error!("{}", e); + CreateSessionError { + error: "captcha failed".to_string(), + status: StatusCode::UNAUTHORIZED, + } + })?; + } + + let mut map = sessions.lock().await; + + let salt = Salt::random(); + + map.insert(salt.clone(), address); + + Ok(CreateSessionResponse { + status: "Success".to_string(), + salt: hex::encode(salt.as_bytes()), + difficulty: *pow_difficulty, + }) +} + +#[derive(Deserialize)] +pub struct SessionQuery { + salt: String, +} + +pub async fn get_session( + query: Query, + Extension(sessions): Extension, +) -> Response { + let salt: Result<[u8; 32], _> = hex::decode(&query.salt).and_then(|value| { + value + .try_into() + .map_err(|_| FromHexError::InvalidStringLength) + }); + + match salt { + Ok(value) => value, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "Invalid salt"})), + ) + .into_response() + } + }; + + let map = sessions.lock().await; + + let result = map.get(&Salt::new(salt.unwrap())); + + match result { + Some(address) => (StatusCode::OK, Json(json!({"address": address}))), + None => (StatusCode::NOT_FOUND, Json(json!({}))), + } + .into_response() +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..f8a87db --- /dev/null +++ b/src/session.rs @@ -0,0 +1,28 @@ +use fuel_types::Address; +use rand::Rng; +use std::collections::HashMap; + +#[derive(Eq, Hash, PartialEq, Clone, Debug)] +pub struct Salt([u8; 32]); + +impl Salt { + pub fn new(bytes: [u8; 32]) -> Self { + Salt(bytes) + } + + pub fn random() -> Self { + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes[..]); + Salt(bytes) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +pub type Pow = (Address, Salt, u64); + +pub type SessionMap = HashMap; +pub type ProofMap = HashMap; diff --git a/static/index.html b/static/index.html index f76ec24..f50b518 100644 --- a/static/index.html +++ b/static/index.html @@ -1,317 +1,374 @@ + + + {{ page_title }} + + + + + - - -
-
- -
-
- - + .button:disabled { + background-color: gray; + } + + .card { + margin-top: 90px; + z-index: 1; + width: 480px; + font-size: 14px; + padding: 40px 20px 20px 20px; + border-radius: 10px; + background-color: white; + box-shadow: 0 0 16px 2px rgba(248, 248, 248, 0.25); + border: 1px solid rgb(229, 231, 235); + max-width: 95%; + } + + .from-control { + display: flex; + flex-direction: column; + } + + .from-control label { + margin-bottom: 0.3em; + color: #333; + } + + .from-control input { + border: 1px solid #888; + border-radius: 4px; + padding: 10px; + } + + .from-control input:invalid { + border: 1px solid red; + } + + .captcha-container { + display: flex; + margin-top: 20px; + justify-content: center; + } + + .bold { + color: #000; + font-weight: bold; + } + + .response-title, + .provider-url, + #response-failure { + text-align: center; + } + + #response-failure { + color: red; + padding: 10px; + } + + #response { + display: none; + } + + .provider-url { + font-size: 12px; + text-align: center; + margin-top: 10px; + color: #949494; + } + + .queued { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 1rem; + } + + .captcha-area { + height: 100px; + display: flex; + justify-content: center; + align-items: center; + } + + .loader { + border: 0.4rem solid #f3f3f3; + border-top: 0.4rem solid #00f58c; + border-radius: 50%; + width: 2rem; + height: 2rem; + animation: spin 2s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } + + .hidden { + display: none; + } + + + +
+
+ -

- This is a Test Ether faucet running on the - Test Fuel network. This faucet sends fake Ether - assets to the provided wallet address. -

-
- {{#if captcha_key}} + +
+ + +
+

+ This is a Test Ether faucet running on the Test Fuel network. This + faucet sends fake Ether assets to the provided wallet address after, and is limited by a proof of work + mechanism +

+
+ {{#if captcha_key}}
- {{/if}} - -
-
- - -
-

Test Ether sent to the wallet

- See on Fuel Explorer +

+
+ +
-
-
Node url: {{ public_node_url }}
- - + function handle_dispense_response() { + return function (data) { + if (data.error) { + document.getElementById("response-failure").innerText = data.error; + stop_pow(); + return; + } + + document.getElementById("dispensed").textContent = `Funds sent!`; + }; + } + + function handle_error(message) { + document.getElementById("response-failure").innerText = message; + hideWaiting(); + } - \ No newline at end of file + return { start_or_stop_pow: start_or_stop_pow }; + })(); + + + diff --git a/static/worker.js b/static/worker.js new file mode 100644 index 0000000..c3c554d --- /dev/null +++ b/static/worker.js @@ -0,0 +1,58 @@ +let working = false; +const u256_max = BigInt( + "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +); + +onmessage = async function (ev) { + // If already working, stop + if (working) { + console.log("worker: stopping"); + working = false; + return; + } + + // Sanitize input + if (!ev || !ev.data) return; + + const difficultyLevel = BigInt(ev.data.difficultyLevel); + const target = u256_max >> difficultyLevel; + const { salt } = ev.data; + + working = true; + + let i = 0; + + console.log("Working", difficultyLevel, salt); + + while (working) { + let buffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${salt}${i}`) + ); + let hash = Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + var bn = BigInt("0x" + hash); + + if (bn <= target) { + this.postMessage({ + type: "hash", + value: { salt, nonce: `${i}`, hash }, + }); + } + + i++; + } + + this.postMessage({ type: "finish" }); +}; + +function getRandomSalt() { + // Generate a random salt + let saltArray = new Uint8Array(32); + crypto.getRandomValues(saltArray); + return Array.from(saltArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/tests/dispense.rs b/tests/dispense.rs index ba1809d..bd66a53 100644 --- a/tests/dispense.rs +++ b/tests/dispense.rs @@ -4,8 +4,11 @@ use fuel_core::service::{Config as NodeConfig, FuelService}; use fuel_core_client::client::pagination::{PageDirection, PaginationRequest}; use fuel_faucet::config::Config; -use fuel_faucet::models::DispenseInfoResponse; + +use fuel_faucet::models::{CreateSessionResponse, DispenseInfoResponse}; +use fuel_faucet::session::Salt; use fuel_faucet::{start_server, Clock, THE_BIGGEST_AMOUNT}; + use fuel_tx::{ConsensusParameters, FeeParameters}; use fuel_types::{Address, AssetId}; use fuels_accounts::fuel_crypto::SecretKey; @@ -130,6 +133,7 @@ impl TestContext { dispense_amount, dispense_asset_id: AssetId::default(), min_gas_price: 1, + pow_difficulty: 0, ..Default::default() }; @@ -204,11 +208,24 @@ async fn _dispense_sends_coins_to_valid_address( let addr = context.addr; let client = reqwest::Client::new(); + let create_session_response: CreateSessionResponse = client + .post(format!("http://{addr}/session")) + .json(&json!({ + "address": recipient_address_str, + "captcha": "" + })) + .send() + .await + .expect("Failed to send create_session request") + .json() + .await + .expect("Failed to deserialize create_session response"); + client .post(format!("http://{addr}/dispense")) .json(&json!({ - "captcha": "", - "address": recipient_address_str, + "salt": create_session_response.salt, + "nonce": "0", })) .send() .await @@ -248,11 +265,25 @@ async fn many_concurrent_requests() { let recipient = recipient.clone(); queries.push(async move { let client = reqwest::Client::new(); + + let create_session_response: CreateSessionResponse = client + .post(format!("http://{addr}/session")) + .json(&json!({ + "address": recipient, + "captcha": "" + })) + .send() + .await + .expect("Failed to send create_session request") + .json() + .await + .expect("Failed to deserialize create_session response"); + client .post(format!("http://{addr}/dispense")) .json(&json!({ - "captcha": "", - "address": recipient, + "salt": create_session_response.salt, + "nonce": hex::encode(Salt::random().as_bytes()), })) .send() .await @@ -291,11 +322,26 @@ async fn dispense_once_per_day() { let dispense_interval = 24 * 60 * 60; let time_increment = dispense_interval / 6; - let response = reqwest::Client::new() + let client = reqwest::Client::new(); + + let create_session_response: CreateSessionResponse = client + .post(format!("http://{addr}/session")) + .json(&json!({ + "address": recipient_address_str, + "captcha": "" + })) + .send() + .await + .expect("Failed to send create_session request") + .json() + .await + .expect("Failed to deserialize create_session response"); + + let response = client .post(format!("http://{addr}/dispense")) .json(&json!({ - "captcha": "", - "address": recipient_address_str.clone(), + "salt": create_session_response.salt, + "nonce": hex::encode(Salt::random().as_bytes()), })) .send() .await @@ -309,8 +355,8 @@ async fn dispense_once_per_day() { let response = reqwest::Client::new() .post(format!("http://{addr}/dispense")) .json(&json!({ - "captcha": "", - "address": recipient_address_str.clone(), + "salt": create_session_response.salt, + "nonce": hex::encode(Salt::random().as_bytes()), })) .send() .await @@ -323,8 +369,8 @@ async fn dispense_once_per_day() { let response = reqwest::Client::new() .post(format!("http://{addr}/dispense")) .json(&json!({ - "captcha": "", - "address": recipient_address_str.clone(), + "salt": create_session_response.salt, + "nonce": hex::encode(Salt::random().as_bytes()), })) .send() .await