sugar_cli/upload/methods/
bundlr.rs

1use std::{cmp, fs, path::Path, sync::Arc};
2
3use anchor_client::solana_sdk::native_token::LAMPORTS_PER_MLN;
4use async_trait::async_trait;
5use bundlr_sdk::{tags::Tag, Bundlr, Ed25519Signer as SolanaSigner};
6use clap::crate_version;
7use console::style;
8use miraland_client::rpc_client::RpcClient;
9use tokio::{
10    task::JoinHandle,
11    time::{sleep, Duration},
12};
13
14use crate::{
15    candy_machine::CANDY_MACHINE_ID,
16    common::*,
17    config::*,
18    upload::{
19        assets::{get_updated_metadata, AssetPair, DataType},
20        uploader::{AssetInfo, ParallelUploader, Prepare, MOCK_URI_SIZE},
21    },
22    utils::*,
23};
24
25/// The number os retries to fetch the Bundlr balance (MAX_RETRY * DELAY_UNTIL_RETRY ms limit)
26const MAX_RETRY: u64 = 120;
27
28/// Time (ms) to wait until next try
29const DELAY_UNTIL_RETRY: u64 = 1000;
30
31/// Size of Bundlr transaction header
32const HEADER_SIZE: u64 = 2_000;
33
34/// Minimum file size for cost calculation
35const MINIMUM_SIZE: u64 = 80_000;
36
37pub struct BundlrMethod {
38    pub client: Arc<Bundlr<SolanaSigner>>,
39    pub sugar_tag: Tag,
40    pubkey: Pubkey,
41    node: String,
42}
43
44impl BundlrMethod {
45    pub async fn new(sugar_config: &SugarConfig, _config_data: &ConfigData) -> Result<Self> {
46        let client = setup_client(sugar_config)?;
47        let program = client.program(CANDY_MACHINE_ID);
48        let miraland_cluster: Cluster = get_cluster(program.rpc())?;
49
50        let bundlr_node = match miraland_cluster {
51            Cluster::Devnet => BUNDLR_DEVNET,
52            Cluster::Mainnet => BUNDLR_MAINNET,
53            Cluster::Unknown | Cluster::Localnet => {
54                return Err(anyhow!("Bundlr is only supported on devnet or mainnet"));
55            }
56        };
57
58        let http_client = reqwest::Client::new();
59        let bundlr_address =
60            BundlrMethod::get_bundlr_solana_address(&http_client, bundlr_node).await?;
61
62        let bundlr_pubkey = Pubkey::from_str(&bundlr_address)?;
63        // get keypair as base58 string for Bundlr
64        let keypair = bs58::encode(sugar_config.keypair.to_bytes()).into_string();
65        let signer = SolanaSigner::from_base58(&keypair);
66
67        let bundlr_client = Bundlr::new(
68            bundlr_node.to_string(),
69            "solana".to_string(),
70            "sol".to_string(),
71            signer,
72        );
73
74        let sugar_tag = Tag::new("App-Name".into(), format!("Sugar {}", crate_version!()));
75
76        Ok(Self {
77            client: Arc::new(bundlr_client),
78            pubkey: bundlr_pubkey,
79            sugar_tag,
80            node: bundlr_node.to_string(),
81        })
82    }
83
84    /// Return the solana address for Bundlr.
85    async fn get_bundlr_solana_address(http_client: &HttpClient, node: &str) -> Result<String> {
86        let url = format!("{}/info", node);
87        let data = http_client.get(&url).send().await?.json::<Value>().await?;
88        let addresses = data
89            .get("addresses")
90            .expect("Failed to get bundlr addresses.");
91
92        let solana_address = addresses
93            .get("solana")
94            .expect("Failed to get Miraland address from bundlr.")
95            .as_str()
96            .expect("Miraland bundlr address is not of type string.")
97            .to_string();
98        Ok(solana_address)
99    }
100
101    /// Add fund to the Bundlr address.
102    async fn fund_bundlr_address(
103        rpc_client: RpcClient,
104        http_client: &HttpClient,
105        bundlr_address: &Pubkey,
106        node: &str,
107        payer: &Keypair,
108        amount: u64,
109    ) -> Result<Response> {
110        let ix = system_instruction::transfer(&payer.pubkey(), bundlr_address, amount);
111        let recent_blockhash = rpc_client.get_latest_blockhash()?;
112        let payer_pubkey = payer.pubkey();
113
114        let tx = Transaction::new_signed_with_payer(
115            &[ix],
116            Some(&payer_pubkey),
117            &[payer],
118            recent_blockhash,
119        );
120
121        println!("Funding address:");
122        println!("  -> pubkey: {}", payer_pubkey);
123        println!(
124            "  -> lamports: {} (◎ {})",
125            amount,
126            amount as f64 / LAMPORTS_PER_MLN as f64
127        );
128
129        let sig = rpc_client.send_and_confirm_transaction_with_spinner_and_commitment(
130            &tx,
131            CommitmentConfig::confirmed(),
132        )?;
133
134        println!("{} {sig}", style("Signature:").bold());
135
136        let mut map = HashMap::new();
137        map.insert("tx_id", sig.to_string());
138        let url = format!("{}/account/balance/solana", node);
139        let response = http_client.post(&url).json(&map).send().await?;
140
141        Ok(response)
142    }
143
144    /// Return the Bundlr balance.
145    pub async fn get_bundlr_balance(
146        http_client: &HttpClient,
147        address: &str,
148        node: &str,
149    ) -> Result<u64> {
150        debug!("Getting balance for address: {address}");
151        let url = format!("{}/account/balance/solana/?address={}", node, address);
152        let response = http_client.get(&url).send().await?.json::<Value>().await?;
153        let value = response
154            .get("balance")
155            .expect("Failed to get balance from bundlr.");
156
157        Ok(value
158            .as_str()
159            .unwrap()
160            .parse::<u64>()
161            .expect("Failed to parse bundlr balance."))
162    }
163
164    /// Return the Bundlr fee for upload based on the data size.
165    async fn get_bundlr_fee(http_client: &HttpClient, node: &str, data_size: u64) -> Result<u64> {
166        let required_amount = http_client
167            .get(format!("{node}/price/solana/{data_size}"))
168            .send()
169            .await?
170            .text()
171            .await?
172            .parse::<u64>()?;
173        Ok(required_amount)
174    }
175
176    async fn send(
177        client: Arc<Bundlr<SolanaSigner>>,
178        tag: Tag,
179        asset_info: AssetInfo,
180    ) -> Result<(String, String)> {
181        let data = match asset_info.data_type {
182            DataType::Image => fs::read(&asset_info.content)?,
183            DataType::Metadata => asset_info.content.into_bytes(),
184            DataType::Animation => fs::read(&asset_info.content)?,
185        };
186
187        let tags = vec![
188            tag,
189            Tag::new("Content-Type".into(), asset_info.content_type.clone()),
190        ];
191
192        let tx = client.create_transaction_with_tags(data, tags);
193        let response = client.send_transaction(tx).await?;
194        let id = response
195            .get("id")
196            .expect("Failed to convert transaction id to string.")
197            .as_str()
198            .expect("Failed to get an id from bundlr transaction.");
199
200        // Get extension for the asset type.
201        let ext = asset_info
202            .content_type
203            .split('/')
204            .nth(1)
205            .ok_or_else(|| anyhow!("Failed context type to get extension"))?;
206
207        let link = match asset_info.data_type {
208            DataType::Image | DataType::Animation => format!("https://arweave.net/{id}?ext={ext}"),
209            DataType::Metadata => format!("https://arweave.net/{id}"),
210        };
211
212        Ok((asset_info.asset_id, link))
213    }
214}
215
216#[async_trait]
217impl Prepare for BundlrMethod {
218    async fn prepare(
219        &self,
220        sugar_config: &SugarConfig,
221        assets: &HashMap<isize, AssetPair>,
222        asset_indices: Vec<(DataType, &[isize])>,
223    ) -> Result<()> {
224        // calculates the size of the files to upload
225        let mut total_size = 0;
226
227        for (data_type, indices) in asset_indices {
228            match data_type {
229                DataType::Image => {
230                    for index in indices {
231                        let item = assets.get(index).unwrap();
232                        let path = Path::new(&item.image);
233                        total_size +=
234                            HEADER_SIZE + cmp::max(MINIMUM_SIZE, fs::metadata(path)?.len());
235                    }
236                }
237                DataType::Animation => {
238                    for index in indices {
239                        let item = assets.get(index).unwrap();
240
241                        if let Some(animation) = &item.animation {
242                            let path = Path::new(animation);
243                            total_size +=
244                                HEADER_SIZE + cmp::max(MINIMUM_SIZE, fs::metadata(path)?.len());
245                        }
246                    }
247                }
248                DataType::Metadata => {
249                    let mock_uri = "x".repeat(MOCK_URI_SIZE);
250
251                    for index in indices {
252                        let item = assets.get(index).unwrap();
253                        let animation = if item.animation.is_some() {
254                            Some(mock_uri.clone())
255                        } else {
256                            None
257                        };
258
259                        total_size += HEADER_SIZE
260                            + cmp::max(
261                                MINIMUM_SIZE,
262                                get_updated_metadata(&item.metadata, &mock_uri.clone(), &animation)?
263                                    .into_bytes()
264                                    .len() as u64,
265                            );
266                    }
267                }
268            }
269        }
270
271        info!("Total upload size: {}", total_size);
272
273        let http_client = reqwest::Client::new();
274
275        let lamports_fee =
276            BundlrMethod::get_bundlr_fee(&http_client, &self.node, total_size).await?;
277        let address = sugar_config.keypair.pubkey().to_string();
278        let mut balance =
279            BundlrMethod::get_bundlr_balance(&http_client, &address, &self.node).await?;
280
281        info!(
282            "Bundlr balance {} lamports, require {} lamports",
283            balance, lamports_fee
284        );
285
286        // funds the bundlr wallet for media upload
287
288        let rpc_client = {
289            let client = setup_client(sugar_config)?;
290            let program = client.program(CANDY_MACHINE_ID);
291            program.rpc()
292        };
293
294        if lamports_fee > balance {
295            // calculates the additional amount to fund the wallet, with padding.
296            let amount = ((lamports_fee - balance) as f64 * 1.3).ceil() as u64;
297
298            BundlrMethod::fund_bundlr_address(
299                rpc_client,
300                &http_client,
301                &self.pubkey,
302                &self.node,
303                &sugar_config.keypair,
304                amount,
305            )
306            .await?;
307
308            let pb = ProgressBar::new(MAX_RETRY);
309            pb.set_style(ProgressStyle::default_bar().template("{spinner} {msg} {wide_bar}"));
310            pb.enable_steady_tick(60);
311            pb.set_message("Verifying balance:");
312
313            // waits until the balance can be verified, otherwise the upload
314            // will fail
315            for _i in 0..MAX_RETRY {
316                let res =
317                    BundlrMethod::get_bundlr_balance(&http_client, &address, &self.node).await;
318
319                if let Ok(value) = res {
320                    balance = value;
321                }
322
323                if balance >= lamports_fee {
324                    break;
325                }
326
327                sleep(Duration::from_millis(DELAY_UNTIL_RETRY)).await;
328                pb.inc(1);
329            }
330
331            pb.finish_and_clear();
332
333            if balance < lamports_fee {
334                let error = anyhow!(format!(
335                    "No Bundlr balance found for address: {0}, check \
336                    Bundlr cluster and address balance",
337                    address
338                ));
339                error!("{error}");
340                return Err(error);
341            }
342        }
343
344        Ok(())
345    }
346}
347
348#[async_trait]
349impl ParallelUploader for BundlrMethod {
350    fn upload_asset(&self, asset_info: AssetInfo) -> JoinHandle<Result<(String, String)>> {
351        let client = self.client.clone();
352        let tag = self.sugar_tag.clone();
353        tokio::spawn(async move { BundlrMethod::send(client, tag, asset_info).await })
354    }
355}