33//! This module contains the logic for periodically testing faucet functionality
44//! by requesting proof-of-work challenges, solving them, and submitting token requests.
55
6- use std:: time:: Duration ;
6+ use std:: time:: { Duration , Instant } ;
77
88use anyhow:: Context ;
99use hex;
10+ use miden_node_utils:: spawn:: spawn_blocking_in_current_span;
1011use reqwest:: Client ;
1112use serde:: { Deserialize , Serialize } ;
1213use sha2:: { Digest , Sha256 } ;
@@ -88,6 +89,8 @@ pub struct FaucetService {
8889 url : Url ,
8990 client : Client ,
9091 interval : Duration ,
92+ /// Wall-clock cap on solving a single `PoW` challenge.
93+ solve_timeout : Duration ,
9194 /// A valid public account ID used as the recipient for faucet token requests. Generated once at
9295 /// construction from a throwaway wallet account; the minted tokens are never spent.
9396 account_id : String ,
@@ -109,6 +112,7 @@ impl FaucetService {
109112 url,
110113 client,
111114 interval,
115+ solve_timeout : request_timeout,
112116 account_id : wallet_account. id ( ) . to_string ( ) ,
113117 success_count : 0 ,
114118 failure_count : 0 ,
@@ -145,7 +149,9 @@ impl Service for FaucetService {
145149 let start_time = std:: time:: Instant :: now ( ) ;
146150 let mut last_error: Option < String > = None ;
147151
148- match perform_faucet_test ( & self . client , & self . url , & self . account_id ) . await {
152+ match perform_faucet_test ( & self . client , & self . url , & self . account_id , self . solve_timeout )
153+ . await
154+ {
149155 Ok ( ( minted_tokens, metadata) ) => {
150156 self . success_count += 1 ;
151157 self . last_tx_id = Some ( minted_tokens. tx_id . clone ( ) ) ;
@@ -198,6 +204,7 @@ async fn perform_faucet_test(
198204 client : & Client ,
199205 faucet_url : & Url ,
200206 account_id : & str ,
207+ solve_timeout : Duration ,
201208) -> anyhow:: Result < ( GetTokensResponse , GetMetadataResponse ) > {
202209 debug ! ( "Using recipient account ID: {} (length: {})" , account_id, account_id. len( ) ) ;
203210
@@ -210,7 +217,7 @@ async fn perform_faucet_test(
210217
211218 let response = client. get ( pow_url) . send ( ) . await ?;
212219
213- let response_text: String = response . text ( ) . await ?;
220+ let response_text = read_success_body ( response ) . await . context ( "/pow request failed" ) ?;
214221 debug ! ( "Faucet PoW response: {}" , response_text) ;
215222
216223 let challenge_response: PowChallengeResponse =
@@ -222,9 +229,16 @@ async fn perform_faucet_test(
222229 & challenge_response. challenge[ ..16 . min( challenge_response. challenge. len( ) ) ]
223230 ) ;
224231
225- // Step 2: Solve the PoW challenge
226- let nonce = solve_pow_challenge ( & challenge_response. challenge , challenge_response. target )
227- . context ( "Failed to solve PoW challenge" ) ?;
232+ // Step 2: Solve the PoW challenge off the async runtime; hashing is CPU-bound and would
233+ // otherwise stall every other checker task scheduled on this worker thread.
234+ let challenge = challenge_response. challenge . clone ( ) ;
235+ let target = challenge_response. target ;
236+ let nonce = spawn_blocking_in_current_span ( move || {
237+ solve_pow_challenge ( & challenge, target, solve_timeout)
238+ } )
239+ . await
240+ . context ( "PoW solver task panicked" ) ?
241+ . context ( "Failed to solve PoW challenge" ) ?;
228242
229243 debug ! ( "Solved PoW challenge with nonce: {}" , nonce) ;
230244
@@ -240,7 +254,7 @@ async fn perform_faucet_test(
240254
241255 let response = client. get ( tokens_url) . send ( ) . await ?;
242256
243- let response_text: String = response . text ( ) . await ?;
257+ let response_text = read_success_body ( response ) . await . context ( "/get_tokens request failed" ) ?;
244258 debug ! ( "Faucet /get_tokens response: {}" , response_text) ;
245259
246260 let tokens_response: GetTokensResponse =
@@ -251,7 +265,8 @@ async fn perform_faucet_test(
251265
252266 let response = client. get ( metadata_url) . send ( ) . await ?;
253267
254- let response_text = response. text ( ) . await ?;
268+ let response_text =
269+ read_success_body ( response) . await . context ( "/get_metadata request failed" ) ?;
255270 debug ! ( "Faucet /get_metadata response: {}" , response_text) ;
256271
257272 let metadata: GetMetadataResponse =
@@ -260,6 +275,16 @@ async fn perform_faucet_test(
260275 Ok ( ( tokens_response, metadata) )
261276}
262277
278+ /// Reads the response body, failing with the HTTP status code and body when the request was not
279+ /// successful, so server-side errors (e.g. 429 or 500) surface directly on the card instead of as a
280+ /// deserialization failure.
281+ async fn read_success_body ( response : reqwest:: Response ) -> anyhow:: Result < String > {
282+ let status = response. status ( ) ;
283+ let body = response. text ( ) . await ?;
284+ anyhow:: ensure!( status. is_success( ) , "HTTP {status}: {body}" ) ;
285+ Ok ( body)
286+ }
287+
263288/// Deserialize a faucet response using [`serde_path_to_error`] so that the failing JSON path (e.g.
264289/// `max_supply`, `explorer_url`) is included in the error message. Combined with
265290/// `#[serde(deny_unknown_fields)]` on each response type, this means renamed, removed, or newly
@@ -274,15 +299,19 @@ where
274299
275300/// Solves a proof-of-work challenge using SHA-256 hashing.
276301///
302+ /// This is CPU-bound and must run on a blocking thread (see the `spawn_blocking` call site).
303+ ///
277304/// # Arguments
278305///
279306/// * `challenge` - The challenge string in hexadecimal format.
280307/// * `target` - The target value. A solution is valid if H(challenge, nonce) < target.
308+ /// * `timeout` - Wall-clock cap; checked every 100k attempts so a pathological difficulty cannot
309+ /// pin the blocking thread indefinitely.
281310///
282311/// # Returns
283312///
284- /// The nonce that solves the challenge, or an error if no solution is found within reasonable
285- /// bounds.
313+ /// The nonce that solves the challenge, or an error if no solution is found within the attempt
314+ /// and time bounds.
286315#[ instrument(
287316 parent = None ,
288317 target = COMPONENT ,
@@ -292,8 +321,9 @@ where
292321 ret( level = "debug" ) ,
293322 err
294323) ]
295- fn solve_pow_challenge ( challenge : & str , target : u64 ) -> anyhow:: Result < u64 > {
324+ fn solve_pow_challenge ( challenge : & str , target : u64 , timeout : Duration ) -> anyhow:: Result < u64 > {
296325 let challenge_bytes = hex:: decode ( challenge) . context ( "Failed to decode challenge from hex" ) ?;
326+ let started = Instant :: now ( ) ;
297327
298328 // Try up to 100 million nonces.
299329 for nonce in 0 ..MAX_CHALLENGE_ATTEMPTS {
@@ -316,8 +346,15 @@ fn solve_pow_challenge(challenge: &str, target: u64) -> anyhow::Result<u64> {
316346 return Ok ( nonce) ;
317347 }
318348
319- // Log progress every 100k attempts
349+ // Check the deadline and log progress every 100k attempts
320350 if nonce % 100_000 == 0 && nonce > 0 {
351+ let elapsed = started. elapsed ( ) ;
352+ if elapsed >= timeout {
353+ anyhow:: bail!(
354+ "Failed to solve PoW challenge within {timeout:?} ({nonce} attempts, target \
355+ {target})"
356+ ) ;
357+ }
321358 debug ! (
322359 "PoW attempt {}: current_hash={}, target={} (~{} bits)" ,
323360 nonce,
0 commit comments