From 2374813d4e7c5f5b9d117892a1fef08144f3f433 Mon Sep 17 00:00:00 2001 From: stefan-mysten <135084671+stefan-mysten@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:13:34 -0700 Subject: [PATCH] sui-graphql-client: introduce a faucet client (#11) --- crates/sui-graphql-client/Cargo.toml | 3 + crates/sui-graphql-client/README.md | 55 ++++++ crates/sui-graphql-client/src/faucet.rs | 242 ++++++++++++++++++++++++ crates/sui-graphql-client/src/lib.rs | 11 +- 4 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 crates/sui-graphql-client/src/faucet.rs diff --git a/crates/sui-graphql-client/Cargo.toml b/crates/sui-graphql-client/Cargo.toml index 99d1ec4c3..46b95d101 100644 --- a/crates/sui-graphql-client/Cargo.toml +++ b/crates/sui-graphql-client/Cargo.toml @@ -18,7 +18,10 @@ chrono = "0.4.38" cynic = "3.8.0" futures = "0.3.30" reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1.0" } +serde_json = {version = "1.0"} sui-types = { package = "sui-sdk-types", path = "../sui-sdk-types", features = ["serde"] } +tracing = "0.1.40" tokio = "1.40.0" [dev-dependencies] diff --git a/crates/sui-graphql-client/README.md b/crates/sui-graphql-client/README.md index 894c25b0e..1e6b5e0cb 100644 --- a/crates/sui-graphql-client/README.md +++ b/crates/sui-graphql-client/README.md @@ -36,6 +36,61 @@ async fn main() -> Result<()> { } ``` +## Requesting gas from the faucet +The client provides an API to request gas from the faucet. The `request_and_wait` function sends a request to the faucet and waits until the transaction is confirmed. The function returns the transaction details if the request is successful. + +### Example for standard devnet/testnet/local networks. +```rust, no_run +use sui_graphql_client::faucet::FaucetClient; +use sui_types::types::Address; + +use anyhow::Result; +use std::str::FromStr; + +#[tokio::main] +async fn main() -> Result<()> { + let address = Address::from_str("SUI_ADDRESS_HERE")?; + // Request gas from the faucet and wait until a coin is received + // As the client is set to devnet, faucet will use the devnet faucet. + let faucet = FaucetClient::devnet().request_and_wait(address).await?; + if let Some(resp) = faucet { + let coins = resp.sent; + for coin in coins { + println!("coin: {:?}", coin); + } + } + + // Request gas from the testnet faucet by explicitly setting the faucet to testnet + let faucet_testnet = FaucetClient::testnet().request_and_wait(address).await?; + Ok(()) +} +``` + +### Example for custom faucet service. +Note that this [`FaucetClient`] is explicitly designed to work with two endpoints: `v1/gas`, and `v1/status`. When passing in the custom faucet URL, skip the final endpoint and only pass in the top-level url (e.g., `https://faucet.devnet.sui.io`). +```rust, no_run +use sui_graphql_client::faucet::FaucetClient; +use sui_types::types::Address; + +use anyhow::Result; +use std::str::FromStr; + +#[tokio::main] +async fn main() -> Result<()> { + let address = Address::from_str("SUI_ADDRESS_HERE")?; + // Request gas from the faucet and wait until a coin is received + // As the client is set to devnet, faucet will use the devnet faucet. + let faucet = FaucetClient::new("https://myfaucet_testnet.com").request_and_wait(address).await?; + if let Some(resp) = faucet { + let coins = resp.sent; + for coin in coins { + println!("coin: {:?}", coin); + } + } + Ok(()) +} +``` + ## Custom Queries There are several options for running custom queries. 1) Use a GraphQL client library of your choosing. diff --git a/crates/sui-graphql-client/src/faucet.rs b/crates/sui-graphql-client/src/faucet.rs new file mode 100644 index 000000000..8e1610fdb --- /dev/null +++ b/crates/sui-graphql-client/src/faucet.rs @@ -0,0 +1,242 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use sui_types::types::{Address, ObjectId, TransactionDigest}; + +use anyhow::{anyhow, bail}; +use reqwest::{StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::Duration; +use tracing::{error, info}; + +pub const FAUCET_DEVNET_HOST: &str = "https://faucet.devnet.sui.io"; +pub const FAUCET_TESTNET_HOST: &str = "https://faucet.testnet.sui.io"; +pub const FAUCET_LOCAL_HOST: &str = "http://localhost:9123"; + +const FAUCET_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); +const FAUCET_POLL_INTERVAL: Duration = Duration::from_secs(2); + +pub struct FaucetClient { + faucet_url: Url, + inner: reqwest::Client, +} + +#[derive(serde::Deserialize)] +struct FaucetResponse { + task: Option, + error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +struct BatchStatusFaucetResponse { + pub status: Option, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum BatchSendStatusType { + Inprogress, + Succeeded, + Discarded, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BatchSendStatus { + pub status: BatchSendStatusType, + pub transferred_gas_objects: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FaucetReceipt { + pub sent: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct BatchFaucetReceipt { + pub task: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CoinInfo { + pub amount: u64, + pub id: ObjectId, + pub transfer_tx_digest: TransactionDigest, +} + +impl FaucetClient { + /// Construct a new `FaucetClient` with the given faucet service URL. This [`FaucetClient`] + /// expects that the service provides two endpoints: /v1/gas and /v1/status. As such, do not + /// provide the request endpoint, just the top level service endpoint. + /// + /// - /v1/gas is used to request gas + /// - /v1/status/taks-uuid is used to check the status of the request + pub fn new(faucet_url: &str) -> Self { + let inner = reqwest::Client::new(); + let faucet_url = Url::parse(faucet_url).expect("Invalid faucet URL"); + FaucetClient { faucet_url, inner } + } + + /// Set to local faucet. + pub fn local() -> Self { + Self { + faucet_url: Url::parse(FAUCET_LOCAL_HOST).expect("Invalid faucet URL"), + inner: reqwest::Client::new(), + } + } + + /// Set to devnet faucet. + pub fn devnet() -> Self { + Self { + faucet_url: Url::parse(FAUCET_DEVNET_HOST).expect("Invalid faucet URL"), + inner: reqwest::Client::new(), + } + } + + /// Set to testnet faucet. + pub fn testnet() -> Self { + Self { + faucet_url: Url::parse(FAUCET_TESTNET_HOST).expect("Invalid faucet URL"), + inner: reqwest::Client::new(), + } + } + + /// Request gas from the faucet. Note that this will return the UUID of the request and not + /// wait until the token is received. Use `request_and_wait` to wait for the token. + pub async fn request(&self, address: Address) -> Result, anyhow::Error> { + self.request_impl(address).await + } + + /// Internal implementation of a faucet request. It returns the task Uuid as a String. + async fn request_impl(&self, address: Address) -> Result, anyhow::Error> { + let address = address.to_string(); + let json_body = json![{ + "FixedAmountRequest": { + "recipient": &address + } + }]; + let url = format!("{}v1/gas", self.faucet_url); + info!( + "Requesting gas from faucet for address {} : {}", + address, url + ); + let resp = self + .inner + .post(url) + .header("content-type", "application/json") + .json(&json_body) + .send() + .await?; + match resp.status() { + StatusCode::ACCEPTED | StatusCode::CREATED => { + let faucet_resp: FaucetResponse = resp.json().await?; + + if let Some(err) = faucet_resp.error { + error!("Faucet request was unsuccessful: {err}"); + bail!("Faucet request was unsuccessful: {err}") + } else { + info!("Request succesful: {:?}", faucet_resp.task); + Ok(faucet_resp.task) + } + } + StatusCode::TOO_MANY_REQUESTS => { + error!("Faucet service received too many requests from this IP address."); + bail!("Faucet service received too many requests from this IP address. Please try again after 60 minutes."); + } + StatusCode::SERVICE_UNAVAILABLE => { + error!("Faucet service is currently overloaded or unavailable."); + bail!("Faucet service is currently overloaded or unavailable. Please try again later."); + } + status_code => { + error!("Faucet request was unsuccessful: {status_code}"); + bail!("Faucet request was unsuccessful: {status_code}"); + } + } + } + + /// Request gas from the faucet and wait until the request is completed and token is + /// transferred. Returns `FaucetReceipt` if the request is successful, which contains the list + /// of tokens transferred, and the transaction digest. + /// + /// Note that the faucet is heavily rate-limited, so calling repeatedly the faucet would likely + /// result in a 429 code or 502 code. + pub async fn request_and_wait( + &self, + address: Address, + ) -> Result, anyhow::Error> { + let request_id = self.request(address).await?; + if let Some(request_id) = request_id { + let poll_response = tokio::time::timeout(FAUCET_REQUEST_TIMEOUT, async { + let mut interval = tokio::time::interval(FAUCET_POLL_INTERVAL); + loop { + interval.tick().await; + info!("Polling faucet request status: {request_id}"); + let req = self.request_status(request_id.clone()).await; + + if let Ok(Some(poll_response)) = req { + match poll_response.status { + BatchSendStatusType::Succeeded => { + info!("Faucet request {request_id} succeeded"); + break Ok(poll_response); + } + BatchSendStatusType::Discarded => { + break Ok(BatchSendStatus { + status: BatchSendStatusType::Discarded, + transferred_gas_objects: None, + }); + } + BatchSendStatusType::Inprogress => { + continue; + } + } + } else if let Some(err) = req.err() { + error!("Faucet request {request_id} failed. Error: {:?}", err); + break Err(anyhow!( + "Faucet request {request_id} failed. Error: {:?}", + err + )); + } + } + }) + .await + .map_err(|_| { + error!( + "Faucet request {request_id} timed out. Timeout set to {} seconds", + FAUCET_REQUEST_TIMEOUT.as_secs() + ); + anyhow!("Faucet request timed out") + })??; + Ok(poll_response.transferred_gas_objects) + } else { + Ok(None) + } + } + + /// Check the faucet request status. + /// + /// Possible statuses are defined in: [`BatchSendStatusType`] + pub async fn request_status( + &self, + id: String, + ) -> Result, anyhow::Error> { + let status_url = format!("{}v1/status/{}", self.faucet_url, id); + info!("Checking status of faucet request: {status_url}"); + let response = self.inner.get(&status_url).send().await?; + if response.status() == StatusCode::TOO_MANY_REQUESTS { + bail!("Cannot fetch request status due to too many requests from this IP address."); + } else if response.status() == StatusCode::BAD_GATEWAY { + bail!("Cannot fetch request status due to a bad gateway.") + } + let json = response + .json::() + .await + .map_err(|e| { + error!("Failed to parse faucet response: {:?}", e); + anyhow!("Failed to parse faucet response: {:?}", e) + })?; + Ok(json.status) + } +} diff --git a/crates/sui-graphql-client/src/lib.rs b/crates/sui-graphql-client/src/lib.rs index e0449738b..39dc1215a 100644 --- a/crates/sui-graphql-client/src/lib.rs +++ b/crates/sui-graphql-client/src/lib.rs @@ -3,6 +3,7 @@ #![doc = include_str!("../README.md")] +pub mod faucet; pub mod query_types; use base64ct::Encoding; @@ -29,7 +30,7 @@ use std::pin::Pin; const MAINNET_HOST: &str = "https://sui-mainnet.mystenlabs.com/graphql"; const TESTNET_HOST: &str = "https://sui-testnet.mystenlabs.com/graphql"; const DEVNET_HOST: &str = "https://sui-devnet.mystenlabs.com/graphql"; -const DEFAULT_LOCAL_HOST: &str = "http://localhost:9125/graphql"; +const LOCAL_HOST: &str = "http://localhost:9125/graphql"; static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); #[derive(Debug)] @@ -98,7 +99,7 @@ impl Client { /// Create a new GraphQL client connected to the `localhost` GraphQL server: /// {DEFAULT_LOCAL_HOST}. pub fn new_localhost() -> Self { - Self::new(DEFAULT_LOCAL_HOST).expect("Invalid localhost URL") + Self::new(LOCAL_HOST).expect("Invalid localhost URL") } /// Set the server address for the GraphQL GraphQL client. It should be a valid URL with a host and @@ -677,7 +678,7 @@ impl Client { mod tests { use futures::StreamExt; - use crate::{Client, DEFAULT_LOCAL_HOST, DEVNET_HOST, MAINNET_HOST, TESTNET_HOST}; + use crate::{Client, DEVNET_HOST, LOCAL_HOST, MAINNET_HOST, TESTNET_HOST}; const NETWORKS: [(&str, &str); 2] = [(MAINNET_HOST, "35834a8a"), (TESTNET_HOST, "4c78adac")]; #[test] @@ -688,8 +689,8 @@ mod tests { assert_eq!(client.rpc_server(), TESTNET_HOST); client.set_rpc_server(DEVNET_HOST).unwrap(); assert_eq!(client.rpc_server(), DEVNET_HOST); - client.set_rpc_server(DEFAULT_LOCAL_HOST).unwrap(); - assert_eq!(client.rpc_server(), DEFAULT_LOCAL_HOST); + client.set_rpc_server(LOCAL_HOST).unwrap(); + assert_eq!(client.rpc_server(), LOCAL_HOST); assert!(client.set_rpc_server("localhost:9125/graphql").is_ok()); assert!(client.set_rpc_server("9125/graphql").is_err());