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 you can write your 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 provided. 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 Concordium imposes.
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(
_ctx: &impl HasInitContext,
) -> InitResult<State> {
let state = 0;
Ok(state)
}
#[receive(contract = "counter", name = "increment")]
fn contract_receive<A: HasActions>(
ctx: &impl HasReceiveContext,
state: &mut State,
) -> ReceiveResult<A> {
ensure!(ctx.sender().matches_account(&ctx.owner())); // Only the owner can increment
*state += 1;
Ok(A::accept())
}
There are a number of things to notice:
The type of the functions:
An init function must be of type
&impl HasInitContext -> InitResult<MyState>
whereMyState
is a type that implements theSerialize
trait.A receive function must take a
A: HasActions
type parameter, a&impl HasReceiveContext
and a&mut MyState
parameter, and return aReceiveResult<A>
.
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")]
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
.
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.
Serializable state and parameters#
On-chain, the state of an instance is represented as a byte array and exposed
in a similar interface as the File
interface of the Rust standard library.
This can be done using 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 {
...
}
The same is necessary for parameters to init and receive functions.
Note
Strictly speaking you 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, like the instance state, 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(
_ctx: &impl HasInitContext,
) -> InitResult<State> {
let initial_state = 0;
Ok(initial_state)
}
#[receive(contract = "parameter_example", name = "receive")]
fn receive<A: HasActions>(
ctx: &impl HasReceiveContext,
state: &mut State,
) -> ReceiveResult<A> {
let parameter: ReceiveParameter = ctx.parameter_cursor().get()?;
if parameter.should_add {
*state += parameter.value;
}
Ok(A::accept())
}
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, you can deserialize the parameter using the Read trait:
#[receive(contract = "parameter_example", name = "receive_optimized")]
fn receive_optimized<A: HasActions>(
ctx: &impl HasReceiveContext,
state: &mut State,
) -> ReceiveResult<A> {
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()?;
*state += value;
}
Ok(A::accept())
}
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.
Parameters have a size limit of 65535B. There is no return value size limit (apart from energy).
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, Concordium
recommends 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.