Skip to content

Commit

Permalink
sui-graphql-client: introduce a faucet client (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
stefan-mysten authored Sep 30, 2024
1 parent 8339695 commit 2374813
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 5 deletions.
3 changes: 3 additions & 0 deletions crates/sui-graphql-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
55 changes: 55 additions & 0 deletions crates/sui-graphql-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
242 changes: 242 additions & 0 deletions crates/sui-graphql-client/src/faucet.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
error: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct BatchStatusFaucetResponse {
pub status: Option<BatchSendStatus>,
pub error: Option<String>,
}

#[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<FaucetReceipt>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FaucetReceipt {
pub sent: Vec<CoinInfo>,
}

#[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<Option<String>, 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<Option<String>, 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<Option<FaucetReceipt>, 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<Option<BatchSendStatus>, 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::<BatchStatusFaucetResponse>()
.await
.map_err(|e| {
error!("Failed to parse faucet response: {:?}", e);
anyhow!("Failed to parse faucet response: {:?}", e)
})?;
Ok(json.status)
}
}
11 changes: 6 additions & 5 deletions crates/sui-graphql-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#![doc = include_str!("../README.md")]

pub mod faucet;
pub mod query_types;

use base64ct::Encoding;
Expand All @@ -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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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());
Expand Down

0 comments on commit 2374813

Please sign in to comment.