1use std::collections::hash_map::RandomState;
2use std::hash::BuildHasher;
3
4use argon2::Algorithm;
5use argon2::Argon2;
6use argon2::Params;
7use argon2::Version;
8use reqwest::StatusCode;
9use serde::Deserialize;
10use serde::Serialize;
11use serde::de;
12use sui_sdk_types::Address;
13use sui_sdk_types::Digest;
14
15type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
16
17const POW_VERSION: u64 = 1;
18const POW_DOMAIN: &str = "sui-faucet-pow/1";
19const POW_ALGORITHM: &str = "argon2d";
20const POW_ARGON2_VERSION: u64 = 19;
21const POW_MEMORY_SIZE: u32 = 8192;
22const POW_ITERATIONS: u32 = 1;
23const POW_PARALLELISM: u32 = 1;
24const POW_HASH_LENGTH: usize = 32;
25const POW_SALT: &[u8] = b"sui-faucet-pow-1";
26const MIN_DIFFICULTY: u64 = 2;
27const MAX_DIFFICULTY: u64 = 1 << 48;
28
29#[derive(Serialize, Deserialize, Debug, Clone)]
31#[serde(rename_all = "camelCase")]
32pub struct CoinInfo {
33 pub amount: u64,
34 pub id: Address,
35 pub transfer_tx_digest: Digest,
36}
37
38#[derive(Clone)]
39pub struct FaucetClient {
40 faucet_url: http::Uri,
41 inner: reqwest::Client,
42}
43
44impl FaucetClient {
45 pub const TESTNET: &str = "https://faucet.testnet.sui.io";
47 pub const DEVNET: &str = "https://faucet.devnet.sui.io";
49 pub const LOCAL: &str = "http://localhost:9123";
51
52 pub fn new<T>(faucet_url: T) -> Result<Self, BoxError>
56 where
57 T: TryInto<http::Uri>,
58 T::Error: Into<BoxError>,
59 {
60 let inner = reqwest::Client::new();
61 let faucet_url = faucet_url.try_into().map_err(Into::into)?;
62 Ok(Self { faucet_url, inner })
63 }
64
65 pub async fn create_challenge(&self, recipient: Address) -> Result<PowChallenge, BoxError> {
67 self.fetch_challenge(recipient)
68 .await?
69 .ok_or_else(|| "faucet does not support v3 proof-of-work challenges".into())
70 }
71
72 pub async fn request(&self, recipient: Address) -> Result<FaucetResponse, BoxError> {
79 match self.fetch_challenge(recipient).await? {
80 Some(challenge) => {
81 let (challenge, solution) = tokio::task::spawn_blocking(move || {
82 let solution = challenge.solve()?;
83 Ok::<_, BoxError>((challenge, solution))
84 })
85 .await??;
86 self.submit(&challenge, &solution)
87 .await
88 .map(FaucetResponse::V3)
89 }
90 None => self.request_v2(recipient).await.map(FaucetResponse::V2),
91 }
92 }
93
94 pub async fn submit(
96 &self,
97 challenge: &PowChallenge,
98 solution: &PowSolution,
99 ) -> Result<V3FaucetResponse, BoxError> {
100 challenge.validate(challenge.recipient)?;
101 let response = self
102 .inner
103 .post(self.endpoint("v3/gas"))
104 .json(&FaucetRequest {
105 recipient: challenge.recipient,
106 checkpoint_seq: challenge.checkpoint_seq.to_string(),
107 nonce: solution.nonce.to_string(),
108 hash_hex: solution.hash_hex(),
109 })
110 .send()
111 .await?;
112 self.json_response(response).await
113 }
114
115 async fn fetch_challenge(&self, recipient: Address) -> Result<Option<PowChallenge>, BoxError> {
116 let response = self
117 .inner
118 .get(self.endpoint("v3/challenge"))
119 .query(&[("recipient", recipient.to_string())])
120 .send()
121 .await?;
122 if response.status() == StatusCode::NOT_FOUND {
123 return Ok(None);
124 }
125 let challenge: PowChallenge = self.json_response(response).await?;
126 challenge.validate(recipient)?;
127 Ok(Some(challenge))
128 }
129
130 async fn request_v2(&self, recipient: Address) -> Result<Vec<CoinInfo>, BoxError> {
131 let response = self
132 .inner
133 .post(self.endpoint("v2/gas"))
134 .json(&V2FaucetRequest {
135 fixed_amount_request: V2FixedAmountRequest { recipient },
136 })
137 .send()
138 .await?;
139 let response: V2FaucetResponse = self.json_response(response).await?;
140 match response.status {
141 V2RequestStatus::Success => Ok(response.coins_sent.unwrap_or_default()),
142 V2RequestStatus::Failure(error) => {
143 Err(format!("v2 faucet request failed: {error}").into())
144 }
145 }
146 }
147
148 fn endpoint(&self, path: &str) -> String {
149 format!(
150 "{}/{path}",
151 self.faucet_url.to_string().trim_end_matches('/')
152 )
153 }
154
155 async fn json_response<T>(&self, response: reqwest::Response) -> Result<T, BoxError>
156 where
157 T: serde::de::DeserializeOwned,
158 {
159 let status = response.status();
160 if status.is_success() {
161 return Ok(response.json().await?);
162 }
163
164 let body = response
165 .json::<serde_json::Value>()
166 .await
167 .unwrap_or_default();
168 Err(FaucetError {
170 status,
171 code: body.get("code").and_then(|v| v.as_str()).map(str::to_owned),
172 message: body
173 .get("error")
174 .and_then(|v| v.as_str())
175 .map(str::to_owned)
176 .unwrap_or_else(|| status.to_string()),
177 digest: body
178 .get("digest")
179 .and_then(|v| v.as_str())
180 .and_then(|v| v.parse().ok()),
181 challenge: body
182 .get("challenge")
183 .cloned()
184 .and_then(|v| serde_json::from_value(v).ok()),
185 }
186 .into())
187 }
188}
189
190#[derive(Debug, Clone, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct PowChallenge {
194 pub version: u64,
195 pub domain: String,
196 pub algorithm: String,
197 pub argon2_version: u64,
198 pub memory_size: u32,
199 pub iterations: u32,
200 pub parallelism: u32,
201 pub hash_length: usize,
202 pub salt: String,
203 pub network: String,
204 pub chain_id: String,
205 #[serde(deserialize_with = "deserialize_u64")]
206 pub checkpoint_seq: u64,
207 pub checkpoint_digest: String,
208 pub random_bytes: String,
209 pub faucet_address: Address,
210 pub recipient: Address,
211 #[serde(deserialize_with = "deserialize_u64")]
212 pub difficulty: u64,
213 #[serde(deserialize_with = "deserialize_u64")]
214 pub difficulty_base: u64,
215 #[serde(deserialize_with = "deserialize_u64")]
216 pub threshold: u64,
217 pub expected_attempts: u64,
218 pub window_seconds: u64,
219 #[serde(deserialize_with = "deserialize_u64")]
220 pub amount_mist: u64,
221}
222
223impl PowChallenge {
224 pub fn solve(&self) -> Result<PowSolution, BoxError> {
229 self.validate(self.recipient)?;
230 let params = Params::new(
231 POW_MEMORY_SIZE,
232 POW_ITERATIONS,
233 POW_PARALLELISM,
234 Some(POW_HASH_LENGTH),
235 )
236 .map_err(|error| format!("invalid proof-of-work parameters: {error}"))?;
237 let mut memory_blocks = vec![argon2::Block::default(); params.block_count()];
238 let argon2 = Argon2::new(Algorithm::Argon2d, Version::V0x13, params);
239
240 let start_nonce = RandomState::new().hash_one(());
242 for offset in 0..=u64::MAX {
243 let nonce = start_nonce.wrapping_add(offset);
244 let mut hash = [0; POW_HASH_LENGTH];
245 argon2
246 .hash_password_into_with_memory(
247 self.preimage(nonce).as_bytes(),
248 POW_SALT,
249 &mut hash,
250 &mut memory_blocks,
251 )
252 .map_err(|error| format!("failed to compute proof of work: {error}"))?;
253 if hash_to_u64(&hash) < self.threshold {
254 return Ok(PowSolution { nonce, hash });
255 }
256 }
257
258 Err("exhausted the proof-of-work nonce space".into())
259 }
260
261 fn validate(&self, recipient: Address) -> Result<(), BoxError> {
262 let valid_parameters = self.version == POW_VERSION
263 && self.domain == POW_DOMAIN
264 && self.algorithm == POW_ALGORITHM
265 && self.argon2_version == POW_ARGON2_VERSION
266 && self.memory_size == POW_MEMORY_SIZE
267 && self.iterations == POW_ITERATIONS
268 && self.parallelism == POW_PARALLELISM
269 && self.hash_length == POW_HASH_LENGTH
270 && self.salt.as_bytes() == POW_SALT;
271 if !valid_parameters {
272 return Err("faucet challenge uses unsupported proof-of-work parameters".into());
273 }
274 if self.recipient != recipient {
275 return Err("faucet challenge recipient does not match the requested recipient".into());
276 }
277 if !(MIN_DIFFICULTY..=MAX_DIFFICULTY).contains(&self.difficulty) {
278 return Err("faucet challenge has an invalid difficulty".into());
279 }
280 if self.difficulty_base != self.difficulty
281 || self.threshold != threshold_for(self.difficulty)
282 {
283 return Err("faucet challenge has an inconsistent difficulty".into());
284 }
285 Ok(())
286 }
287
288 fn preimage(&self, nonce: u64) -> String {
289 format!(
290 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{nonce}",
291 self.domain,
292 self.chain_id,
293 self.checkpoint_seq,
294 self.checkpoint_digest,
295 self.random_bytes,
296 self.faucet_address,
297 self.recipient,
298 )
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct PowSolution {
305 pub nonce: u64,
306 pub hash: [u8; POW_HASH_LENGTH],
307}
308
309impl PowSolution {
310 fn hash_hex(&self) -> String {
311 self.hash.iter().map(|byte| format!("{byte:02x}")).collect()
312 }
313}
314
315#[derive(Debug, Clone)]
317pub enum FaucetResponse {
318 V2(Vec<CoinInfo>),
320 V3(V3FaucetResponse),
322}
323
324#[derive(Debug, Clone, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct V3FaucetResponse {
328 pub digest: Digest,
329 pub recipient: Address,
330 #[serde(deserialize_with = "deserialize_u64")]
331 pub amount_mist: u64,
332 #[serde(deserialize_with = "deserialize_u64")]
333 pub difficulty: u64,
334}
335
336#[derive(Serialize)]
337#[serde(rename_all = "camelCase")]
338struct FaucetRequest {
339 recipient: Address,
340 checkpoint_seq: String,
341 nonce: String,
342 hash_hex: String,
343}
344
345#[derive(Debug, Clone)]
350#[non_exhaustive]
351pub struct FaucetError {
352 pub status: StatusCode,
353 pub code: Option<String>,
355 pub message: String,
356 pub digest: Option<Digest>,
358 pub challenge: Option<PowChallenge>,
360}
361
362impl std::fmt::Display for FaucetError {
363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364 write!(f, "faucet request failed: {}", self.message)?;
365 if let Some(code) = &self.code {
366 write!(f, " ({code})")?;
367 }
368 Ok(())
369 }
370}
371
372impl std::error::Error for FaucetError {}
373
374#[derive(Serialize)]
375#[serde(rename_all = "camelCase")]
376struct V2FaucetRequest {
377 #[serde(rename = "FixedAmountRequest")]
378 fixed_amount_request: V2FixedAmountRequest,
379}
380
381#[derive(Serialize)]
382struct V2FixedAmountRequest {
383 recipient: Address,
384}
385
386#[derive(Deserialize)]
387struct V2FaucetResponse {
388 status: V2RequestStatus,
389 coins_sent: Option<Vec<CoinInfo>>,
390}
391
392#[derive(Deserialize)]
393enum V2RequestStatus {
394 Success,
395 Failure(V2FaucetError),
396}
397
398#[derive(Deserialize)]
399enum V2FaucetError {
400 MissingTurnstileTokenHeader,
401 TooManyRequests(String),
402 Internal(String),
403 InvalidUserAgent(String),
404}
405
406impl std::fmt::Display for V2FaucetError {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 match self {
409 Self::MissingTurnstileTokenHeader => f.write_str("missing X-Turnstile-Token header"),
410 Self::TooManyRequests(message) => write!(f, "request limit exceeded: {message}"),
411 Self::Internal(message) => write!(f, "internal error: {message}"),
412 Self::InvalidUserAgent(message) => write!(f, "invalid user agent: {message}"),
413 }
414 }
415}
416
417fn deserialize_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
418where
419 D: de::Deserializer<'de>,
420{
421 let value = String::deserialize(deserializer)?;
422 if value.is_empty()
423 || (value.len() > 1 && value.starts_with('0'))
424 || !value.bytes().all(|byte| byte.is_ascii_digit())
425 {
426 return Err(de::Error::custom("expected a canonical decimal u64"));
427 }
428 value.parse().map_err(de::Error::custom)
429}
430
431fn hash_to_u64(hash: &[u8; POW_HASH_LENGTH]) -> u64 {
432 let mut value = [0; 8];
433 value.copy_from_slice(&hash[..8]);
434 u64::from_be_bytes(value)
435}
436
437fn threshold_for(difficulty: u64) -> u64 {
438 ((1u128 << 64) / u128::from(difficulty)) as u64
439}
440
441#[cfg(test)]
442mod tests {
443 use std::io::Read;
444 use std::io::Write;
445 use std::net::TcpListener;
446 use std::str::FromStr;
447
448 use super::*;
449
450 const RECIPIENT: &str = "0x0000000000000000000000000000000000000000000000000000000000000001";
451
452 #[tokio::test]
453 async fn creates_a_v3_proof_of_work_challenge() {
454 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
455 let address = listener.local_addr().unwrap();
456 let server = std::thread::spawn(move || {
457 let (mut stream, _) = listener.accept().unwrap();
458 let mut request = [0; 4096];
459 let size = stream.read(&mut request).unwrap();
460 let request = String::from_utf8_lossy(&request[..size]).into_owned();
461 let body = challenge_json();
462 write!(
463 stream,
464 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
465 body.len(),
466 )
467 .unwrap();
468 request
469 });
470
471 let client = FaucetClient::new(format!("http://{address}/")).unwrap();
472 let challenge = client
473 .create_challenge(Address::from_str(RECIPIENT).unwrap())
474 .await
475 .unwrap();
476
477 assert_eq!(challenge.recipient, Address::from_str(RECIPIENT).unwrap());
478 assert_eq!(challenge.checkpoint_seq, 42);
479 assert_eq!(challenge.difficulty, 2);
480 assert!(
481 server
482 .join()
483 .unwrap()
484 .starts_with(&format!("GET /v3/challenge?recipient={RECIPIENT} HTTP/1.1"))
485 );
486 }
487
488 #[test]
489 fn solves_and_submits_a_v3_gas_request() {
490 let (started_tx, started_rx) = std::sync::mpsc::channel();
491 let runtime = tokio::runtime::Builder::new_current_thread()
492 .enable_all()
493 .on_thread_start(move || started_tx.send(()).unwrap())
494 .build()
495 .unwrap();
496
497 runtime.block_on(request_v3_with_mock_server());
498
499 assert!(
502 started_rx.try_recv().is_ok(),
503 "proof of work must run on a blocking thread"
504 );
505 }
506
507 async fn request_v3_with_mock_server() {
508 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
509 let address = listener.local_addr().unwrap();
510 let server = std::thread::spawn(move || {
511 let (mut challenge_stream, _) = listener.accept().unwrap();
512 let challenge_request = read_http_request(&mut challenge_stream);
513 write_json_response(&mut challenge_stream, &challenge_json());
514
515 let (mut gas_stream, _) = listener.accept().unwrap();
516 let gas_request = read_http_request(&mut gas_stream);
517 let body = format!(
518 r#"{{"status":"success","digest":"{}","recipient":"{RECIPIENT}","amountMist":"1000000000","difficulty":"2"}}"#,
519 "1".repeat(32),
520 );
521 write_json_response(&mut gas_stream, &body);
522 (challenge_request, gas_request)
523 });
524
525 let client = FaucetClient::new(format!("http://{address}")).unwrap();
526 let payout = client
527 .request(Address::from_str(RECIPIENT).unwrap())
528 .await
529 .unwrap();
530 let (challenge_request, gas_request) = server.join().unwrap();
531 let gas_body = gas_request.split_once("\r\n\r\n").unwrap().1;
532 let gas_body: serde_json::Value = serde_json::from_str(gas_body).unwrap();
533
534 assert!(
535 challenge_request
536 .starts_with(&format!("GET /v3/challenge?recipient={RECIPIENT} HTTP/1.1"))
537 );
538 assert!(gas_request.starts_with("POST /v3/gas HTTP/1.1"));
539 assert_eq!(gas_body["recipient"], RECIPIENT);
540 assert_eq!(gas_body["checkpointSeq"], "42");
541 assert!(gas_body["nonce"].as_str().is_some());
542 assert_eq!(
543 gas_body["hashHex"].as_str().unwrap().len(),
544 POW_HASH_LENGTH * 2
545 );
546 let FaucetResponse::V3(payout) = payout else {
547 panic!("expected a v3 faucet response");
548 };
549 assert_eq!(payout.recipient, Address::from_str(RECIPIENT).unwrap());
550 assert_eq!(payout.amount_mist, 1_000_000_000);
551 assert_eq!(payout.difficulty, 2);
552 }
553
554 #[tokio::test]
555 async fn falls_back_to_v2_when_v3_challenge_is_unavailable() {
556 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
557 let address = listener.local_addr().unwrap();
558 let server = std::thread::spawn(move || {
559 let (mut challenge_stream, _) = listener.accept().unwrap();
560 let challenge_request = read_http_request(&mut challenge_stream);
561 write_http_response(
562 &mut challenge_stream,
563 404,
564 "Not Found",
565 r#"{"error":"not found","code":"not_found"}"#,
566 );
567
568 let (mut gas_stream, _) = listener.accept().unwrap();
569 let gas_request = read_http_request(&mut gas_stream);
570 let body = format!(
571 r#"{{"status":"Success","coins_sent":[{{"amount":1000000000,"id":"0x0000000000000000000000000000000000000000000000000000000000000003","transferTxDigest":"{}"}}]}}"#,
572 "1".repeat(32),
573 );
574 write_json_response(&mut gas_stream, &body);
575 (challenge_request, gas_request)
576 });
577
578 let client = FaucetClient::new(format!("http://{address}")).unwrap();
579 let response = client
580 .request(Address::from_str(RECIPIENT).unwrap())
581 .await
582 .unwrap();
583 let (challenge_request, gas_request) = server.join().unwrap();
584 let gas_body = gas_request.split_once("\r\n\r\n").unwrap().1;
585 let gas_body: serde_json::Value = serde_json::from_str(gas_body).unwrap();
586
587 assert!(challenge_request.starts_with("GET /v3/challenge"));
588 assert!(gas_request.starts_with("POST /v2/gas HTTP/1.1"));
589 assert_eq!(gas_body["FixedAmountRequest"]["recipient"], RECIPIENT);
590 let FaucetResponse::V2(coins) = response else {
591 panic!("expected a v2 faucet response");
592 };
593 assert_eq!(coins.len(), 1);
594 assert_eq!(coins[0].amount, 1_000_000_000);
595 }
596
597 #[tokio::test]
598 async fn does_not_fall_back_to_v2_when_v3_challenge_fails() {
599 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
600 let address = listener.local_addr().unwrap();
601 let server = std::thread::spawn(move || {
602 let (mut stream, _) = listener.accept().unwrap();
603 let request = read_http_request(&mut stream);
604 write_http_response(
605 &mut stream,
606 503,
607 "Service Unavailable",
608 r#"{"error":"not ready","code":"not_ready"}"#,
609 );
610 request
611 });
612
613 let client = FaucetClient::new(format!("http://{address}")).unwrap();
614 assert!(
615 client
616 .request(Address::from_str(RECIPIENT).unwrap())
617 .await
618 .is_err()
619 );
620 assert!(server.join().unwrap().starts_with("GET /v3/challenge"));
621 }
622
623 #[tokio::test]
624 async fn preserves_structured_faucet_errors() {
625 let client = FaucetClient::new(FaucetClient::LOCAL).unwrap();
626 let digest = Digest::ZERO;
627 let challenge: serde_json::Value = serde_json::from_str(&challenge_json()).unwrap();
628 for (status, code) in [
629 (StatusCode::BAD_GATEWAY, "payout_status_unknown"),
630 (StatusCode::BAD_GATEWAY, "payout_failed"),
631 (StatusCode::CONFLICT, "already_used"),
632 (StatusCode::BAD_REQUEST, "future_error_code"),
633 ] {
634 let body = serde_json::json!({
635 "error": "inspect the payout",
636 "code": code,
637 "digest": digest,
638 "challenge": challenge,
639 });
640 let response = http::Response::builder()
641 .status(status)
642 .body(body.to_string())
643 .unwrap();
644 let error = client
645 .json_response::<serde_json::Value>(response.into())
646 .await
647 .unwrap_err();
648 let error = error.downcast_ref::<FaucetError>().unwrap();
649
650 assert_eq!(error.status, status);
651 assert_eq!(error.code.as_deref(), Some(code));
652 assert_eq!(error.message, "inspect the payout");
653 assert_eq!(error.digest, Some(digest));
654 assert_eq!(error.challenge.as_ref().unwrap().checkpoint_seq, 42);
655 assert_eq!(
656 error.to_string(),
657 format!("faucet request failed: inspect the payout ({code})")
658 );
659 }
660 }
661
662 #[tokio::test]
663 async fn preserves_error_metadata_without_a_decodable_challenge() {
664 let client = FaucetClient::new(FaucetClient::LOCAL).unwrap();
665 for body in [
666 serde_json::json!({"error": "failed", "code": "payout_failed"}),
667 serde_json::json!({
668 "error": "failed",
669 "code": "payout_failed",
670 "digest": Digest::ZERO,
671 "challenge": {"version": 2},
672 }),
673 ] {
674 let response = http::Response::builder()
675 .status(StatusCode::BAD_GATEWAY)
676 .body(body.to_string())
677 .unwrap();
678 let error = client
679 .json_response::<serde_json::Value>(response.into())
680 .await
681 .unwrap_err();
682 let error = error.downcast_ref::<FaucetError>().unwrap();
683
684 assert_eq!(error.status, StatusCode::BAD_GATEWAY);
685 assert_eq!(error.code.as_deref(), Some("payout_failed"));
686 assert_eq!(error.message, "failed");
687 assert_eq!(error.digest.is_some(), body.get("digest").is_some());
688 assert!(error.challenge.is_none());
689 }
690 }
691
692 #[tokio::test]
693 async fn preserves_http_status_without_a_json_error_body() {
694 let client = FaucetClient::new(FaucetClient::LOCAL).unwrap();
695 let response = http::Response::builder()
696 .status(StatusCode::BAD_GATEWAY)
697 .body("bad gateway")
698 .unwrap();
699 let error = client
700 .json_response::<serde_json::Value>(response.into())
701 .await
702 .unwrap_err();
703 let error = error.downcast_ref::<FaucetError>().unwrap();
704
705 assert_eq!(error.status, StatusCode::BAD_GATEWAY);
706 assert_eq!(error.message, StatusCode::BAD_GATEWAY.to_string());
707 assert!(error.code.is_none());
708 assert!(error.digest.is_none());
709 assert!(error.challenge.is_none());
710 }
711
712 #[test]
713 fn solves_a_v1_challenge() {
714 let challenge: PowChallenge = serde_json::from_str(&challenge_json()).unwrap();
715 let solution = challenge.solve().unwrap();
716
717 assert!(hash_to_u64(&solution.hash) < challenge.threshold);
718 assert_eq!(solution.hash_hex().len(), POW_HASH_LENGTH * 2);
719 }
720
721 #[test]
722 fn rejects_unsupported_challenge_parameters_before_grinding() {
723 let json = challenge_json().replace("\"memorySize\": 8192", "\"memorySize\": 8193");
724 let challenge: PowChallenge = serde_json::from_str(&json).unwrap();
725
726 assert!(challenge.solve().is_err());
727 }
728
729 fn read_http_request(stream: &mut std::net::TcpStream) -> String {
730 let mut bytes = Vec::new();
731 let mut buffer = [0; 1024];
732 loop {
733 let size = stream.read(&mut buffer).unwrap();
734 bytes.extend_from_slice(&buffer[..size]);
735 let Some(headers_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n")
736 else {
737 continue;
738 };
739 let headers = std::str::from_utf8(&bytes[..headers_end]).unwrap();
740 let content_length = headers
741 .lines()
742 .find_map(|header| header.strip_prefix("content-length: "))
743 .and_then(|value| value.parse::<usize>().ok())
744 .unwrap_or_default();
745 if bytes.len() >= headers_end + 4 + content_length {
746 return String::from_utf8(bytes).unwrap();
747 }
748 }
749 }
750
751 fn write_json_response(stream: &mut std::net::TcpStream, body: &str) {
752 write_http_response(stream, 200, "OK", body);
753 }
754
755 fn write_http_response(
756 stream: &mut std::net::TcpStream,
757 status: u16,
758 reason: &str,
759 body: &str,
760 ) {
761 write!(
762 stream,
763 "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
764 body.len(),
765 )
766 .unwrap();
767 }
768
769 fn challenge_json() -> String {
770 format!(
771 r#"{{
772 "version": 1,
773 "domain": "sui-faucet-pow/1",
774 "algorithm": "argon2d",
775 "argon2Version": 19,
776 "memorySize": 8192,
777 "iterations": 1,
778 "parallelism": 1,
779 "hashLength": 32,
780 "salt": "sui-faucet-pow-1",
781 "network": "testnet",
782 "chainId": "chain-id",
783 "checkpointSeq": "42",
784 "checkpointDigest": "checkpoint-digest",
785 "randomBytes": "random-bytes",
786 "faucetAddress": "0x0000000000000000000000000000000000000000000000000000000000000002",
787 "recipient": "{RECIPIENT}",
788 "difficulty": "2",
789 "difficultyBase": "2",
790 "threshold": "9223372036854775808",
791 "expectedAttempts": 2,
792 "windowSeconds": 60,
793 "amountMist": "1000000000"
794 }}"#,
795 )
796 }
797}