Writing the piggy bank smart contract#
This is the first part of a tutorial on smart contract
development. In this part you will focus on how to write a smart contract in the
Rust programming language using the concordium-std
library.
Preparation#
Set up a new smart contract project by following the guide Set up a smart contract project and return to this point afterwards.
Bring in the standard library#
The source code of your smart contract is going to be in the src
directory,
which already contains a lib.rs
file, assuming you follow the above guide
to set up your project.
The smart contract template also includes some example tests under the tests
directory,
which you can delete for now. You will come back to tests later in this tutorial.
In lib.rs
, start by bringing everything from the concordium-std
library into scope
by adding the line:
use concordium_std::*;
This library contains everything needed to write a smart contract. It provides convenient wrappers around some low-level operations making your code more readable, and although it is not strictly necessary to use this, it will save a lot of code for the vast majority of contract developers.
Piggy bank contract#
The contract you are going to build in this tutorial is going to act as a classic piggy bank. Everyone should be able to put money in the form of CCD into it, but only the owner can smash it and retrieve the CCD inside. Once the piggy bank has been smashed, it should not be possible to add CCD to it.
The piggy-bank smart contract is going to contain a function for setting up a new piggy bank and two functions for updating a piggy bank; one is for everyone to use for inserting CCD, the other is for the owner to smash the piggy bank and prevent further interaction. It will also contain a method for everyone to view the current state and balance of the piggy bank.
Specifying the state#
To implement a piggy bank you need to keep track of the amount of CCD it holds, and you need to know whether it has been smashed. The blockchain will take care of the first task for you since the chain keeps track of the balance of each smart-contract instance. Therefore, you only need to maintain whether the piggy bank has been smashed, which you do as part of the smart contract state.
In Rust you represent this state as an enum with a variant for the piggy bank being intact and one for it being smashed:
enum PiggyBankState {
Intact,
Smashed,
}
Since the state of your smart contract is going to be stored on the blockchain,
you need to specify how the contract state should be serialized.
When using the concordium-std
library, this all boils down to your type
for the contract state having to implement the Serialize
[1] trait exposed by
concordium-std
.
Luckily the library already contains implementations for most of the primitives
and standard types in Rust, and a procedural macro for deriving
Serialize
for most cases of enums and structs:
#[derive(Serialize)]
enum PiggyBankState {
Intact,
Smashed,
}
Later in this tutorial, you will also need to check the state for equality and
return a copy of the state, so you also derive the trait implementation for
PartialEq
, Eq
, Clone
, and Copy
:
#[derive(Serialize, PartialEq, Eq, Debug, Clone, Copy)]
enum PiggyBankState {
Intact,
Smashed,
}
Set up a piggy bank#
Now you will write the function to set up a new piggy bank, which in turn means specifying the init function for a smart contract. A smart contract must specify an init function, which is called when new instances of the contract are created, and is used to set up the initial state of the contract instance.
Note
A Rust developer could compare init functions with the convention of
having a new
function for types, and the smart contract as the type.
If you have experience with object-oriented programming, it might help to think of a smart contract as a class, the init function as a constructor, and smart contract instances as objects.
In the case of the piggy bank; the initial state should be set to Intact
.
The #[init]
macro#
In Rust an init function can be specified as a regular function that is annotated
with a procedural macro from concordium-std
called #[init]
.
This allows you to create a new piggy bank as follows:
#[init(contract = "PiggyBank")]
fn piggy_init(
_ctx: &InitContext,
_state_builder: &mut StateBuilder,
) -> InitResult<PiggyBankState> {
Ok(PiggyBankState::Intact)
}
The macro saves you the details of setting up the function as an external function and serializing the state to bytes. The macro also provides an interface for accessing information about the context of the smart-contract call.
The init function requires a name for the smart contract, in this case
"PiggyBank"
. The name [2] is used as part of the exported
function, and is how you distinguish this smart contract from other smart
contracts in your module.
#[init(contract = "PiggyBank")]
The init function takes two arguments:
ctx: &InitContext
, which is a struct with a number of getter functions for accessing information about the current context, such as the account that invoked this contract, the supplied arguments, and information about the state of the blockchainstate_builder: &mut StateBuilder
, which has functions for creating sets, maps, and boxes that effectively utilize the way contract state is stored on the chain.
The return type of the function is InitResult<PiggyBankState>
, which is an
alias for Result<PiggyBankState, Reject>
. The returned state is serialized
and set as the initial state of the smart contract.
Initializing the piggy bank state to be Intact
is then straightforward:
Ok(PiggyBankState::Intact)
A more complex smart contract would take a parameter and check during initialization that everything is set up as expected. This will be described later.
The contract name is only allowed to consist of ASCII alphanumeric or
punctuation characters, and is not allowed to contain the .
symbol. The function name is only allowed to consist of ASCII alphanumeric or punctuation characters. The function name together with contract name must not exceed 99 characters.
Define interaction with piggy banks#
You have now defined how instances of your smart contract are created, and the smart contract is in principle a valid contract. However, you would also like to define how to interact with instances of your contract, specifically how to add CCD to it and how to smash a piggy bank.
A smart contract can expose zero or more functions for interacting with an instance. These functions are called receive functions. They can access the state of the instance and the blockchain and perform actions, such as transferring CCD to an account or invoking another contract instance. Receive functions are immutable/readonly by default, which means that they cannot mutate the state. You will look at mutable receive methods when it’s time to implement smashing the piggy bank.
Note
For a Rust developer, receive functions are like methods with a reference to self. The reference is immutable by default, and mutable for mutable receive functions.
A continuation of the analogy to object-oriented programming: receive functions correspond to object methods.
The #[receive(...)]
macro#
In Rust, receive functions can be specified using the procedural macro
#[receive]
, which, like #[init]
, is used to annotate a function and sets up an
external function and supplies you with an interface for accessing the context.
But, unlike the #[init]
macro, the function for #[receive]
is supplied with
a reference to the host (through which you can access the state of the instance):
#[receive(contract = "MyContract", name = "some_interaction")]
fn some_receive(
ctx: &ReceiveContext,
host: &Host<MyState>,
) -> ReceiveResult<MyReturnValue> {
todo!()
}
The contract
attribute supplies the name of the contract to the macro.
This name should match the name in the corresponding attribute in #[init]
("PiggyBank"
in our case). It also requires a name to identify this
particular receive function using name
. The name and contract attributes
each have to be unique within a smart contract module.
The return type of the function is ReceiveResult<MyReturnValue>
, which is an alias for
Result<MyReturnValue, Reject>
.
You will learn more about return values when implementing a view function for
the piggy bank.
Inserting CCD#
The first interaction you will specify for your piggy bank is how to insert CCD into it. You start by defining a receive function as:
#[receive(contract = "PiggyBank", name = "insert")]
fn piggy_insert(
_ctx: &ReceiveContext,
host: &Host<PiggyBankState>,
) -> ReceiveResult<()> {
todo!()
}
Make sure that the contract name matches the one you use for the #[init]
macro,
and name the receive function insert
.
The function will not need to use the ctx
context, so by convention, you
prefix the argument with _
.
In the function body you have to make sure that the piggy bank is still intact: the smart contract should reject any messages if the piggy bank is smashed:
if *host.state() == PiggyBankState::Smashed {
return Err(Reject::default());
}
Since returning early is a common pattern when writing smart contracts, and in
Rust in general, concordium-std
exposes a bail!
macro:
if *host.state() == PiggyBankState::Smashed {
bail!();
}
Furthermore, you can use the ensure!
macro for returning early depending on a condition:
ensure!(*host.state() == PiggyBankState::Intact);
From this line, you will know that the state of the piggy bank is intact and all you have left to do is accept the incoming amount of CCD. The CCD balance is maintained by the blockchain, so there is no need for you to maintain this in your contract. The contract just needs to produce an empty return value:
Ok(())
So far you have the following definition of the receive function:
#[receive(contract = "PiggyBank", name = "insert")]
fn piggy_insert(
_ctx: &ReceiveContext,
host: &Host<PiggyBankState>,
) -> ReceiveResult<()> {
ensure!(*host.state() == PiggyBankState::Intact);
Ok(())
}
The definition of how to add CCD to the piggy bank is almost done, but one important detail is
missing.
If you were to send CCD to the current smart contract, the transaction
would be rejected. This is a safety feature of concordium-std
,
which, by default, prevents init and receive functions
from accepting CCD.
The reason for rejecting CCD by default is to reduce the risk of creating a smart contract that accepts CCD without the ability to retrieve it again; any CCD passed to such a contract would be inaccessible forever.
To be able to accept CCD, you have to add the payable
attribute to the #[receive]
macro.
Now the function is required to
take an extra argument amount: Amount
, which represents the amount that is passed to the receive
function.
#[receive(contract = "PiggyBank", name = "insert", payable)]
fn piggy_insert(
_ctx: &ReceiveContext,
host: &Host<PiggyBankState>,
_amount: Amount,
) -> ReceiveResult<()> {
ensure!(*host.state() == PiggyBankState::Intact);
Ok(())
}
As mentioned above, since the blockchain is maintaining the balance of our smart contract, you
do not have to do that yourself, and the amount
is not used by your contract.
Note
The payable
attribute also exists for the #[init]
macro.
Smashing a piggy bank#
Now that you can insert CCD into a piggy bank, you also need to define how to smash one. Remember, you only want the owner of the piggy bank (smart contract instance) to be able to smash it and only if it isn’t already smashed. It should set its state to be smashed and transfer all of its CCD to the owner.
Again you use the #[receive]
macro to define the smash function:
#[receive(contract = "PiggyBank", name = "smash")]
fn piggy_smash(
ctx: &ReceiveContext,
host: &Host<PiggyBankState>,
) -> ReceiveResult<()> {
todo!()
}
Ensure that the contract name matches the one of your smart contract and name this function smash
.
To access the contract owner, use a getter function exposed by the context
ctx
:
let owner = ctx.owner();
This returns the account address of the contract instance owner, i.e. the account that created the smart contract instance by invoking the init function.
Similarly, the context has a getter function to access the sender of the current message that triggered this receive function:
let sender = ctx.sender();
Since both accounts and smart-contract instances are capable of sending messages,
sender
is of the Address
type, which is either an account
address or a contract instance address.
To compare the sender
with owner
you can use the matches_account
function defined on the sender
, which will only return true if the sender is
an account address that is equal to the owner:
ensure!(sender.matches_account(&owner));
Next, ensure that the state of the piggy bank is Intact
, just like previously:
ensure!(*host.state() == PiggyBankState::Intact);
At this point you know the piggy bank is still intact and the sender is the
owner, meaning you now get to the smashing part.
But there is one problem.
The state is immutable, so you first need to make the receive function mutable by
adding the mutable
attribute to the #[receive]
macro.
#[receive(contract = "PiggyBank", name = "smash", mutable)]
fn piggy_smash(
ctx: &ReceiveContext,
host: &mut Host<PiggyBankState>,
) -> ReceiveResult<()> {
let owner = ctx.owner();
let sender = ctx.sender();
ensure!(sender.matches_account(&owner));
ensure!(*host.state() == PiggyBankState::Intact);
todo!()
}
This gives you a mutable reference to the host
, through which you can access
the mutable state with the state_mut
function. You then set the state to
Smashed
, preventing further insertions of CCD:
*host.state_mut() = PiggyBankState::Smashed;
Lastly, you need to empty the piggy bank. To do that, transfer all the CCD of the smart-contract instance to an account.
To transfer CCD from a smart contract instance you use the invoke_transfer
method on the host
. For this, you need to provide the address of the receiving
account and the amount to transfer.
In this case the receiver is the owner of the piggy bank and the amount is the
entire balance of the piggy bank.
The host
has a getter function for reading
the current balance of the smart contract instance, which is called
self_balance
:
let balance = host.self_balance();
You already have a variable with the address of the contract owner, so you can use that to invoke the transfer:
Ok(host.invoke_transfer(&owner, balance)?)
A transfer can fail in two ways, either your contract has insufficient funds, or
the receiver account does not exist. Neither can occur in this contract. This is
because it tries to transfer its own balance, and because a contract always has
a valid owner. The code propagates the error out with the ?
, which will
become useful when testing the contract.
The final definition of the “smash” receive function is then:
#[receive(contract = "PiggyBank", name = "smash", mutable)]
fn piggy_smash(
ctx: &ReceiveContext,
host: &mut Host<PiggyBankState>,
) -> ReceiveResult<()> {
let owner = ctx.owner();
let sender = ctx.sender();
ensure!(sender.matches_account(&owner));
ensure!(*host.state() == PiggyBankState::Intact);
*host.state_mut() = PiggyBankState::Smashed;
let balance = host.self_balance();
Ok(host.invoke_transfer(&owner, balance)?)
}
Viewing the state#
Now that you can smash and insert CCD into a piggy bank, you can add a way to check the current state and balance of the piggy bank. This is what the return values of receive methods are for:
#[receive(contract = "PiggyBank", name = "view")]
fn piggy_view(
_ctx: &ReceiveContext,
host: &Host<PiggyBankState>,
) -> ReceiveResult<(PiggyBankState, Amount)> {
let current_state = *host.state();
let current_balance = host.self_balance();
Ok((current_state, current_balance))
}
The piggy_view
method gets a copy of the state and the balance and returns
it as a tuple of type (PiggyBankState, Amount)
, which is also specified in
the return type.
A more complex smart contract might have multiple view functions that return different parts of the state or return a value computed from the state.
Note
To view return values of a contract instance on the chain, see the guide Invoke a smart contract instance.
You now have all the parts for your piggy bank smart contract. Before you start testing it, check that it builds by running:
$cargo concordium build
This should succeed if everything is set up correctly. Otherwise, compare your code with the one found here.
To continue with the tutorial click here.