Skip to main content

sui_rpc/
faucet.rs

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/// Represent a coin sent by a v2 faucet.
30#[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    /// URL for the testnet faucet.
46    pub const TESTNET: &str = "https://faucet.testnet.sui.io";
47    /// URL for the devnet faucet.
48    pub const DEVNET: &str = "https://faucet.devnet.sui.io";
49    /// URL for the local faucet.
50    pub const LOCAL: &str = "http://localhost:9123";
51
52    /// Construct a client for a faucet service URL.
53    ///
54    /// The URL must not include an API path. The service must implement the v3 faucet API.
55    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    /// Fetch a proof-of-work challenge for `recipient`.
66    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    /// Request faucet funds, using v3 proof of work when the faucet supports it.
73    ///
74    /// This method falls back to v2 only when `GET /v3/challenge` returns 404. It solves v3 proofs
75    /// on Tokio's blocking thread pool. Once started, solving continues even if this future is
76    /// dropped. Use [`Self::create_challenge`], [`PowChallenge::solve`], and [`Self::submit`] to
77    /// schedule that work separately.
78    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    /// Submit a solved proof-of-work challenge.
95    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        // Decode fields independently so an incompatible challenge cannot hide a payout digest.
169        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/// Represent a v3 proof-of-work challenge returned by the faucet.
191#[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    /// Solve this challenge and return the proof accepted by the v3 faucet.
225    ///
226    /// This method is CPU and memory intensive. Call it from a blocking task when running in an
227    /// asynchronous application.
228    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        // Vary the start to reduce duplicate proofs for the same checkpoint and recipient.
241        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/// Represent a solved faucet proof-of-work challenge.
303#[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/// Represent a successful faucet payout.
316#[derive(Debug, Clone)]
317pub enum FaucetResponse {
318    /// A non-PoW v2 payout containing the faucet's coin objects.
319    V2(Vec<CoinInfo>),
320    /// A PoW-protected v3 payout.
321    V3(V3FaucetResponse),
322}
323
324/// Represent a successful v3 faucet payout.
325#[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/// Preserve a faucet HTTP failure for inspection through `error.downcast_ref::<FaucetError>()`.
346///
347/// Check `code` and `digest` before requesting another payout. An error does not necessarily mean
348/// that no funds were sent. An attached challenge does not authorize an automatic retry.
349#[derive(Debug, Clone)]
350#[non_exhaustive]
351pub struct FaucetError {
352    pub status: StatusCode,
353    /// Preserve the server's error code, including unknown codes; `None` means absent or malformed.
354    pub code: Option<String>,
355    pub message: String,
356    /// Identify the payout transaction to inspect; `None` does not prove that no payout occurred.
357    pub digest: Option<Digest>,
358    /// Retain a decodable challenge supplied by the server, without validating it.
359    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        // A current-thread runtime only starts threads for blocking work. The mock server uses
500        // an IP address, so DNS resolution cannot start a blocking task instead of the solver.
501        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}