sugar_cli/upload/methods/
bundlr.rs1use 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
25const MAX_RETRY: u64 = 120;
27
28const DELAY_UNTIL_RETRY: u64 = 1000;
30
31const HEADER_SIZE: u64 = 2_000;
33
34const 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 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 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 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 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 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 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 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 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 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 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}