Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tutorial working with workspaces #1106

Merged
merged 6 commits into from
Feb 20, 2025
Merged
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
383 changes: 383 additions & 0 deletions docs/build/guides/conventions/workspace.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,383 @@
---
title: Organize contracts with workspace
hide_table_of_contents: true
description: Organize your contracts using Cargo workspaces
---

### Initializing workspace project

Using Cargo's workspace [feature](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) makes it very convenient to organize your smart contracts in subdirectories of your project's root.

It's very simple to get started using the cli:

```bash
mkdir soroban-project
cd soroban-project
PROJECT_ROOT=.
stellar contract init . --name add_contract
```

Running this command will create a root project directory (`soroban-project`) and then initialize a project workspace with a single contract named `add_contract`.

Adding one more contract template to the project can be done using the same command:

```bash
stellar contract init . --name main_contract
```

The project tree will look like this:

```
$ tree
.
├── Cargo.toml
├── contracts
│   ├── add-contract
│   │   ├── Cargo.toml
│   │   ├── Makefile
│   │   └── src
│   │   ├── lib.rs
│   │   └── test.rs
│   └── main-contract
│   ├── Cargo.toml
│   ├── Makefile
│   └── src
│   ├── lib.rs
│   └── test.rs
└── README.md
```

Running `contract init` command created a sample contracts subdirectories (located in `./contracts), each contains:

1. Cargo.toml file with all (minimal) necessary dependencies
2. `Makefile` to easily build contract using `make` command
3. `src` directory with an example hello-world contract and a simple test.

We can now build our project's contracts with

```bash
stellar contract build
```

command and check the build directory:

```bash
$ ls target/wasm32-unknown-unknown/release/ | grep wasm
add_contract.wasm
main_contract.wasm
```

### Cross-contract call between workspaces

With the given project structure, cross-contract calls can be easily made. Starting with modifying the sample hello world contract into an add contract:

```rust
// contracts/add_contract/src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl};

#[contract]
pub struct ContractAdd;

#[contractimpl]
impl ContractAdd {
pub fn add(x: u32, y: u32) -> u32 {
x.checked_add(y).expect("no overflow")
}
}
```

The `ContractAdd` can now be referenced and used from another contracts in the same workspace using [`contractimport`](https://docs.rs/soroban-sdk/latest/soroban_sdk/macro.contractimport.html) macros. Note that the `contractimport` requires relative path of the compiled wasm file, so first it's required to recompile the contract with:

```bash
stellar contract build
```

As seen previously, target wasm file is located in

```
$PROJECT_ROOT/target/wasm32-unknown-unknown/release/
```

In order to reference this wasm file from `contracts/main_contract/src/lib.rs`, we need to first construct the path to the project root: `../..` (relative to second contract's Cargo.toml file location) followed by path to the wasm file:

```bash
../../../target/wasm32-unknown-unknown/release/add_contract.wasm
```

It can now be used in the rust code:

```rust
// contracts/main_contract/src/lib.rs
#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

mod contract_add {
soroban_sdk::contractimport!(
file = "../../../target/wasm32-unknown-unknown/release/add_contract.wasm"
);
}

#[contract]
pub struct ContractMain;

#[contractimpl]
impl ContractMain {
pub fn add_with(env: Env, contract: Address, x: u32, y: u32) -> u32 {
let client = contract_add::Client::new(&env, &contract);
client.add(&x, &y)
}
}

mod test;
```

Here, main contract will invoke `ContractAdd`'s `add` function to calculate the sum of 2 numbers. It's a good idea to update tests for our main contract as well:

```rust
// contracts/main_contract/src/test.rs
#![cfg(test)]

use crate::{contract_add, ContractMain, ContractMainClient};
use soroban_sdk::Env;

#[test]
fn test_adding_cross_contract() {
let env = Env::default();

// Register add contract using the imported WASM.
let contract_add_id = env.register_contract_wasm(None, contract_add::WASM);

// Register main contract defined in this crate.
let contract_main_id = env.register_contract(None, ContractMain);

// Create a client for calling main contract.
let client = ContractMainClient::new(&env, &contract_main_id);

// Invoke main contract via its client. Main contract will invoke add contract.
let sum = client.add_with(&contract_add_id, &5, &7);
assert_eq!(sum, 12);
}
```

Contracts can now be re-complied:

```bash
cd $PROJECT_ROOT
stellar contract build
```

And check that the test is working correctly:

```bash
$ cd contracts/main_contract
$ cargo test

running 1 test
test test::test_adding_cross_contract ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
```

Finally, let's deploy this contracts and call our main contract using cli. If you haven't already, set up an account (alice) first

```bash
stellar keys generate alice --fund --network testnet
STELLAR_SOURCE_ACCOUNT=alice
```

Second is to deploy the contracts:

```bash
ADD_CONTRACT=`stellar contract deploy --network testnet --wasm target/wasm32-unknown-unknown/release/add_contract.wasm`
MAIN_CONTRACT=`stellar contract deploy --network testnet --wasm target/wasm32-unknown-unknown/release/main_contract.wasm`
```

And finally call the main contract:

```bash
$ stellar contract invoke --id $MAIN_CONTRACT --network testnet -- add_with --contract $ADD_CONTRACT --x 9 --y 10
ℹ️ Send skipped because simulation identified as read-only. Send by rerunning with `--send=yes`.
19
```

### Adding contract interfaces

As the next step, we can abstract away add contract and allow it to have multiple implementations. Main contract will in turn use the contract interface that is not bound to its implementation.

```bash
cd $PROJECT_ROOT
stellar contract init . --name adder_interface
stellar contract init . --name add_extra_contract
```

First, let's create an interface and change our existing implementation to use this interface:

```rust
// contracts/adder_interface/src/lib.rs
#![no_std]

use soroban_sdk::contractclient;

#[contractclient(name = "ContractAClient")]
pub trait ContractAInterface {
fn add(x: u32, y: u32) -> u32;
}
```

To use the interface definition our workspace members will now have an `adder_interface` as a dependency:

```toml
# $PROJECT_PATH/Cargo.toml
# <...>
[workspace.dependencies]
soroban-sdk = "21.0.0"
adder-interface = { path = "contracts/adder_interface" }
# <...>
```

```toml
# $PROJECT_PATH/contracts/add_contract/Cargo.toml
# <...>
[dependencies]
soroban-sdk = { workspace = true }
adder-interface = {workspace = true}
# <...>
```

```toml
# $PROJECT_PATH/contracts/main_contract/Cargo.toml
# <...>
[dependencies]
soroban-sdk = { workspace = true }
adder-interface = {workspace = true}
# <...>
```

```toml
# $PROJECT_PATH/contracts/add_extra_contract/Cargo.toml
# <...>
[dependencies]
soroban-sdk = { workspace = true }
adder-interface = {workspace = true}
# <...>
```

And change lib type of `adder_interface` crate:

```toml
# $PROJECT_PATH/contracts/adder_interface/Cargo.toml
# <...>
[lib]
crate-type = ["rlib"]
# <...>
```

```rust
# $PROJECT_PATH/contracts/adder_interface/src/lib.rs
#![no_std]

use soroban_sdk::contractclient;

#[contractclient(name = "AdderClient")]
pub trait Adder {
fn add(x: u32, y: u32) -> u32;
}

```

```rust
// contracts/add_contract/src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl};
use adder_interface::Adder;

#[contract]
pub struct ContractAdd;

#[contractimpl]
impl Adder for ContractAdd {
fn add(x: u32, y: u32) -> u32 {
x.checked_add(y).expect("no overflow")
}
}

```

```rust
// contracts/main_contract/src/lib.rs
#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};
use adder_interface::AdderClient;

#[contract]
pub struct ContractMain;

#[contractimpl]
impl ContractMain {
pub fn add_with(env: Env, contract: Address, x: u32, y: u32) -> u32 {
let client = AdderClient::new(&env, &contract);
client.add(&x, &y)
}
}

mod test;
```

And update test imports:

```rust
// contracts/main_contract/src/test.rs
#![cfg(test)]

use crate::{ContractMain, ContractMainClient};
use soroban_sdk::Env;

mod contract_add {
soroban_sdk::contractimport!(
file = "../../target/wasm32-unknown-unknown/release/my_contract.wasm"
);
}
// <...>
```

As the final step we can create an alternative `Adder` implementation that adds an extra 1:

```rust
// contracts/add_extra_contract/src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl};
use adder_interface::Adder;

#[contract]
pub struct ContractAdd;

#[contractimpl]
impl Adder for ContractAdd {
fn add(x: u32, y: u32) -> u32 {
x.checked_add(y).expect("no overflow").checked_add(1).expect("no overflow")
}
}
```

We can now deploy this contracts and test the new behavior:

```bash
stellar contract build
ADD_CONTRACT=`stellar contract deploy --network testnet --wasm target/wasm32-unknown-unknown/release/add_contract.wasm`
WRONG_MATH_CONTRACT=`stellar contract deploy --network testnet --wasm target/wasm32-unknown-unknown/release/add_extra_contract.wasm`
MAIN_CONTRACT=`stellar contract deploy --network testnet --wasm target/wasm32-unknown-unknown/release/main_contract.wasm`
```

Now let's try to do sum 2 unsigned integers causing an overflow:

```bash
$ stellar contract invoke --id $MAIN_CONTRACT --network testnet -- add_with --contract $ADD_CONTRACT --x 2 --y 2
ℹ️ Send skipped because simulation identified as read-only. Send by rerunning with `--send=yes`.
4
$ stellar contract invoke --id $MAIN_CONTRACT --network testnet -- add_with --contract $WRONG_MATH_CONTRACT --x 2 --y 2
ℹ️ Send skipped because simulation identified as read-only. Send by rerunning with `--send=yes`.
5
```
2 changes: 2 additions & 0 deletions docs/build/guides/testing/unit-tests.mdx
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ fn test() {
assert_eq!(client.increment(), 3);
}
```

Ref: https://github.com/stellar/soroban-examples/blob/main/increment/src/test.rs

:::tip
@@ -42,6 +43,7 @@ The Env created at the beginning of the test is not a simulation of the Soroban
It's a simple test, but it's a complete test. There's a full environment setup, used, and torn down in the test, and it happens fast. The Rust test harness runs all the tests for a contract in parallel and each will have its own isolated contract environment.

Most tests, even integration tests and fuzz tests, will look very similar to this unit test. They'll do four things:

1. Create an environment, the `Env`.
2. Register the contract(s) to be tested.
3. Invoke functions using a client.