Developing smart contracts in Rust#
On the Concordium blockchain smart contracts are deployed as Wasm modules, but Wasm is designed primarily as a compilation target and is not convenient to write by hand. Instead we can write our smart contracts in the Rust programming language, which has good support for compiling to Wasm.
Smart contracts do not have to be written in Rust. This is simply the first SDK we provide. Manually written Wasm, or Wasm compiled from C, C++, AssemblyScript, and others, is equally valid on the chain, as long as it adheres to the Wasm limitations we impose.
See also
For more information on the functions described below, see the concordium_std API for writing smart contracts on the Concordium blockchain in Rust.
See also
See Smart contract modules for more information about smart contract modules.
A smart contract module is developed in Rust as a library crate, which is then
compiled to Wasm.
To obtain correct exports, the crate-type attribute must be set to
["cdylib", "rlib"]
in the Cargo.toml
file:
...
[lib]
crate-type = ["cdylib", "rlib"]
...
Writing a smart contract using concordium_std
#
It is recommended to use the concordium_std
crate, which provides a
more Rust-like experience for developing smart contract modules and calling
host functions.
The crate enables writing init and receive functions as simple Rust
functions annotated with #[init(...)]
and #[receive(...)]
, respectively.
Here is an example of a smart contract that implements a counter:
use concordium_std::*;
type State = u32;
#[init(contract = "counter")]
fn counter_init<S: HasStateApi>(
_ctx: &impl HasInitContext,
_state_builder: &mut StateBuilder<S>,
) -> InitResult<State> {
let state = 0;
Ok(state)
}
#[receive(contract = "counter", name = "increment", mutable)]
fn contract_receive<S: HasStateApi>(
ctx: &impl HasReceiveContext,
host: &mut impl HasHost<State, StateApiType = S>,
) -> ReceiveResult<()> {
ensure!(ctx.sender().matches_account(&ctx.owner())); // Only the owner can increment
*host.state_mut() += 1;
Ok(())
}
There are a number of things to notice:
The type of the functions:
An init function must be of type
(&impl HasInitContext, &mut StateBuilder) -> InitResult<MyState>
whereMyState
is a type that implements theSerialize
1 trait.A receive function, which, by default, cannot mutate the state, must take a
S: HasStateApi
type parameter, a&impl HasReceiveContext
, and a&impl HasHost<MyState, StateApiType = S>
parameter, and return aReceiveResult<MyReturnValue>
, whereMyReturnValue
is type that implementsSerialize
.A receive function can be allowed to mutate the state by adding the
mutable
attribute, in which case the host parameter becomes mutable:&mut impl HasHost<MyState, StateApiType = S>
. The other types and requirements remain unchanged as compared to the immutable receive functions.
The annotation
#[init(contract = "counter")]
marks the function it is applied to as the init function of the contract namedcounter
. Concretely, this means that behind the scenes this macro generates an exported function with the required signature and nameinit_counter
.#[receive(contract = "counter", name = "increment", mutable)]
deserializes and supplies the state to be manipulated directly. Behind the scenes this annotation also generates an exported function with namecounter.increment
that has the required signature, and does all of the boilerplate of deserializing the state into the required typeState
. Mutable receive functions also serialize and save the state once the function finishes. This means that you should only use themutable
attribute if it is necessary. Otherwise, the state will appear as having mutated and you will also pay for the cost of saving and serializing the state.
Note
Note that deserialization is not without cost, and in some cases the
user might want more fine-grained control over the use of host functions.
For such use cases the annotations support a low_level
option, which has
less overhead, but requires more from the user.
- 1(1,2)
If the state contains one or more of the types
StateBox
,StateMap
, orStateSet
, it should implementSerial
andDeserialWithState
instead. The difference is the deserialization, whereSerialize
is a combination of the traitsSerial
andDeserial
.State*
types are essentially pointers to data stored in state, and when serialized, only the pointer is written, while the values are stored in the state. To load the values again, the state context is needed, hence theDeserialWithState
.
Serializable state and parameters#
On-chain, the state of an instance is represented as a prefix tree, where nodes in the tree can have data in the form of a byte array. The instance uses functions provided by the host environment to create, delete, and find nodes in the tree. The host also provides functions for reading, writing, and resizing the byte array held by a particular node in the tree.
For simple contracts, the complete contract state is stored in the root node of
the state tree. For this to work, the state must implement the
Serialize
trait which contains (de-)serialization functions.
The concordium_std
crate includes this trait and implementations for most
types in the Rust standard library.
It also includes macros for deriving the trait for user-defined structs and
enums.
use concordium_std::*;
#[derive(Serialize)]
struct MyState {
...
}
For contracts that maintain a large state, it is often beneficial to split the
state into multiple nodes in the state tree.
concordium_std
crate provides ergonomic types for this purpose, namely StateMap
and
StateSet
.
Which provide an interface similar to that of a map and set.
These types cannot implement Serialize
, but they do implement Serial
and DeserialWithState
1.
concordium_std
also has a macros for deriving these two types for
user-defined structs and enums.
use concordium_std::*;
#[derive(Serial, DeserialWithState)]
#[concordium(state_parameter = S)]
struct MyState<S, T> {
a: StateBox<String, S>,
b: Vec<T>,
...
}
Parameters to init and receive functions must implement Serialize
, where as
the state must implement Serialize
or Serial + DeserialWithState
.
Note
Strictly speaking we only need to deserialize bytes to our parameter type, but it is convenient to be able to serialize types when writing unit tests.
Working with parameters#
Parameters to the init and receive functions are represented as byte arrays. While the byte arrays can be used directly, they can also be deserialized into structured data.
The simplest way to deserialize a parameter is through the get() function of the Get trait.
As an example, see the following contract in which the parameter
ReceiveParameter
is deserialized on the highlighted line:
use concordium_std::*;
type State = u32;
#[derive(Serialize)]
struct ReceiveParameter{
should_add: bool,
value: u32,
}
#[init(contract = "parameter_example")]
fn init<S: HasStateApi>(
_ctx: &impl HasInitContext,
_state_builder: &mut StateBuilder,
) -> InitResult<State> {
let initial_state = 0;
Ok(initial_state)
}
#[receive(contract = "parameter_example", name = "receive", mutable)]
fn receive<S: HasStateApi>(
ctx: &impl HasReceiveContext,
host: &mut impl HasHost<State, StateApiType = S>,
) -> ReceiveResult<()> {
let parameter: ReceiveParameter = ctx.parameter_cursor().get()?;
if parameter.should_add {
*host.state_mut() += parameter.value;
}
Ok(())
}
The receive function above is inefficient in that it deserializes the
value
even when it is not needed, i.e., when should_add
is false
.
To get more control, and in this case, more efficiency, we can deserialize the parameter using the Read trait:
#[receive(contract = "parameter_example", name = "receive_optimized", mutable)]
fn receive_optimized<S: HasStateApi>(
ctx: &impl HasReceiveContext,
host: &mut impl HasHost<State, StateApiType = S>,
) -> ReceiveResult<()> {
let mut cursor = ctx.parameter_cursor();
let should_add: bool = cursor.read_u8()? != 0;
if should_add {
// Only decode the value if it is needed.
let value: u32 = cursor.read_u32()?;
*host.state_mut() += value;
}
Ok(())
}
Notice that the value
is only deserialized if should_add
is
true
.
While the gain in efficiency is minimal in this example, it could have an
substantial impact for more complex examples.
Building a smart contract module with cargo-concordium
#
The Rust compiler has good support for compiling to Wasm using the
wasm32-unknown-unknown
target.
However, even when compiling with --release
the resulting build includes
large sections of debug information in custom sections, which are not useful for
smart contracts on-chain.
To optimize the build and allow for new features such as embedding schemas, we
recommend using cargo-concordium
to build smart contracts.
See also
For instructions on how to build using cargo-concordium
see
Compile a Rust smart contract module.
Best practices#
Avoid creating black holes#
A smart contract is not required to use the amount of CCD send to it, and by default a smart contract does not define any behavior for emptying the balance of an instance, in case someone were to send some CCD. These CCD would then be forever lost, and there would be no way to recover them.
Therefore it is good practice for smart contracts that are not dealing with CCD,
to ensure the sent amount of CCD is zero and reject any invocations which are
not.
Using the #[init(...)]
and #[receive(...)]
macros will help you in this
endeavour, as they will cause functions to return a NotPayble
error if
they receive a non-zero amount of CCD.
To enable receiving CCD for a function, use the payable
attribute in the
macro, e.g.: #[init(..., payable)]
and #[receive(..., payable)]
.