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