Skip to main content

shadow_drive/
lib.rs

1use std::path::Path;
2use std::str::FromStr;
3use std::sync::Arc;
4
5use pyo3::exceptions::{PyRuntimeError, PyValueError};
6use pyo3::prelude::*;
7use pyo3::{
8    pymodule,
9    types::{PyModule, PyString},
10    PyResult, Python,
11};
12use shadow_drive_sdk::constants::SHDW_DRIVE_OBJECT_PREFIX;
13use shadow_drive_sdk::models::{ShadowFile, ShadowUploadResponse};
14use shadow_drive_sdk::{
15    read_keypair_file, Byte, CommitmentConfig, Keypair, Pubkey, RpcClient,
16    ShadowDriveClient as ShadowDriveRustClient, Signer,
17};
18use tokio::runtime::{Builder, Runtime};
19
20/// A Python module implemented in Rust.
21#[pymodule]
22fn shadow_drive(_py: Python, m: &PyModule) -> PyResult<()> {
23    // Add Solana Mainnet-Beta RPC endpoint
24    const SOLANA_MAINNET_BETA: &'static str = "https://api.mainnet-beta.solana.com";
25    m.add("SOLANA_MAINNET_BETA", SOLANA_MAINNET_BETA)?;
26
27    #[pyclass]
28    pub struct ShadowDriveClient {
29        rust_client: Arc<ShadowDriveRustClient<Keypair>>,
30        runtime: Runtime,
31        current_account: Option<Pubkey>,
32    }
33
34    #[pymethods]
35    impl ShadowDriveClient {
36        /// new(keypair, account/)
37        /// --
38        ///
39        /// ShadowDriveClient constructor. By default, this uses confirmed commitment and the Solana Labs
40        /// public RPC endpoint. To use a custom commitment level, use the new_with_commitment method. To
41        /// use a custom RPC endpoint, use the new_with_rpc method. Or, to specify both, use the method
42        /// new_with_commitment_and_rpc.
43        #[new]
44        fn new(keypair: &str, account: Option<&str>) -> PyResult<ShadowDriveClient> {
45            let keypair: Keypair = read_keypair_file(keypair)
46                .map_err(|e| PyRuntimeError::new_err(format!("Failed to read keypair file {e}")))?;
47
48            let rust_client = Arc::new(ShadowDriveRustClient::new(
49                keypair,
50                SOLANA_MAINNET_BETA.to_string(),
51            ));
52            let runtime = Builder::new_multi_thread()
53                .worker_threads(2)
54                .enable_time()
55                .enable_io()
56                .build()
57                .unwrap();
58            let current_account: Option<Pubkey> =
59                check_current_account(account, &rust_client, &runtime);
60
61            Ok(ShadowDriveClient {
62                rust_client,
63                runtime,
64                current_account,
65            })
66        }
67
68        /// new_with_commitment(keypair, commitment, /)
69        /// --
70        ///
71        /// ShadowDriveClient constructor. Specify one of 'processed', 'confirmed', or 'finalized'
72        /// for the commitment level.
73        fn new_with_commitment(
74            keypair: Py<PyAny>,
75            commitment: &str,
76            account: Option<&str>,
77        ) -> PyResult<ShadowDriveClient> {
78            // Extract commitment
79            let commitment_config = extract_commitment(commitment)?;
80
81            let keypair: Keypair = read_keypair_file(keypair.to_string())
82                .map_err(|e| PyRuntimeError::new_err(format!("Failed to read keypair file {e}")))?;
83
84            let rust_client = Arc::new(ShadowDriveRustClient::new_with_rpc(
85                keypair,
86                RpcClient::new_with_commitment(SOLANA_MAINNET_BETA.to_string(), commitment_config),
87            ));
88
89            let runtime = Builder::new_multi_thread()
90                .worker_threads(2)
91                .enable_time()
92                .enable_io()
93                .build()
94                .unwrap();
95            let current_account: Option<Pubkey> =
96                check_current_account(account, &rust_client, &runtime);
97
98            Ok(ShadowDriveClient {
99                rust_client,
100                runtime,
101                current_account,
102            })
103        }
104
105        /// new_with_rpc(keypair, rpc, /)
106        /// --
107        ///
108        /// ShadowDriveClient constructor. Specify a custom RPC endpoint. Uses finalized commitment level.
109        fn new_with_rpc(
110            keypair: Py<PyAny>,
111            rpc: &str,
112            account: Option<&str>,
113        ) -> PyResult<ShadowDriveClient> {
114            let keypair: Keypair = read_keypair_file(keypair.to_string())
115                .map_err(|e| PyRuntimeError::new_err(format!("Failed to read keypair file {e}")))?;
116
117            let rust_client = Arc::new(ShadowDriveRustClient::new_with_rpc(
118                keypair,
119                RpcClient::new_with_commitment(rpc.to_string(), CommitmentConfig::finalized()),
120            ));
121
122            let runtime = Builder::new_multi_thread()
123                .worker_threads(2)
124                .enable_time()
125                .enable_io()
126                .build()
127                .unwrap();
128            let current_account: Option<Pubkey> =
129                check_current_account(account, &rust_client, &runtime);
130
131            Ok(ShadowDriveClient {
132                rust_client,
133                runtime,
134                current_account,
135            })
136        }
137
138        /// new_with_commitment_and_rpc(keypair, commitment, rpc, /)
139        /// --
140        ///
141        /// ShadowDriveClient constructor. Specify one of 'processed', 'confirmed', or 'finalized'
142        /// for the commitment level, and a custom RPC enpdoint.
143        fn new_with_commitment_and_rpc(
144            keypair: Py<PyAny>,
145            commitment: &str,
146            rpc: &str,
147            account: Option<&str>,
148            py: Python,
149        ) -> PyResult<ShadowDriveClient> {
150            // Extract commitment
151            let commitment_config = extract_commitment(commitment)?;
152
153            let keypair: Keypair = read_keypair_file(keypair.to_string())
154                .map_err(|e| PyRuntimeError::new_err(format!("Failed to read keypair file {e}")))?;
155
156            let rust_client = Arc::new(ShadowDriveRustClient::new_with_rpc(
157                keypair,
158                RpcClient::new_with_commitment(rpc.to_string(), commitment_config),
159            ));
160
161            let runtime = Builder::new_multi_thread()
162                .worker_threads(2)
163                .enable_time()
164                .enable_io()
165                .build()
166                .unwrap();
167            let current_account: Option<Pubkey> =
168                check_current_account(account, &rust_client, &runtime);
169
170            Ok(ShadowDriveClient {
171                rust_client,
172                runtime,
173                current_account,
174            })
175        }
176
177        /// create_account(name, size, rpc, /)
178        /// --
179        ///
180        /// Create a Shadow Drive storage account with the specified name and number of bytes.
181        fn create_account(
182            &mut self,
183            name: &str,
184            size: u64,
185            use_account: Option<bool>,
186            py: Python,
187        ) -> PyResult<(Py<PyString>, Py<PyString>)> {
188            let result: PyResult<(Py<PyString>, Py<PyString>)> = self.runtime.block_on(async {
189                self.rust_client
190                    .create_storage_account(
191                        name,
192                        Byte::from(size as u128),
193                        shadow_drive_sdk::StorageAccountVersion::V2,
194                    )
195                    .await
196                    .map(|response| {
197                        (
198                            PyString::new(py, &response.shdw_bucket.unwrap()).into(),
199                            PyString::new(py, &response.transaction_signature).into(),
200                        )
201                    })
202                    .map_err(|err| {
203                        PyValueError::new_err(format!("failed to create storage account {err:?}"))
204                            .into()
205                    })
206            });
207            if let Ok((ref bucket, _)) = result {
208                if let Some(true) = use_account {
209                    self.current_account = Some(
210                        Pubkey::from_str(&bucket.to_string())
211                            .expect("sucessful storage account creation"),
212                    );
213                }
214            }
215            result
216        }
217
218        /// delete_account(name, size, rpc, /)
219        /// --
220        ///
221        /// Delete a Shadow Drive storage account with the specified name and number of bytes.
222        fn delete_account(&self, name: &str) -> PyResult<()> {
223            self.runtime
224                .block_on(self.rust_client.delete_storage_account(&try_pubkey(name)?))
225                .map(|response| {
226                    println!("deleted storage account {name}: {}", response.txid);
227                })
228                .map_err(|err| {
229                    PyValueError::new_err(format!("failed to delete storage account {err:?}"))
230                        .into()
231                })
232        }
233
234        /// add_storage(amount, /)
235        /// --
236        ///
237        /// Add bytes to the current storage account (set by set_account, or when creating with use_account=True)
238        fn add_storage(&self, amount: u64) -> PyResult<()> {
239            self.runtime.block_on(async {
240                // Check if account is immutable
241                if let Some(ref account) = self.current_account {
242                    let storage_account = self
243                        .rust_client
244                        .get_storage_account(account)
245                        .await
246                        .map_err(|e| {
247                            PyRuntimeError::new_err(format!(
248                                "unable to retrieve storage account {e:?}"
249                            ))
250                        })?;
251
252                    if storage_account.is_immutable() {
253                        self.rust_client
254                            .add_immutable_storage(account, Byte::from(amount))
255                            .await
256                            .map(|response| {
257                                println!("AddImmutableStorage Response: {}", response.message);
258                            })
259                            .map_err(|err| {
260                                PyValueError::new_err(format!("failed to add storage {err:?}"))
261                                    .into()
262                            })
263                    } else {
264                        self.rust_client
265                            .add_storage(account, Byte::from(amount))
266                            .await
267                            .map(|response| {
268                                println!("AddStorage Response: {}", response.message);
269                            })
270                            .map_err(|err| {
271                                PyValueError::new_err(format!("failed to add storage {err:?}"))
272                                    .into()
273                            })
274                    }
275                } else {
276                    Err(PyRuntimeError::new_err(
277                        "no storage account is set. Set one using the set_account(...) method",
278                    ))
279                }
280            })
281        }
282
283        /// reduce_storage(amount, /)
284        /// --
285        ///
286        /// Reduce bytes of the current storage account (set by set_account, or when creating with use_account=True)
287        fn reduce_storage(&self, amount: u64) -> PyResult<()> {
288            self.runtime.block_on(async {
289                // Check if account is immutable
290                if let Some(ref account) = self.current_account {
291                    let storage_account = self
292                        .rust_client
293                        .get_storage_account(account)
294                        .await
295                        .map_err(|e| {
296                            PyRuntimeError::new_err(format!(
297                                "unable to retrieve storage account {e:?}"
298                            ))
299                        })?;
300
301                    let is_immutable: bool = storage_account.is_immutable();
302                    let total_storage = storage_account.storage();
303                    if total_storage < amount {
304                        return Err(PyRuntimeError::new_err(format!("Account only has {total_storage} bytes, but you attempted to reduce by {amount}")));
305                    }
306
307                    if is_immutable {
308                        Err(PyRuntimeError::new_err(
309                            "Account selected is immutable. Cannot remove storage",
310                        ))
311                    } else {
312                        self.rust_client
313                            .reduce_storage(account, Byte::from(amount))
314                            .await
315                            .map(|response| {
316                                println!("RemoveStorage Response: {}", response.message);
317                            })
318                            .map_err(|err| {
319                                PyValueError::new_err(format!("failed to add storage {err:?}"))
320                                    .into()
321                            })
322                    }
323                } else {
324                    Err(PyRuntimeError::new_err(
325                        "no storage account is set. Set one using the set_account(...) method",
326                    ))
327                }
328            })
329        }
330
331        /// upload_files(files, /)
332        /// --
333        ///
334        /// Upload the specified files. Note that any non-Unicode characters in the file name are
335        /// converted to the U+FFFD REPLACEMENT CHARACTER.
336        fn upload_files(&self, files: Vec<&str>, py: Python) -> PyResult<Vec<Py<PyString>>> {
337            if let Some(ref storage_account) = self.current_account {
338                // Turn files provided into ShadowFiles
339                let files: Vec<ShadowFile> = files
340                    .into_iter()
341                    .map(|file| {
342                        let path: &Path = Path::new(file);
343                        if let Some(name) = path
344                            .file_name()
345                            .map(|name| name.to_string_lossy().to_string())
346                        {
347                            Ok(ShadowFile::file(name, path))
348                        } else {
349                            Err(PyValueError::new_err(format!(
350                                "an invalid file path was provided: {}",
351                                path.display()
352                            )))
353                        }
354                    })
355                    .collect::<PyResult<Vec<ShadowFile>>>()?;
356
357                // Upload files
358                let response: ShadowUploadResponse = self
359                    .runtime
360                    .block_on(self.rust_client.store_files(storage_account, files))
361                    .map_err(|err| {
362                        PyValueError::new_err(format!("failed to upload files: {err:?}"))
363                    })?;
364
365                // Alert the user of any errors
366                for error in &response.upload_errors {
367                    println!("failed to upload file {}: {}", &error.file, &error.error);
368                }
369
370                // Return successful uploads
371                let successes = response
372                    .finalized_locations
373                    .iter()
374                    .map(|loc| PyString::new(py, loc).into())
375                    .collect();
376                Ok(successes)
377            } else {
378                Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, or specify one with set_account"))
379            }
380        }
381
382        /// delete_files(file_urls, /)
383        /// --
384        ///
385        /// Delete the specified files (that live at the specified urls) in the current_storage account.
386        fn delete_files(&self, file_urls: Vec<String>) -> PyResult<()> {
387            if let Some(ref storage_account) = self.current_account {
388                self.runtime.block_on(async move {
389                    tokio_scoped::scope(|scope| {
390                        for url in file_urls {
391                            scope.spawn(async move {
392                                if let Err(err) = self
393                                    .rust_client
394                                    .delete_file(&storage_account, url.clone())
395                                    .await
396                                {
397                                    println!("failed to delete file {url}: {err:?}");
398                                }
399                            });
400                        }
401                    });
402                });
403                Ok(())
404            } else {
405                Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, or specify one with set_account"))
406            }
407        }
408
409        /// list_files(/)
410        /// --
411        ///
412        /// List all files associated with the current storage account (if account=None). If an account is provided, those files are checked instead.
413        fn list_files(&self, account: Option<&str>) -> PyResult<Vec<String>> {
414            let account_to_check = self
415                .current_account
416                .map(Result::Ok)
417                .or(account.map(Pubkey::from_str));
418
419            if let Some(Ok(ref storage_account)) = account_to_check {
420                self.runtime.block_on(async move {
421                    self.rust_client
422                        .list_objects(storage_account)
423                        .await
424                        .map_err(|e| {
425                            PyRuntimeError::new_err(format!(
426                                "failed to gather files for storage account: {e:?}"
427                            ))
428                        })
429                })
430            } else {
431                Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, specify one with set_account, or pass in the 'account' optional arugment"))
432            }
433        }
434
435        /// get_file(/)
436        /// --
437        ///
438        /// Retrieve the specified file if it exists in the storage account. Can also provide a url to a file (need not be in the current storage account).
439        fn get_file(&self, file: &str) -> PyResult<Vec<u8>> {
440            let url = if file.contains(SHDW_DRIVE_OBJECT_PREFIX) {
441                file.to_string()
442            } else {
443                if let Some(ref storage_account) = self.current_account {
444                    format!("{SHDW_DRIVE_OBJECT_PREFIX}")
445                } else {
446                    return Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, specify one with set_account, or pass in the 'account' optional arugment"));
447                }
448            };
449            self.runtime.block_on(async move {
450                reqwest::get(url)
451                    .await
452                    .map(|response| response.bytes())
453                    .map_err(|e| PyRuntimeError::new_err(format!("failed to retrieve file {e:?}")))?
454                    .await
455                    .map(|bytes| bytes.to_vec())
456                    .map_err(|e| PyRuntimeError::new_err(format!("failed to retrieve file {e:?}")))
457            })
458        }
459
460        /// cancel_delete_storage(/)
461        /// --
462        ///
463        /// If a storage account was previously requested to be deleted, this sends a request to cancel that deletion request.
464        /// Sends the request for another account if it is provided. If successful, returns transaction id
465        fn cancel_delete_account(&self, account: Option<&str>) -> PyResult<String> {
466            let account_to_check = self
467                .current_account
468                .map(Result::Ok)
469                .or(account.map(Pubkey::from_str));
470
471            if let Some(Ok(ref storage_account)) = account_to_check {
472                self.runtime.block_on(async move {
473                    self.rust_client
474                        .cancel_delete_storage_account(storage_account)
475                        .await
476                        .map(|response| {
477                            println!("CancelDeleteAccount Response: {}", response.txid);
478                            response.txid
479                        })
480                        .map_err(|err| {
481                            PyValueError::new_err(format!("failed to add storage {err:?}")).into()
482                        })
483                })
484            } else {
485                Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, specify one with set_account, or pass in the 'account' optional arugment"))
486            }
487        }
488
489        /// make_account_immutable(skip_warning/)
490        /// --
491        ///
492        /// Makes account immutable. NOTE: THIS IS IRREVERSIBLE!
493        fn make_account_immutable(&self, skip_warning: Option<bool>) -> PyResult<()> {
494            if let Some(ref storage_account) = self.current_account {
495                // Warn user if they are not skipping
496                if skip_warning != Some(true) {
497                    println!("You are about to make {storage_account} immutable. This is a permanent, irreversible action. Proceed? [y/n]");
498                    let mut user_input = String::new();
499                    let _ = std::io::stdin().read_line(&mut user_input);
500
501                    if !["yes", "y"].contains(&user_input.to_lowercase().as_ref()) {
502                        println!("Did not mark account as immutable");
503                        return Ok(());
504                    }
505                }
506
507                self.runtime.block_on(async move {
508                    self.rust_client
509                        .make_storage_immutable(storage_account)
510                        .await
511                        .map(|response| {
512                            println!("RemoveStorage Response: {}", response.message);
513                        })
514                        .map_err(|e| {
515                            PyRuntimeError::new_err(format!(
516                                "failed to mark account as immutable: {e:?}"
517                            ))
518                        })
519                })
520            } else {
521                Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, specify one with set_account, or pass in the 'account' optional arugment"))
522            }
523        }
524
525        /// claim_stake(skip_warning/)
526        /// --
527        ///
528        /// Claims outstanding for current account (or provided optional account). Returns transaction signature if successful.
529        fn claim_stake(&self, account: Option<&str>) -> PyResult<String> {
530            let account_to_check = self
531                .current_account
532                .map(Result::Ok)
533                .or(account.map(Pubkey::from_str));
534
535            if let Some(Ok(ref storage_account)) = account_to_check {
536                self.runtime.block_on(async move {
537                    self.rust_client
538                        .claim_stake(storage_account)
539                        .await
540                        .map(|response| {
541                            println!("ClaimStake Response: {}", response.txid);
542                            response.txid
543                        })
544                        .map_err(|e| {
545                            PyRuntimeError::new_err(format!(
546                                "failed to claim stake for storage account: {e:?}"
547                            ))
548                        })
549                })
550            } else {
551                Err(PyRuntimeError::new_err("No storage account is specified. Create one with create_account, specify one with set_account, or pass in the 'account' optional arugment"))
552            }
553        }
554
555        /// set_account(account, /)
556        /// --
557        ///
558        /// Specify an existing storage account to manage or use.
559        fn set_account(&mut self, account: &str) -> PyResult<()> {
560            self.current_account = Some(Pubkey::from_str(account).map_err(|err| {
561                PyValueError::new_err(format!(
562                    "an invalid Pubkey {} was provided: {}",
563                    account, err
564                ))
565            })?);
566
567            Ok(())
568        }
569    }
570
571    m.add_class::<ShadowDriveClient>()?;
572
573    Ok(())
574}
575
576fn extract_commitment(commitment: &str) -> PyResult<CommitmentConfig> {
577    match commitment.to_lowercase().as_ref() {
578        "processed" => Ok(CommitmentConfig::processed()),
579        "confirmed" => Ok(CommitmentConfig::confirmed()),
580        "finalized" => Ok(CommitmentConfig::finalized()),
581        _ => Err(PyValueError::new_err(
582            "the only acceptable commitment values are 'processed', 'confirmed', and 'finalized'.",
583        )),
584    }
585}
586
587fn check_current_account(
588    account: Option<&str>,
589    rust_client: &ShadowDriveRustClient<Keypair>,
590    runtime: &Runtime,
591) -> Option<Pubkey> {
592    if let Some(acct) = account {
593        match Pubkey::from_str(&acct) {
594            // if we got a valid pubkey, check that it is a valid storage account
595            Ok(key) => runtime
596                .block_on(rust_client.get_storage_account(&key))
597                .map(|_| key)
598                .map_err(|err| {
599                    println!("invalid account pubkey provided: {err:?}");
600                })
601                .ok(),
602
603            // Otherwise, return None
604            Err(err) => {
605                println!("invalid account pubkey provided: {err:?}");
606                None
607            }
608        }
609    } else {
610        None
611    }
612}
613
614fn try_pubkey(key: &str) -> PyResult<Pubkey> {
615    Pubkey::from_str(key).map_err(|err| {
616        PyValueError::new_err(format!("an invalid Pubkey {} was provided: {}", key, err))
617    })
618}