Workspace
The workspace example demonstrates how multiple smart contracts can be developed, tested, and built side-by-side in the same Rust workspace.
Run the Example
First go through the Setup process to get your development environment configured, then clone the soroban-examples
repository:
git clone https://github.com/stellar/soroban-examples
Or, skip the development environment setup and open this example in Gitpod.
To run the tests for the example use cargo test
.
cd workspace
cargo test
You should see three sets of output, one for contract_a
, contract_a_interface
, and contract_b
. The first two crates in the workspace contain no tests, but the third crate should give you the following output:
running 1 test
test test::test_token_auth ... ok
Code
- Contract A Interface
- Contract A
- Contract B
- Contract B Test
#![no_std]
use soroban_sdk::contractclient;
#[contractclient(name = "ContractAClient")]
pub trait ContractAInterface {
fn add(x: u32, y: u32) -> u32;
}
#![no_std]
use soroban_sdk::{contract, contractimpl};
use soroban_workspace_contract_a_interface::ContractAInterface;
#[contract]
pub struct ContractA;
#[contractimpl]
impl ContractAInterface for ContractA {
fn add(x: u32, y: u32) -> u32 {
x.checked_add(y).expect("no overflow")
}
}
#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env};
use soroban_workspace_contract_a_interface::ContractAClient;
#[contract]
pub struct ContractB;
#[contractimpl]
impl ContractB {
pub fn add_with(env: Env, contract: Address, x: u32, y: u32) -> u32 {
let client = ContractAClient::new(&env, &contract);
client.add(&x, &y)
}
}
mod test;
#![cfg(test)]
use soroban_sdk::Env;
use crate::{ContractB, ContractBClient};
use soroban_workspace_contract_a::ContractA;
#[test]
fn test() {
let env = Env::default();
// Register contract A using the native contract imported.
let contract_a_id = env.register_contract(None, ContractA);
// Register contract B defined in this crate.
let contract_b_id = env.register_contract(None, ContractB);
// Create a client for calling contract B.
let client = ContractBClient::new(&env, &contract_b_id);
// Invoke contract B via its client. Contract B will invoke contract A.
let sum = client.add_with(&contract_a_id, &5, &7);
assert_eq!(sum, 12);
}
Ref: https://github.com/stellar/soroban-examples/tree/main/workspace
How It Works
There are three crates that are part of the workspace.
contract_a_interface
contains a trait,ContractAInterface
, that only serves as a place to define the interface trait for Contract A.contract_a
contains a smart contract,ContractA
, that implements logic, and conforms to theContractAInterface
trait.contract_b
is another smart contract, implementing a different interface, and makes a call toContractA
, and cross-calls thecontract_a
function. This is also the only crate in the workspace with defined tests.
Let's take a look at each crate, and see how they all work together.
Contract A Interface: The Trait
The contract_a_interface
crate defines a trait containing a contract interface. This interface is defined separate from the implementation and only defines what the exported functions of the implementation.
The interface defines add
as the only function the contract will contain. The add
function requires two inputs, x
and y
, both u32
integers, and returns a u32
integer as well.
The use of contractclient
as an attribute macro on ContractAInterface
means a client will be created, conforming to this interface, that can be used by contracts existing outside of the contract_a_interface
crate. As you'll see later, the client, ContractAClient
, is used in the contract_b
crate to call the add
function.
Contract A: The Logic
The contract_a
crate contains the implementation for Contract A, defining for each function what it should actually do.
The implementation takes the value of x
, along with the value of y
, and performs a checked_add
, returning the sum of both numbers (while avoiding an overflow error).
The add
function doesn't require an Env
argument. That's totally fine! Env
has many useful features that are available to your smart contracts, if you need them. But, you're not at all required to use it, if you don't.
All that is required to make use of the previously defined ContractAInterface
is to use
the interface, define a ContractA
struct, and impl
the interface for that struct.
This crate uses the contractimpl
attribute macro on the ContractA
implementation, making the add
function public and invocable by others on the Stellar network.
Contract B: The Invocation
Now that we've created a trait in contract_a_interface
, and implemented it in contract_a
, we can use the contract_b
crate to invoke the add
function and get the sum of our integers.
We're creating and implementing an entirely new contract, ContractB
. We're skipping the trait, and getting right to the contract itself.
::: info
Defining the contract interface with a trait
separately is optional. It's a great way to share the interface of a contract, and a contract client, with multiple contracts in a multi-contract workspace.
:::
ContractB
has one function, add_with
, that requires three arguments:
contract: Address
- The address of a contract which implements that required client interface,ContractAInterface
.x: u32
andy: u32
- The two numbers we want to (safely) compute the sum of.
ContractB
invokes ContractA
's add
function, returning its value back to the original invoker. It's a bit of a long round-trip for this simple example, but it illustrates a really powerful way you can separate out interface/trait definitions from contract logic and share it and its client in a multi-contract workspace.
Practical Use-Case Examples
Beyond this simple example, this technique is versatile and useful. For example, this strategy could be used to:
- Create and reference a standardized, consistent token interface.
- Reuse a single interface that you want to incorporate across many different contracts.
Build the Contracts
To build the contracts into a set of .wasm
files, use the stellar contract build
command. Both workspace/contract_a
and workspace/contract_b
will be built, and you can use a single command, since our workspace defines its members
in the Cargo.toml
file:
stellar contract build
Two .wasm
files should be found in the workspace/target
directory:
target/wasm32-unknown-unknown/release/soroban_workspace_contract_a.wasm
target/wasm32-unknown-unknown/release/soroban_workspace_contract_b.wasm
The stellar-cli
knows the contract_a_interface
is not intended to be compiled into a .wasm, because the contract_a_interface
's Cargo.toml
has its crate-type
configured as rlib
(rust library). Nice!
Run the Contract
If you have stellar-cli
installed, you can invoke contract the functions. Both contracts must be deployed.
- macOS/Linux
- Windows (PowerShell)
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/soroban_workspace_contract_a.wasm \
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/soroban_workspace_contract_b.wasm \
stellar contract deploy `
--wasm target/wasm32-unknown-unknown/release/soroban_workspace_contract_a.wasm `
stellar contract deploy `
--wasm target/wasm32-unknown-unknown/release/soroban_workspace_contract_b.wasm `
Invoke ContractB
's add_with
function, passing in ContractA
's address for contract
, and integer values for x
and y
(e.g. as 5
and 7
).
- macOS/Linux
- Windows (PowerShell)
stellar contract invoke \
--id CONTRACT_B_ADDRESS \
-- \
add_with \
--contract CONTRACT_A_ADDRESS \
--x 5 \
--y 7
stellar contract invoke `
--id CONTRACT_B_ADDRESS `
-- `
add_with `
--contract CONTRACT_A_ADDRESS `
--x 5 `
--y 7