soroban_rs/
contract.rs

1//! # Soroban Contract Management
2//!
3//! This module provides functionality for interacting with Soroban Smart Contracts,
4//! including deployment and function invocation.
5//!
6//! ## Features
7//!
8//! - Loading contract WASM bytecode from file
9//! - Deploying contracts to the Soroban network
10//! - Invoking contract functions with arguments
11//! - Managing contract identifiers
12//!
13//! ## Example
14//!
15//! ```rust,no_run
16//! use soroban_rs::{Account, Contract, Env, EnvConfigs, Signer};
17//! use stellar_xdr::curr::ScVal;
18//! use ed25519_dalek::SigningKey;
19//!
20//! async fn deploy_and_invoke() {
21//!     // Setup environment and account
22//!     let env = Env::new(EnvConfigs {
23//!         rpc_url: "https://soroban-testnet.stellar.org".to_string(),
24//!         network_passphrase: "Test SDF Network ; September 2015".to_string(),
25//!     }).unwrap();
26//!
27//!     let private_key_bytes: [u8; 32] = [
28//!         1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
29//!         26, 27, 28, 29, 30, 31, 32,
30//!     ];
31//!     let signing_key = SigningKey::from_bytes(&private_key_bytes);
32//!     let mut account = Account::single(Signer::new(signing_key));
33//!
34//!     // Load and deploy contract
35//!     let contract = Contract::new("path/to/contract.wasm", None).unwrap();
36//!     let mut deployed = contract.deploy(&env, &mut account, None).await.unwrap();
37//!
38//!     // Invoke contract function
39//!     let args = vec![/* function arguments as ScVal */];
40//!     let result = deployed.invoke("function_name", args).await.unwrap();
41//! }
42//! ```
43use crate::{
44    Account, Env, ParseResult, Parser, ParserType, crypto,
45    error::SorobanHelperError,
46    fs::{DefaultFileReader, FileReader},
47    operation::Operations,
48    transaction::TransactionBuilder,
49};
50use stellar_strkey::Contract as ContractId;
51use stellar_xdr::curr::{
52    ContractIdPreimage, ContractIdPreimageFromAddress, Hash, ScAddress, ScVal,
53};
54
55/// Name of the constructor function
56const CONSTRUCTOR_FUNCTION_NAME: &str = "__constructor";
57
58/// Configuration for client interaction with a deployed contract
59///
60/// Contains all necessary information to interact with a deployed contract,
61/// including the contract identifier, environment, and signing account.
62#[derive(Clone)]
63pub struct ClientContractConfigs {
64    /// The deployed contract's identifier
65    pub contract_id: ContractId,
66    /// The environment for interacting with the network
67    pub env: Env,
68    /// The account used for signing transactions
69    pub account: Account,
70}
71
72/// Represents a Soroban smart contract
73///
74/// Provides functionality to deploy and interact with Soroban smart contracts.
75/// A Contract instance can represent either an undeployed contract (with just WASM bytecode)
76/// or a deployed contract (with client configuration for interacting with it).
77pub struct Contract {
78    /// Raw WASM bytecode of the contract
79    wasm_bytes: Vec<u8>,
80    /// SHA-256 hash of the WASM bytecode
81    wasm_hash: Hash,
82    /// Optional configuration for interacting with a deployed instance of this contract
83    client_configs: Option<ClientContractConfigs>,
84}
85
86impl Clone for Contract {
87    fn clone(&self) -> Self {
88        Self {
89            wasm_bytes: self.wasm_bytes.clone(),
90            wasm_hash: self.wasm_hash.clone(),
91            client_configs: self.client_configs.clone(),
92        }
93    }
94}
95
96impl Contract {
97    /// Creates a new Contract instance from a WASM file path
98    ///
99    /// # Parameters
100    ///
101    /// * `wasm_path` - Path to the contract's WASM file
102    /// * `client_configs` - Optional configuration for interacting with an already deployed instance
103    ///
104    /// # Returns
105    ///
106    /// A new Contract instance or an error if the file couldn't be read
107    pub fn new(
108        wasm_path: &str,
109        client_configs: Option<ClientContractConfigs>,
110    ) -> Result<Self, SorobanHelperError> {
111        Self::new_with_reader(wasm_path, client_configs, DefaultFileReader)
112    }
113
114    /// Creates a new Contract instance from a WASM file path and custom file reader
115    ///
116    /// ### Parameters
117    ///
118    /// * `wasm_path` - Path to the contract's WASM file
119    /// * `client_configs` - Optional configuration for interacting with an already deployed instance
120    /// * `file_reader` - Custom file reader for reading the WASM file adopting the `FileReader` trait.
121    ///
122    /// ### Returns
123    ///
124    /// A new Contract instance or an error if the file couldn't be read
125    pub fn new_with_reader<T: FileReader>(
126        wasm_path: &str,
127        client_configs: Option<ClientContractConfigs>,
128        file_reader: T,
129    ) -> Result<Self, SorobanHelperError> {
130        let wasm_bytes = file_reader.read(wasm_path)?;
131        let wasm_hash = crypto::sha256_hash(&wasm_bytes);
132
133        Ok(Self {
134            wasm_bytes,
135            wasm_hash,
136            client_configs,
137        })
138    }
139
140    /// Deploys the contract to the Soroban network
141    ///
142    /// This method performs two operations:
143    /// 1. Uploads the contract WASM bytecode if it doesn't exist on the network
144    /// 2. Creates a contract instance with the uploaded WASM
145    ///
146    /// If the contract has a constructor function,
147    /// the provided constructor arguments will be passed to it during deployment.
148    ///
149    /// # Parameters
150    ///
151    /// * `env` - The environment to use for deployment
152    /// * `account` - The account that will deploy the contract and pay for the transaction
153    /// * `constructor_args` - Optional arguments to pass to the contract's constructor
154    ///
155    /// # Returns
156    ///
157    /// The Contract instance updated with client configuration for the deployed contract
158    pub async fn deploy(
159        mut self,
160        env: &Env,
161        account: &mut Account,
162        constructor_args: Option<Vec<ScVal>>,
163    ) -> Result<Self, SorobanHelperError> {
164        self.upload_wasm(account, env).await?;
165
166        let salt = crypto::generate_salt();
167
168        let contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
169            address: ScAddress::Account(account.account_id()),
170            salt,
171        });
172
173        let has_constructor =
174            String::from_utf8_lossy(&self.wasm_bytes).contains(CONSTRUCTOR_FUNCTION_NAME);
175        let create_operation = Operations::create_contract(
176            contract_id_preimage,
177            self.wasm_hash.clone(),
178            if has_constructor {
179                constructor_args
180            } else {
181                None
182            },
183        )?;
184
185        let builder = TransactionBuilder::new(account, env).add_operation(create_operation);
186
187        let deploy_tx = builder.simulate_and_build(env, account).await?;
188        let tx_envelope = account.sign_transaction(&deploy_tx, &env.network_id())?;
189        let tx_result = env.send_transaction(&tx_envelope).await?;
190
191        let parser = Parser::new(ParserType::Deploy);
192        let result = parser.parse(&tx_result)?;
193
194        let contract_id = match result {
195            ParseResult::Deploy(Some(contract_id)) => contract_id,
196            _ => return Err(SorobanHelperError::ContractDeployedConfigsNotSet),
197        };
198
199        self.set_client_configs(ClientContractConfigs {
200            contract_id,
201            env: env.clone(),
202            account: account.clone(),
203        });
204
205        Ok(self)
206    }
207
208    /// Sets the client configuration for interacting with a deployed contract
209    ///
210    /// # Parameters
211    ///
212    /// * `client_configs` - The client configuration to set
213    fn set_client_configs(&mut self, client_configs: ClientContractConfigs) {
214        self.client_configs = Some(client_configs);
215    }
216
217    /// Returns the contract ID if the contract has been deployed
218    ///
219    /// # Returns
220    ///
221    /// The contract ID or None if the contract has not been deployed
222    pub fn contract_id(&self) -> Option<ContractId> {
223        self.client_configs.as_ref().map(|c| c.contract_id)
224    }
225
226    /// Uploads the contract WASM bytecode to the network
227    ///
228    /// # Parameters
229    ///
230    /// * `account` - The account that will pay for the upload
231    /// * `env` - The environment to use for the upload
232    ///
233    /// # Returns
234    ///
235    /// Ok(()) if the upload was successful or the code already exists
236    async fn upload_wasm(
237        &self,
238        account: &mut Account,
239        env: &Env,
240    ) -> Result<(), SorobanHelperError> {
241        let upload_operation = Operations::upload_wasm(self.wasm_bytes.clone())?;
242
243        let builder = TransactionBuilder::new(account, env).add_operation(upload_operation);
244
245        let upload_tx = builder.simulate_and_build(env, account).await?;
246        let tx_envelope = account.sign_transaction(&upload_tx, &env.network_id())?;
247
248        match env.send_transaction(&tx_envelope).await {
249            Ok(_) => Ok(()),
250            Err(e) => {
251                // If it failed because the code already exists, that's fine
252                if let SorobanHelperError::ContractCodeAlreadyExists = e {
253                    Ok(())
254                } else {
255                    Err(e)
256                }
257            }
258        }
259    }
260
261    /// Invokes a function on the deployed contract
262    ///
263    /// # Parameters
264    ///
265    /// * `function_name` - The name of the function to invoke
266    /// * `args` - The arguments to pass to the function
267    ///
268    /// # Returns
269    ///
270    /// The transaction response from the network
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if the contract has not been deployed or
275    /// if there's an issue with the invocation
276    pub async fn invoke(
277        &mut self,
278        function_name: &str,
279        args: Vec<ScVal>,
280    ) -> Result<stellar_rpc_client::GetTransactionResponse, SorobanHelperError> {
281        let client_configs = self
282            .client_configs
283            .as_mut()
284            .ok_or(SorobanHelperError::ContractDeployedConfigsNotSet)?;
285
286        let contract_id = client_configs.contract_id;
287        let env = client_configs.env.clone();
288
289        let invoke_operation = Operations::invoke_contract(&contract_id, function_name, args)?;
290
291        let builder =
292            TransactionBuilder::new(&client_configs.account, &env).add_operation(invoke_operation);
293
294        let invoke_tx = builder
295            .simulate_and_build(&env, &client_configs.account)
296            .await?;
297        let tx_envelope = client_configs
298            .account
299            .sign_transaction(&invoke_tx, &env.network_id())?;
300
301        env.send_transaction(&tx_envelope).await
302    }
303}
304
305#[cfg(test)]
306mod test {
307    use crate::{
308        Account, ClientContractConfigs, Contract, crypto,
309        error::SorobanHelperError,
310        mock::{
311            fs::MockFileReader,
312            mock_account_entry, mock_contract_id, mock_env, mock_signer1,
313            mock_simulate_tx_response, mock_transaction_response,
314            transaction::{create_contract_id_val, mock_transaction_response_with_return_value},
315        },
316    };
317    use std::io::Write;
318    use tempfile::NamedTempFile;
319
320    #[test]
321    fn test_contract_clone() {
322        let wasm_bytes = b"mock wasm bytes".to_vec();
323        let wasm_hash = crypto::sha256_hash(&wasm_bytes);
324        let env = mock_env(None, None, None);
325        let account = Account::single(mock_signer1());
326
327        let client_configs = Some(ClientContractConfigs {
328            contract_id: mock_contract_id(account.clone(), &env),
329            env: env.clone(),
330            account: account.clone(),
331        });
332
333        let original_contract = Contract {
334            wasm_bytes: wasm_bytes.clone(),
335            wasm_hash,
336            client_configs: client_configs.clone(),
337        };
338
339        let cloned_contract = original_contract.clone();
340
341        assert_eq!(cloned_contract.wasm_bytes, original_contract.wasm_bytes);
342        assert_eq!(cloned_contract.wasm_hash.0, original_contract.wasm_hash.0);
343
344        assert!(cloned_contract.client_configs.is_some());
345        let cloned_configs = cloned_contract.client_configs.unwrap();
346        let original_configs = original_contract.client_configs.unwrap();
347
348        assert_eq!(cloned_configs.contract_id.0, original_configs.contract_id.0);
349    }
350
351    #[test]
352    fn test_contract_new() {
353        // Create fake temp wasm file because of DefaultFileReader
354        let mut temp_file = NamedTempFile::new().unwrap();
355        let wasm_bytes = b"test wasm bytes";
356        temp_file.write_all(wasm_bytes).unwrap();
357
358        let wasm_path = temp_file.path().to_str().unwrap();
359        let contract = Contract::new(wasm_path, None).unwrap();
360
361        assert_eq!(contract.wasm_bytes, wasm_bytes);
362        assert_eq!(contract.wasm_hash, crypto::sha256_hash(wasm_bytes));
363        assert!(contract.client_configs.is_none());
364    }
365
366    #[test]
367    fn test_contract_id() {
368        let wasm_bytes = b"mock wasm bytes".to_vec();
369        let contract_without_configs = Contract {
370            wasm_bytes: wasm_bytes.clone(),
371            wasm_hash: crypto::sha256_hash(&wasm_bytes),
372            client_configs: None,
373        };
374
375        assert!(contract_without_configs.contract_id().is_none());
376
377        let env = mock_env(None, None, None);
378        let account = Account::single(mock_signer1());
379        let contract_id = mock_contract_id(account.clone(), &env);
380
381        let contract_with_configs = Contract {
382            wasm_bytes: wasm_bytes.clone(),
383            wasm_hash: crypto::sha256_hash(&wasm_bytes),
384            client_configs: Some(ClientContractConfigs {
385                contract_id,
386                env: env.clone(),
387                account: account.clone(),
388            }),
389        };
390
391        let retrieved_id = contract_with_configs.contract_id();
392        assert!(retrieved_id.is_some());
393        assert_eq!(retrieved_id.unwrap().0, contract_id.0);
394    }
395
396    #[tokio::test]
397    async fn test_file_reader() {
398        let wasm_path = "path/to/wasm";
399        let client_configs = None;
400        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
401        let contract = Contract::new_with_reader(wasm_path, client_configs, file_reader).unwrap();
402        assert_eq!(contract.wasm_bytes, b"mock wasm bytes".to_vec());
403    }
404
405    #[tokio::test]
406    async fn test_upload_wasm() {
407        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
408        let signer_1_account_id = mock_signer1().account_id().0.to_string();
409        let get_account_result = mock_account_entry(&signer_1_account_id);
410
411        let env = mock_env(
412            Some(Ok(get_account_result)),
413            Some(Ok(simulate_transaction_envelope_result)),
414            None,
415        );
416        let wasm_path = "path/to/wasm";
417        let mut account = Account::single(mock_signer1());
418        let client_configs = ClientContractConfigs {
419            contract_id: mock_contract_id(account.clone(), &env),
420            env: env.clone(),
421            account: account.clone(),
422        };
423        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
424        let contract =
425            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
426
427        assert!(contract.upload_wasm(&mut account, &env).await.is_ok());
428    }
429
430    #[tokio::test]
431    async fn test_upload_wasm_contract_code_already_exists() {
432        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
433
434        let signer_1_account_id = mock_signer1().account_id().0.to_string();
435        let get_account_result = mock_account_entry(&signer_1_account_id);
436
437        let send_transaction_result = Err(SorobanHelperError::ContractCodeAlreadyExists);
438
439        let env = mock_env(
440            Some(Ok(get_account_result)),
441            Some(Ok(simulate_transaction_envelope_result)),
442            Some(send_transaction_result),
443        );
444        let wasm_path = "path/to/wasm";
445        let mut account = Account::single(mock_signer1());
446        let client_configs = ClientContractConfigs {
447            contract_id: mock_contract_id(account.clone(), &env),
448            env: env.clone(),
449            account: account.clone(),
450        };
451        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
452        let contract =
453            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
454
455        let res = contract.upload_wasm(&mut account, &env).await;
456        // result must be Ok, because the contract code already exists.
457        assert!(res.is_ok());
458    }
459
460    #[tokio::test]
461    async fn test_contract_invoke() {
462        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
463
464        let signer_1_account_id = mock_signer1().account_id().0.to_string();
465        let get_account_result = mock_account_entry(&signer_1_account_id);
466
467        let send_transaction_result = Ok(mock_transaction_response());
468
469        let env = mock_env(
470            Some(Ok(get_account_result)),
471            Some(Ok(simulate_transaction_envelope_result)),
472            Some(send_transaction_result),
473        );
474        let wasm_path = "path/to/wasm";
475        let account = Account::single(mock_signer1());
476        let client_configs = ClientContractConfigs {
477            contract_id: mock_contract_id(account.clone(), &env),
478            env: env.clone(),
479            account: account.clone(),
480        };
481        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
482        let mut contract =
483            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
484
485        let res = contract.invoke("function_name", vec![]).await;
486        assert!(res.is_ok());
487        assert_eq!(
488            res.unwrap().result_meta,
489            mock_transaction_response().result_meta
490        );
491    }
492
493    #[tokio::test]
494    async fn test_contract_deploy() {
495        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
496        let signer_1_account_id = mock_signer1().account_id().0.to_string();
497        let get_account_result = mock_account_entry(&signer_1_account_id);
498
499        // Create a contract ID value for the mock response
500        let contract_val = create_contract_id_val();
501        let send_transaction_result = Ok(mock_transaction_response_with_return_value(contract_val));
502
503        let env = mock_env(
504            Some(Ok(get_account_result)),
505            Some(Ok(simulate_transaction_envelope_result)),
506            Some(send_transaction_result),
507        );
508        let wasm_path = "path/to/wasm";
509        let mut account = Account::single(mock_signer1());
510        let client_configs = ClientContractConfigs {
511            contract_id: mock_contract_id(account.clone(), &env),
512            env: env.clone(),
513            account: account.clone(),
514        };
515        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
516
517        let wasm_hash = crypto::sha256_hash(b"mock wasm bytes");
518        let contract =
519            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
520        let res = contract.deploy(&env, &mut account, None).await;
521        assert!(res.is_ok());
522        assert_eq!(res.unwrap().wasm_hash, wasm_hash);
523    }
524
525    #[test]
526    fn test_set_client_configs() {
527        let wasm_bytes = b"mock wasm bytes".to_vec();
528        let mut contract = Contract {
529            wasm_bytes: wasm_bytes.clone(),
530            wasm_hash: crypto::sha256_hash(&wasm_bytes),
531            client_configs: None,
532        };
533
534        let env = mock_env(None, None, None);
535        let account = Account::single(mock_signer1());
536        let contract_id = mock_contract_id(account.clone(), &env);
537
538        let configs = ClientContractConfigs {
539            contract_id,
540            env: env.clone(),
541            account: account.clone(),
542        };
543
544        contract.set_client_configs(configs.clone());
545
546        assert!(contract.client_configs.is_some());
547        let set_configs = contract.client_configs.unwrap();
548        assert_eq!(set_configs.contract_id.0, contract_id.0);
549    }
550}