Skip to main content

linera_faucet_client/
lib.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The client component of the Linera faucet.
5
6#![deny(missing_docs)]
7
8// TODO(#3362): generate this code
9
10use std::collections::BTreeMap;
11
12use linera_base::{
13    crypto::{CryptoHash, ValidatorPublicKey},
14    data_types::{Amount, ArithmeticError, ChainDescription, Timestamp},
15    identifiers::{AccountOwner, ChainId},
16};
17use linera_client::config::GenesisConfig;
18use linera_execution::{committee::ValidatorState, Committee, ResourceControlPolicy};
19use linera_version::VersionInfo;
20
21/// The kinds of error that the faucet client can return.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum Error {
25    /// A response from the faucet could not be parsed as JSON.
26    #[error("JSON parsing error: {0:?}")]
27    Json(#[from] serde_json::Error),
28    /// The faucet returned one or more GraphQL errors.
29    #[error("GraphQL error: {0:?}")]
30    GraphQl(Vec<serde_json::Value>),
31    /// An HTTP request to the faucet failed.
32    #[error("{}", describe_http_failure(.0))]
33    Http(#[from] reqwest::Error),
34    /// An arithmetic operation overflowed.
35    #[error(transparent)]
36    ArithmeticError(#[from] ArithmeticError),
37    /// A GraphQL query could not be sent to the faucet.
38    #[error("failed to execute query {query:?}: {}", describe_http_failure(.source))]
39    Query {
40        /// The query that could not be sent.
41        query: String,
42        /// The underlying HTTP failure.
43        #[source]
44        source: reqwest::Error,
45    },
46}
47
48/// Describes a `reqwest` failure without reproducing its source.
49///
50/// On wasm that source is built with `format!("{js_val:?}")`, which embeds the JavaScript
51/// stack trace of the failed `fetch`. The trace differs per browser and per bundle hash, so
52/// carrying it splits one underlying failure across many distinct-looking reports. It buys
53/// little in exchange: browsers deliberately report CORS rejections, offline and DNS
54/// failures as the same opaque `TypeError`, so the text that varies is mostly the browser's
55/// own phrasing. The error is still available through `source()` for anything that wants it.
56fn describe_http_failure(error: &reqwest::Error) -> String {
57    let where_ = match error.url() {
58        Some(url) => format!(" at {url}"),
59        None => String::new(),
60    };
61    if let Some(status) = error.status() {
62        format!("the faucet{where_} returned {status}")
63    } else if error.is_timeout() {
64        format!("the request to the faucet{where_} timed out")
65    } else if error.is_decode() {
66        format!("could not decode the faucet's response{where_}")
67    } else if error.is_body() {
68        format!("the request body sent to the faucet{where_} was rejected")
69    } else {
70        format!("could not reach the faucet{where_}")
71    }
72}
73
74/// The result of a successful claim mutation.
75#[derive(Clone, Debug, serde::Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ClaimOutcome {
78    /// The ID of the chain.
79    pub chain_id: ChainId,
80    /// The hash of the certificate containing the operation.
81    pub certificate_hash: CryptoHash,
82    /// The amount of tokens transferred.
83    pub amount: Amount,
84}
85
86/// Information about the initial chain claim.
87#[derive(Clone, Debug, serde::Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct InitialClaim {
90    /// The chain ID that was created.
91    pub chain_id: ChainId,
92    /// The block timestamp when the chain was created.
93    pub timestamp: Timestamp,
94}
95
96/// Returns the `destination` argument to append to a claim mutation, which is omitted for the
97/// chain account so that queries stay compatible with faucets that predate the argument.
98fn destination_argument(destination: &AccountOwner) -> String {
99    if destination.is_chain() {
100        String::new()
101    } else {
102        format!(", destination: \"{destination}\"")
103    }
104}
105
106/// A faucet instance that can be queried.
107#[derive(Debug, Clone)]
108pub struct Faucet {
109    url: String,
110}
111
112impl Faucet {
113    /// Creates a faucet client querying the faucet service at the given URL.
114    pub fn new(url: String) -> Self {
115        Self { url }
116    }
117
118    /// Returns the URL of the faucet service.
119    pub fn url(&self) -> &str {
120        &self.url
121    }
122
123    async fn query<Response: serde::de::DeserializeOwned>(
124        &self,
125        query: impl AsRef<str>,
126    ) -> Result<Response, Error> {
127        let query = query.as_ref();
128
129        #[derive(serde::Deserialize)]
130        struct GraphQlResponse<T> {
131            data: Option<T>,
132            errors: Option<Vec<serde_json::Value>>,
133        }
134
135        let builder = reqwest::ClientBuilder::new();
136
137        #[cfg(not(target_arch = "wasm32"))]
138        let builder = builder.timeout(linera_base::time::Duration::from_secs(30));
139
140        let response: GraphQlResponse<Response> = builder
141            .build()
142            .unwrap()
143            .post(&self.url)
144            .json(&serde_json::json!({
145                "query": query,
146            }))
147            .send()
148            .await
149            .map_err(|source| Error::Query {
150                query: query.to_string(),
151                source,
152            })?
153            .error_for_status()?
154            .json()
155            .await?;
156
157        if let Some(errors) = response.errors {
158            // Extract just the error messages, ignore locations and path
159            let messages = errors
160                .iter()
161                .filter_map(|error| {
162                    error
163                        .get("message")
164                        .and_then(|msg| msg.as_str())
165                        .map(|s| s.to_string())
166                })
167                .collect::<Vec<_>>();
168
169            if messages.is_empty() {
170                Err(Error::GraphQl(errors))
171            } else {
172                Err(Error::GraphQl(vec![serde_json::Value::String(
173                    messages.join("; "),
174                )]))
175            }
176        } else {
177            Ok(response
178                .data
179                .expect("no errors present but no data returned"))
180        }
181    }
182
183    /// Fetches the network's genesis configuration from the faucet.
184    pub async fn genesis_config(&self) -> Result<GenesisConfig, Error> {
185        #[derive(serde::Deserialize)]
186        #[serde(rename_all = "camelCase")]
187        struct Response {
188            genesis_config: GenesisConfig,
189        }
190
191        Ok(self
192            .query::<Response>("query { genesisConfig }")
193            .await?
194            .genesis_config)
195    }
196
197    /// Fetches the faucet's version information.
198    pub async fn version_info(&self) -> Result<VersionInfo, Error> {
199        #[derive(serde::Deserialize)]
200        struct Response {
201            version: VersionInfo,
202        }
203
204        Ok(self.query::<Response>("query { version }").await?.version)
205    }
206
207    /// Claims a new chain for the given owner, returning its chain description. The tokens are
208    /// credited to the new chain's own account.
209    pub async fn claim(&self, owner: &AccountOwner) -> Result<ChainDescription, Error> {
210        self.claim_to(owner, &AccountOwner::CHAIN).await
211    }
212
213    /// Claims a new chain for the given owner, crediting the tokens to `destination` on it.
214    ///
215    /// A chain funded only in an owner's account can pay fees just for the blocks that owner
216    /// authenticates.
217    pub async fn claim_to(
218        &self,
219        owner: &AccountOwner,
220        destination: &AccountOwner,
221    ) -> Result<ChainDescription, Error> {
222        #[derive(serde::Deserialize)]
223        struct Response {
224            claim: ChainDescription,
225        }
226        Ok(self
227            .query::<Response>(format!(
228                "mutation {{ claim(owner: \"{owner}\"{}) }}",
229                destination_argument(destination)
230            ))
231            .await?
232            .claim)
233    }
234
235    /// Claims daily tokens for the given owner, credited to their chain's own account.
236    /// The user must have already claimed a chain. Each user can claim once per
237    /// 24-hour period.
238    pub async fn daily_claim(&self, owner: &AccountOwner) -> Result<ClaimOutcome, Error> {
239        self.daily_claim_to(owner, &AccountOwner::CHAIN).await
240    }
241
242    /// Claims daily tokens for the given owner, crediting them to `destination` on their chain.
243    pub async fn daily_claim_to(
244        &self,
245        owner: &AccountOwner,
246        destination: &AccountOwner,
247    ) -> Result<ClaimOutcome, Error> {
248        #[derive(serde::Deserialize)]
249        #[serde(rename_all = "camelCase")]
250        struct Response {
251            daily_claim: ClaimOutcome,
252        }
253
254        Ok(self
255            .query::<Response>(format!(
256                "mutation {{ dailyClaim(owner: \"{owner}\"{}) }}",
257                destination_argument(destination)
258            ))
259            .await?
260            .daily_claim)
261    }
262
263    /// Returns the initial claim for the given owner, if any.
264    pub async fn initial_claim(&self, owner: &AccountOwner) -> Result<Option<InitialClaim>, Error> {
265        #[derive(serde::Deserialize)]
266        #[serde(rename_all = "camelCase")]
267        struct Response {
268            initial_claim: Option<InitialClaim>,
269        }
270
271        Ok(self
272            .query::<Response>(format!(
273                "query {{ initialClaim(owner: \"{owner}\") {{ chainId timestamp }} }}"
274            ))
275            .await?
276            .initial_claim)
277    }
278
279    /// Returns the earliest time at which the owner can make a daily claim.
280    /// If the returned timestamp is in the past (or now), the user can claim immediately.
281    /// Returns `None` if the user has not yet completed the initial claim.
282    pub async fn next_daily_claim(&self, owner: &AccountOwner) -> Result<Option<Timestamp>, Error> {
283        #[derive(serde::Deserialize)]
284        #[serde(rename_all = "camelCase")]
285        struct Response {
286            next_daily_claim: Option<Timestamp>,
287        }
288
289        Ok(self
290            .query::<Response>(format!("query {{ nextDailyClaim(owner: \"{owner}\") }}"))
291            .await?
292            .next_daily_claim)
293    }
294
295    /// Returns the current validators' public keys and network addresses.
296    pub async fn current_validators(&self) -> Result<Vec<(ValidatorPublicKey, String)>, Error> {
297        #[derive(serde::Deserialize)]
298        #[serde(rename_all = "camelCase")]
299        struct Validator {
300            public_key: ValidatorPublicKey,
301            network_address: String,
302        }
303
304        #[derive(serde::Deserialize)]
305        #[serde(rename_all = "camelCase")]
306        struct Response {
307            current_validators: Vec<Validator>,
308        }
309
310        Ok(self
311            .query::<Response>("query { currentValidators { publicKey networkAddress } }")
312            .await?
313            .current_validators
314            .into_iter()
315            .map(|validator| (validator.public_key, validator.network_address))
316            .collect())
317    }
318
319    /// Returns the current committee: its validators and resource-control policy.
320    pub async fn current_committee(&self) -> Result<Committee, Error> {
321        #[derive(serde::Deserialize)]
322        struct CommitteeResponse {
323            validators: BTreeMap<ValidatorPublicKey, ValidatorState>,
324            policy: ResourceControlPolicy,
325        }
326
327        #[derive(serde::Deserialize)]
328        #[serde(rename_all = "camelCase")]
329        struct Response {
330            current_committee: CommitteeResponse,
331        }
332
333        let response = self
334            .query::<Response>(
335                "query { currentCommittee { \
336                    validators \
337                    policy \
338                } }",
339            )
340            .await?;
341
342        let committee_response = response.current_committee;
343
344        Ok(Committee::new(
345            committee_response.validators,
346            committee_response.policy,
347        )?)
348    }
349}