1use 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
32const 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
46impl 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 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
183async 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
219async 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 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 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 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}