Skip to main content

sui_faucet/
local_faucet.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fmt;
5use std::sync::Arc;
6
7use anyhow::bail;
8use backoff::ExponentialBackoff;
9use sui_rpc_api::client::ExecutedTransaction;
10use sui_sdk::types::effects::TransactionEffectsAPI;
11use tokio::sync::Mutex;
12use tokio::time::Duration;
13use tracing::info;
14
15use crate::FaucetConfig;
16use crate::FaucetError;
17
18use crate::CoinInfo;
19use shared_crypto::intent::Intent;
20use sui_keys::keystore::AccountKeystore;
21use sui_sdk::types::programmable_transaction_builder::ProgrammableTransactionBuilder;
22use sui_sdk::types::{
23    base_types::{ObjectID, SuiAddress},
24    gas_coin::GasCoin,
25    transaction::{Transaction, TransactionData},
26};
27use sui_sdk::wallet_context::WalletContext;
28
29const GAS_BUDGET: u64 = 10_000_000;
30const NUM_RETRIES: u8 = 2;
31
32/// On a freshly created `--force-regenesis` network the genesis coin objects may not yet be
33/// readable the instant the cluster reports started — especially on slow or contended storage —
34/// so the gas-coin scan is retried with backoff for up to this long before failing.
35const GAS_COIN_LOOKUP_INITIAL_INTERVAL: Duration = Duration::from_millis(200);
36const GAS_COIN_LOOKUP_MAX_ELAPSED_TIME: Duration = Duration::from_secs(10);
37
38pub struct LocalFaucet {
39    wallet: WalletContext,
40    active_address: SuiAddress,
41    coin_id: Arc<Mutex<ObjectID>>,
42    coin_amount: u64,
43    num_coins: usize,
44}
45
46/// We do not just derive(Debug) because WalletContext and the WriteAheadLog do not implement Debug / are also hard
47/// to implement Debug.
48impl fmt::Debug for LocalFaucet {
49    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
50        f.debug_struct("SimpleFaucet")
51            .field("faucet_wallet", &self.active_address)
52            .field("coin_amount", &self.coin_amount)
53            .finish()
54    }
55}
56
57impl LocalFaucet {
58    pub async fn new(
59        mut wallet: WalletContext,
60        config: FaucetConfig,
61    ) -> Result<Arc<Self>, FaucetError> {
62        let (coins, active_address) = find_gas_coins_and_address(&mut wallet, &config).await?;
63        info!("Starting faucet with address: {:?}", active_address);
64
65        Ok(Arc::new(LocalFaucet {
66            wallet,
67            active_address,
68            coin_id: Arc::new(Mutex::new(*coins[0].id())),
69            coin_amount: config.amount,
70            num_coins: config.num_coins,
71        }))
72    }
73
74    /// Make transaction and execute it.
75    pub async fn local_request_execute_tx(
76        &self,
77        recipient: SuiAddress,
78    ) -> Result<Vec<CoinInfo>, FaucetError> {
79        let gas_price = self
80            .wallet
81            .get_reference_gas_price()
82            .await
83            .map_err(|e| FaucetError::internal(format!("Failed to get gas price: {}", e)))?;
84
85        let mut ptb = ProgrammableTransactionBuilder::new();
86        let recipients = vec![recipient; self.num_coins];
87        let amounts = vec![self.coin_amount; self.num_coins];
88        ptb.pay_sui(recipients, amounts)
89            .map_err(FaucetError::internal)?;
90
91        let ptb = ptb.finish();
92
93        let coin_id = self.coin_id.lock().await;
94        let coin_id_ref = self
95            .wallet
96            .get_object_ref(*coin_id)
97            .await
98            .map_err(|e| FaucetError::internal(format!("Failed to get object ref: {}", e)))?;
99        let tx_data = TransactionData::new_programmable(
100            self.active_address,
101            vec![coin_id_ref],
102            ptb,
103            GAS_BUDGET,
104            gas_price,
105        );
106
107        let tx = self
108            .execute_txn_with_retries(tx_data, *coin_id, NUM_RETRIES)
109            .await
110            .map_err(FaucetError::internal)?;
111
112        let coins: Vec<CoinInfo> = tx
113            .effects
114            .created()
115            .into_iter()
116            .map(|o| CoinInfo {
117                amount: self.coin_amount,
118                id: o.0.0,
119                transfer_tx_digest: *tx.effects.transaction_digest(),
120            })
121            .collect();
122
123        Ok(coins)
124    }
125
126    async fn execute_txn(
127        &self,
128        tx_data: &TransactionData,
129        coin_id: ObjectID,
130    ) -> Result<ExecutedTransaction, anyhow::Error> {
131        let signature = self
132            .wallet
133            .config
134            .keystore
135            .sign_secure(&self.active_address, &tx_data, Intent::sui_transaction())
136            .await
137            .map_err(FaucetError::internal)?;
138        let tx = Transaction::from_data(tx_data.clone(), vec![signature]);
139
140        let client = self.wallet.grpc_client()?;
141
142        client
143            .execute_transaction_and_wait_for_checkpoint(&tx)
144            .await
145            .map_err(|e| {
146                FaucetError::internal(format!(
147                    "Failed to execute PaySui transaction for coin {:?}, with err {:?}",
148                    coin_id, e
149                ))
150            })
151            .map_err(Into::into)
152    }
153
154    async fn execute_txn_with_retries(
155        &self,
156        tx: TransactionData,
157        coin_id: ObjectID,
158        num_retries: u8,
159    ) -> Result<ExecutedTransaction, anyhow::Error> {
160        let mut retry_delay = Duration::from_millis(500);
161        let mut i = 0;
162
163        loop {
164            if i == num_retries {
165                bail!("Failed to execute transaction after {num_retries} retries",);
166            }
167            let res = self.execute_txn(&tx, coin_id).await;
168
169            if res.is_ok() {
170                return res;
171            }
172            i += 1;
173            tokio::time::sleep(retry_delay).await;
174            retry_delay *= 2;
175        }
176    }
177
178    pub fn get_coin_amount(&self) -> u64 {
179        self.coin_amount
180    }
181}
182
183/// Finds gas coins with sufficient balance and returns the address to use as the active address
184/// for the faucet. If the initial active address in the wallet does not have enough gas coins,
185/// it will iterate through the addresses to find one with sufficient gas coins.
186///
187/// Retries the scan with exponential backoff so that a transient startup race — where genesis
188/// coins are not yet readable — does not fail the faucet.
189async fn find_gas_coins_and_address(
190    wallet: &mut WalletContext,
191    config: &FaucetConfig,
192) -> Result<(Vec<GasCoin>, SuiAddress), FaucetError> {
193    let active_address = wallet
194        .active_address()
195        .map_err(|e| FaucetError::Wallet(e.to_string()))?;
196    let wallet = &*wallet;
197
198    let backoff = ExponentialBackoff {
199        initial_interval: GAS_COIN_LOOKUP_INITIAL_INTERVAL,
200        current_interval: GAS_COIN_LOOKUP_INITIAL_INTERVAL,
201        max_elapsed_time: Some(GAS_COIN_LOOKUP_MAX_ELAPSED_TIME),
202        ..Default::default()
203    };
204
205    backoff::future::retry(backoff, || async move {
206        let found = scan_for_gas_coins(wallet, active_address, config)
207            .await
208            .map_err(backoff::Error::transient)?;
209
210        found.ok_or_else(|| {
211            backoff::Error::transient(FaucetError::Wallet(
212                "No address found with sufficient coins".to_string(),
213            ))
214        })
215    })
216    .await
217}
218
219/// Scans the wallet's addresses once for a gas coin with a balance of at least `config.amount`,
220/// returning the matching coins and the address that holds them, or `Ok(None)` if no such coin is
221/// currently visible.
222async fn scan_for_gas_coins(
223    wallet: &WalletContext,
224    active_address: SuiAddress,
225    config: &FaucetConfig,
226) -> Result<Option<(Vec<GasCoin>, SuiAddress)>, FaucetError> {
227    for address in std::iter::once(active_address).chain(wallet.get_addresses()) {
228        let coins: Vec<_> = wallet
229            .gas_objects(address)
230            .await
231            .map_err(|e| FaucetError::Wallet(e.to_string()))?
232            .iter()
233            .filter_map(|(balance, obj)| {
234                if *balance >= config.amount {
235                    GasCoin::try_from(obj).ok()
236                } else {
237                    None
238                }
239            })
240            .collect();
241
242        if !coins.is_empty() {
243            return Ok(Some((coins, address)));
244        }
245    }
246
247    Ok(None)
248}
249
250#[cfg(test)]
251mod tests {
252
253    use super::*;
254    use test_cluster::TestClusterBuilder;
255
256    #[tokio::test]
257    async fn test_local_faucet_execute_txn() {
258        // Setup test cluster
259        let cluster = TestClusterBuilder::new().build().await;
260        let client = cluster.grpc_client();
261
262        let config = FaucetConfig::default();
263        let local_faucet = LocalFaucet::new(cluster.wallet, config).await.unwrap();
264
265        // Test execute_txn
266        let recipient = SuiAddress::random_for_testing_only();
267        let tx = local_faucet.local_request_execute_tx(recipient).await;
268
269        assert!(tx.is_ok());
270
271        let coins = client
272            .get_owned_objects(recipient, None, None, None)
273            .await
274            .unwrap();
275
276        assert_eq!(coins.items.len(), local_faucet.num_coins);
277
278        let tx = local_faucet.local_request_execute_tx(recipient).await;
279        assert!(tx.is_ok());
280        let coins = client
281            .get_owned_objects(recipient, None, None, None)
282            .await
283            .unwrap();
284
285        assert_eq!(coins.items.len(), 2 * local_faucet.num_coins);
286    }
287
288    #[tokio::test]
289    async fn test_find_gas_coins_and_address() {
290        let mut cluster = TestClusterBuilder::new().build().await;
291        let wallet = cluster.wallet_mut();
292        let config = FaucetConfig::default();
293
294        // Test find_gas_coins_and_address
295        let result = find_gas_coins_and_address(wallet, &config).await;
296        assert!(result.is_ok());
297
298        let (coins, _) = result.unwrap();
299        assert!(!coins.is_empty());
300        assert!(coins.iter().map(|c| c.value()).sum::<u64>() >= config.amount);
301    }
302}