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, SorobanTransactionResponse, 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 source_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 deployed contract
115    ///
116    /// # Parameters
117    ///
118    /// * `client_configs` - Configuration for interacting with the deployed contract
119    ///
120    /// # Returns
121    ///
122    /// A new Contract instance
123    pub fn from_configs(client_configs: ClientContractConfigs) -> Self {
124        Self {
125            wasm_bytes: Vec::new(),
126            wasm_hash: crypto::sha256_hash(&[]),
127            client_configs: Some(client_configs),
128        }
129    }
130
131    /// Creates a new Contract instance from a WASM file path and custom file reader
132    ///
133    /// ### Parameters
134    ///
135    /// * `wasm_path` - Path to the contract's WASM file
136    /// * `client_configs` - Optional configuration for interacting with an already deployed instance
137    /// * `file_reader` - Custom file reader for reading the WASM file adopting the `FileReader` trait.
138    ///
139    /// ### Returns
140    ///
141    /// A new Contract instance or an error if the file couldn't be read
142    pub fn new_with_reader<T: FileReader>(
143        wasm_path: &str,
144        client_configs: Option<ClientContractConfigs>,
145        file_reader: T,
146    ) -> Result<Self, SorobanHelperError> {
147        let wasm_bytes = file_reader.read(wasm_path)?;
148        let wasm_hash = crypto::sha256_hash(&wasm_bytes);
149
150        Ok(Self {
151            wasm_bytes,
152            wasm_hash,
153            client_configs,
154        })
155    }
156
157    /// Deploys the contract to the Soroban network
158    ///
159    /// This method performs two operations:
160    /// 1. Uploads the contract WASM bytecode if it doesn't exist on the network
161    /// 2. Creates a contract instance with the uploaded WASM
162    ///
163    /// If the contract has a constructor function,
164    /// the provided constructor arguments will be passed to it during deployment.
165    ///
166    /// # Parameters
167    ///
168    /// * `env` - The environment to use for deployment
169    /// * `account` - The account that will deploy the contract and pay for the transaction
170    /// * `constructor_args` - Optional arguments to pass to the contract's constructor
171    ///
172    /// # Returns
173    ///
174    /// The Contract instance updated with client configuration for the deployed contract
175    pub async fn deploy(
176        mut self,
177        env: &Env,
178        account: &mut Account,
179        constructor_args: Option<Vec<ScVal>>,
180    ) -> Result<Self, SorobanHelperError> {
181        self.upload_wasm(account, env).await?;
182
183        let salt = crypto::generate_salt();
184
185        let contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
186            address: ScAddress::Account(account.account_id()),
187            salt,
188        });
189
190        let has_constructor =
191            String::from_utf8_lossy(&self.wasm_bytes).contains(CONSTRUCTOR_FUNCTION_NAME);
192        let create_operation = Operations::create_contract(
193            contract_id_preimage,
194            self.wasm_hash.clone(),
195            if has_constructor {
196                constructor_args
197            } else {
198                None
199            },
200        )?;
201
202        let builder = TransactionBuilder::new(account, env).add_operation(create_operation);
203
204        let deploy_tx = builder.simulate_and_build(env, account).await?;
205        let tx_envelope = account.sign_transaction(&deploy_tx, &env.network_id())?;
206        let tx_result = env.send_transaction(&tx_envelope).await?;
207
208        let parser = Parser::new(ParserType::Deploy);
209        let result = parser.parse(&tx_result.response)?;
210
211        let contract_id = match result {
212            ParseResult::Deploy(Some(contract_id)) => contract_id,
213            _ => return Err(SorobanHelperError::ContractDeployedConfigsNotSet),
214        };
215
216        self.set_client_configs(ClientContractConfigs {
217            contract_id,
218            env: env.clone(),
219            source_account: account.clone(),
220        });
221
222        Ok(self)
223    }
224
225    /// Sets the client configuration for interacting with a deployed contract
226    ///
227    /// # Parameters
228    ///
229    /// * `client_configs` - The client configuration to set
230    fn set_client_configs(&mut self, client_configs: ClientContractConfigs) {
231        self.client_configs = Some(client_configs);
232    }
233
234    /// Returns the contract ID if the contract has been deployed
235    ///
236    /// # Returns
237    ///
238    /// The contract ID or None if the contract has not been deployed
239    pub fn contract_id(&self) -> Option<ContractId> {
240        self.client_configs.as_ref().map(|c| c.contract_id)
241    }
242
243    /// Uploads the contract WASM bytecode to the network
244    ///
245    /// # Parameters
246    ///
247    /// * `account` - The account that will pay for the upload
248    /// * `env` - The environment to use for the upload
249    ///
250    /// # Returns
251    ///
252    /// Ok(()) if the upload was successful or the code already exists
253    async fn upload_wasm(
254        &self,
255        account: &mut Account,
256        env: &Env,
257    ) -> Result<(), SorobanHelperError> {
258        let upload_operation = Operations::upload_wasm(self.wasm_bytes.clone())?;
259
260        let builder = TransactionBuilder::new(account, env).add_operation(upload_operation);
261
262        let upload_tx = builder.simulate_and_build(env, account).await?;
263        let tx_envelope = account.sign_transaction(&upload_tx, &env.network_id())?;
264
265        match env.send_transaction(&tx_envelope).await {
266            Ok(_) => Ok(()),
267            Err(e) => {
268                // If it failed because the code already exists, that's fine
269                if let SorobanHelperError::ContractCodeAlreadyExists = e {
270                    Ok(())
271                } else {
272                    Err(e)
273                }
274            }
275        }
276    }
277
278    /// Invokes a function on the deployed contract
279    ///
280    /// # Parameters
281    ///
282    /// * `function_name` - The name of the function to invoke
283    /// * `args` - The arguments to pass to the function
284    ///
285    /// # Returns
286    ///
287    /// The transaction response from the network
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if the contract has not been deployed or
292    /// if there's an issue with the invocation
293    pub async fn invoke(
294        &mut self,
295        function_name: &str,
296        args: Vec<ScVal>,
297    ) -> Result<SorobanTransactionResponse, SorobanHelperError> {
298        let client_configs = self
299            .client_configs
300            .as_mut()
301            .ok_or(SorobanHelperError::ContractDeployedConfigsNotSet)?;
302
303        let contract_id = client_configs.contract_id;
304        let env = client_configs.env.clone();
305
306        let invoke_operation = Operations::invoke_contract(&contract_id, function_name, args)?;
307
308        let builder = TransactionBuilder::new(&client_configs.source_account, &env)
309            .add_operation(invoke_operation);
310
311        let invoke_tx = builder
312            .simulate_and_build(&env, &client_configs.source_account)
313            .await?;
314
315        let tx_envelope = client_configs
316            .source_account
317            .sign_transaction(&invoke_tx, &env.network_id())?;
318
319        env.send_transaction(&tx_envelope).await
320    }
321}
322
323#[cfg(test)]
324mod test {
325    use crate::{
326        Account, ClientContractConfigs, Contract, crypto,
327        error::SorobanHelperError,
328        mock::{
329            fs::MockFileReader,
330            mock_account_entry, mock_contract_id, mock_env, mock_signer1,
331            mock_simulate_tx_response, mock_transaction_response,
332            transaction::{create_contract_id_val, mock_transaction_response_with_return_value},
333        },
334    };
335    use std::io::Write;
336    use tempfile::NamedTempFile;
337
338    #[test]
339    fn test_contract_clone() {
340        let wasm_bytes = b"mock wasm bytes".to_vec();
341        let wasm_hash = crypto::sha256_hash(&wasm_bytes);
342        let env = mock_env(None, None, None);
343        let account = Account::single(mock_signer1());
344
345        let client_configs = Some(ClientContractConfigs {
346            contract_id: mock_contract_id(account.clone(), &env),
347            env: env.clone(),
348            source_account: account.clone(),
349        });
350
351        let original_contract = Contract {
352            wasm_bytes: wasm_bytes.clone(),
353            wasm_hash,
354            client_configs: client_configs.clone(),
355        };
356
357        let cloned_contract = original_contract.clone();
358
359        assert_eq!(cloned_contract.wasm_bytes, original_contract.wasm_bytes);
360        assert_eq!(cloned_contract.wasm_hash.0, original_contract.wasm_hash.0);
361
362        assert!(cloned_contract.client_configs.is_some());
363        let cloned_configs = cloned_contract.client_configs.unwrap();
364        let original_configs = original_contract.client_configs.unwrap();
365
366        assert_eq!(cloned_configs.contract_id.0, original_configs.contract_id.0);
367    }
368
369    #[test]
370    fn test_contract_new() {
371        // Create fake temp wasm file because of DefaultFileReader
372        let mut temp_file = NamedTempFile::new().unwrap();
373        let wasm_bytes = b"test wasm bytes";
374        temp_file.write_all(wasm_bytes).unwrap();
375
376        let wasm_path = temp_file.path().to_str().unwrap();
377        let contract = Contract::new(wasm_path, None).unwrap();
378
379        assert_eq!(contract.wasm_bytes, wasm_bytes);
380        assert_eq!(contract.wasm_hash, crypto::sha256_hash(wasm_bytes));
381        assert!(contract.client_configs.is_none());
382    }
383
384    #[test]
385    fn test_contract_id() {
386        let wasm_bytes = b"mock wasm bytes".to_vec();
387        let contract_without_configs = Contract {
388            wasm_bytes: wasm_bytes.clone(),
389            wasm_hash: crypto::sha256_hash(&wasm_bytes),
390            client_configs: None,
391        };
392
393        assert!(contract_without_configs.contract_id().is_none());
394
395        let env = mock_env(None, None, None);
396        let account = Account::single(mock_signer1());
397        let contract_id = mock_contract_id(account.clone(), &env);
398
399        let contract_with_configs = Contract {
400            wasm_bytes: wasm_bytes.clone(),
401            wasm_hash: crypto::sha256_hash(&wasm_bytes),
402            client_configs: Some(ClientContractConfigs {
403                contract_id,
404                env: env.clone(),
405                source_account: account.clone(),
406            }),
407        };
408
409        let retrieved_id = contract_with_configs.contract_id();
410        assert!(retrieved_id.is_some());
411        assert_eq!(retrieved_id.unwrap().0, contract_id.0);
412    }
413
414    #[tokio::test]
415    async fn test_file_reader() {
416        let wasm_path = "path/to/wasm";
417        let client_configs = None;
418        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
419        let contract = Contract::new_with_reader(wasm_path, client_configs, file_reader).unwrap();
420        assert_eq!(contract.wasm_bytes, b"mock wasm bytes".to_vec());
421    }
422
423    #[tokio::test]
424    async fn test_upload_wasm() {
425        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
426        let signer_1_account_id = mock_signer1().account_id().0.to_string();
427        let get_account_result = mock_account_entry(&signer_1_account_id);
428
429        let env = mock_env(
430            Some(Ok(get_account_result)),
431            Some(Ok(simulate_transaction_envelope_result)),
432            None,
433        );
434        let wasm_path = "path/to/wasm";
435        let mut account = Account::single(mock_signer1());
436        let client_configs = ClientContractConfigs {
437            contract_id: mock_contract_id(account.clone(), &env),
438            env: env.clone(),
439            source_account: account.clone(),
440        };
441        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
442        let contract =
443            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
444
445        assert!(contract.upload_wasm(&mut account, &env).await.is_ok());
446    }
447
448    #[tokio::test]
449    async fn test_upload_wasm_contract_code_already_exists() {
450        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
451
452        let signer_1_account_id = mock_signer1().account_id().0.to_string();
453        let get_account_result = mock_account_entry(&signer_1_account_id);
454
455        let send_transaction_result = Err(SorobanHelperError::ContractCodeAlreadyExists);
456
457        let env = mock_env(
458            Some(Ok(get_account_result)),
459            Some(Ok(simulate_transaction_envelope_result)),
460            Some(send_transaction_result),
461        );
462        let wasm_path = "path/to/wasm";
463        let mut account = Account::single(mock_signer1());
464        let client_configs = ClientContractConfigs {
465            contract_id: mock_contract_id(account.clone(), &env),
466            env: env.clone(),
467            source_account: account.clone(),
468        };
469        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
470        let contract =
471            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
472
473        let res = contract.upload_wasm(&mut account, &env).await;
474        // result must be Ok, because the contract code already exists.
475        assert!(res.is_ok());
476    }
477
478    #[tokio::test]
479    async fn test_contract_invoke() {
480        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
481
482        let signer_1_account_id = mock_signer1().account_id().0.to_string();
483        let get_account_result = mock_account_entry(&signer_1_account_id);
484        let send_transaction_result = mock_transaction_response();
485
486        let env = mock_env(
487            Some(Ok(get_account_result)),
488            Some(Ok(simulate_transaction_envelope_result)),
489            Some(Ok(send_transaction_result)),
490        );
491        let wasm_path = "path/to/wasm";
492        let account = Account::single(mock_signer1());
493        let client_configs = ClientContractConfigs {
494            contract_id: mock_contract_id(account.clone(), &env),
495            env: env.clone(),
496            source_account: account.clone(),
497        };
498        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
499        let mut contract =
500            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
501
502        let res = contract.invoke("function_name", vec![]).await;
503        assert!(res.is_ok());
504        assert_eq!(
505            res.unwrap().response.result_meta,
506            mock_transaction_response().response.result_meta
507        );
508    }
509
510    #[tokio::test]
511    async fn test_contract_deploy() {
512        let simulate_transaction_envelope_result = mock_simulate_tx_response(None);
513        let signer_1_account_id = mock_signer1().account_id().0.to_string();
514        let get_account_result = mock_account_entry(&signer_1_account_id);
515
516        // Create a contract ID value for the mock response
517        let contract_val = create_contract_id_val();
518        let send_transaction_result = Ok(mock_transaction_response_with_return_value(contract_val));
519
520        let env = mock_env(
521            Some(Ok(get_account_result)),
522            Some(Ok(simulate_transaction_envelope_result)),
523            Some(send_transaction_result),
524        );
525        let wasm_path = "path/to/wasm";
526        let mut account = Account::single(mock_signer1());
527        let client_configs = ClientContractConfigs {
528            contract_id: mock_contract_id(account.clone(), &env),
529            env: env.clone(),
530            source_account: account.clone(),
531        };
532        let file_reader = MockFileReader::new(Ok(b"mock wasm bytes".to_vec()));
533
534        let wasm_hash = crypto::sha256_hash(b"mock wasm bytes");
535        let contract =
536            Contract::new_with_reader(wasm_path, Some(client_configs), file_reader).unwrap();
537        let res = contract.deploy(&env, &mut account, None).await;
538        assert!(res.is_ok());
539        assert_eq!(res.unwrap().wasm_hash, wasm_hash);
540    }
541
542    #[test]
543    fn test_set_client_configs() {
544        let wasm_bytes = b"mock wasm bytes".to_vec();
545        let mut contract = Contract {
546            wasm_bytes: wasm_bytes.clone(),
547            wasm_hash: crypto::sha256_hash(&wasm_bytes),
548            client_configs: None,
549        };
550
551        let env = mock_env(None, None, None);
552        let account = Account::single(mock_signer1());
553        let contract_id = mock_contract_id(account.clone(), &env);
554
555        let configs = ClientContractConfigs {
556            contract_id,
557            env: env.clone(),
558            source_account: account.clone(),
559        };
560
561        contract.set_client_configs(configs.clone());
562
563        assert!(contract.client_configs.is_some());
564        let set_configs = contract.client_configs.unwrap();
565        assert_eq!(set_configs.contract_id.0, contract_id.0);
566    }
567
568    #[test]
569    fn test_from_configs() {
570        let env = mock_env(None, None, None);
571        let account = Account::single(mock_signer1());
572        let contract_id = mock_contract_id(account.clone(), &env);
573
574        let client_configs = ClientContractConfigs {
575            contract_id,
576            env: env.clone(),
577            source_account: account.clone(),
578        };
579        let contract = Contract::from_configs(client_configs.clone());
580
581        assert!(contract.client_configs.is_some());
582        let stored_configs = contract.client_configs.unwrap();
583        assert_eq!(stored_configs.contract_id.0, contract_id.0);
584
585        // Verify WASM bytes are empty
586        assert!(contract.wasm_bytes.is_empty());
587        assert_eq!(contract.wasm_hash, crypto::sha256_hash(&[]));
588    }
589}