All contracts need to begin with the following imports
The imports!() macro comes from the elrond-wasm crate and automatically imports everything you need. It includes the following
The contract logic itself is contained within a trait annotated as follows:
All appropriately annotated functions within this trait form the public interface of the smart contract.
There must be only one function annotated with #[init]
. This called once only when the contract is deployed and can be used to set initial values in the storage.
Like all contract functions it has access to self
which exposes info and functions on the contract. This can be used to call other functions or some of the exposed functionality such as self.get_caller()
.
These are generic public exposed methods that cannot mutate the state, only provide a convenient way to read certain parts of the state. These do not cost gas to call.
The attribute can take an optional argument to rename the exposed contract method
These are generic public exposed functions that can be called on the smart contract by making transactions. These are allowed to mutate the contract state and therefore cost gas. The attribute can also take an optional argument to change the name of the exposed contract method from that implemented in the Rust code e.g. #[endpoint(transferFunds)]
.
Endpoints can also be payable
that is to say the transaction to call them can also include ERD which the contract can accept and possibly transfer. By default, any function in a smart contract is not payable, i.e. sending a sum of ERD to the contract using the function will cause the transaction to be rejected. Payable functions need to be annotated with #[payable]
. Notice the #[payment] payment: &BigUint
argument. This is not a real argument, but just syntactic sugar to pass the payed sum to the function.
This is used to annotate a function without a body and convert it into a setter for a particular key in the contract storage.
Providing multiple key arguments makes it possible to define a map in the storage. all but the last argument will become part of the storage key. Any number of such key arguments can be added. The resulting storage key will be a concatenation of the specified base key "<storage-key>" and the serialized argument delimited by a pipe ('|') character.
Pretty self explanatory, can be used to read a value from the contract store. There is also the #storage_get_mut
macro. This will return a mutable reference to a variable in the store. This variable can be modified and when it goes out of scope it will mutate the store.
Contracts can trigger events which the node running them can listen for. The event macro takes a single argument which is the local address for the event (?).
Events are function and can accept multiple arguments of accepted types which form the topics of the event. Events can be triggered from elsewhere in the contract by calling them on self
As contracts get larger it is nice to split them into multiple files. This is where modules come in.
Declaring a module is similar to declering a contract.
This module can then be included in a contract by importing it in the rust .lib file and then using the #module
attribute macro on a function within the contract trait.
Modules can be used inside other modules if their functionality is required. Presumably this will be de-duped at compile time by the contract macro.
Functions in other modules can be called by first calling their module function. For example in the main contract the call functions can be called as
All of the following methods are available on self
during contract function execution. Most are pretty self explanatory
Calling between contracts is a little more involved in Elrond compared with Ethereum. This is presumable due to the complexity of making calls between shards? (unconfirmed).
In Elrond calls are made asynchronously so rather than blocking the current execution a callback must be provided to follow up on the results of a cross-contract call.
Before you can make a call to another contract you must first define a new Rust trait called a 'proxy'. This is a type safe way to describe the functions you will be calling in another contract and also a way to wire up the callback. It is not nesseary to re-implement all of the public function in the other contract, just the ones you want to call.
Say we had an ERC20 style contract and we wanted to call to transfer some tokens. The proxy we would need to define is as follows:
Using this to call the function in a foreign contract requires we know its address. In our own contract code we can make the call by calling
As mentioned this call will happen asynchronously. It will not block the current execution or yield a result. In this case we need to use callbacks to change our contract state based on the success/failure of a foreign contract call.
Callbacks are registered on the proxy trait using the #[callback(<callback_fn_name>)]
attrubute. To set up a callback to run after a token transfer
and inside our contract trait
One problem with this configuration is it doesn't distinguish between contract calls. In this case we have no way of knowing who the token were transferred to in the callback code. To allow this the SDK includes callback args. These are special arguments that can be added to the proxy that will be passed directly to the callback when it is invoked. For example if we want the callback to know who the tokens were transferred to
and inside our contract trait
Now we need to make the call as
The Elrond SDK ships with a custom testing framework that executes the contract WASM on the Arwen VM to simulate a real network with multiple agents. The tests are defined as json files describing the steps to run in and checks to make.
The general form for a .scen.json
object is:
The following step types are supported. There may be more that I don't yet know about
This is a powerful step that can fully define the state of the blockchain. It can create accounts or contracts, set the state of those accounts/contracts and probably more.
It can contain the following sub-fields
This is a map between addresses and accounts. Each account can be defined with initial nonce, balance, storage and code. This means contract or regular accounts can be defined. It is probably better practice to use "scDeploy"
to deploy contracts so that the init function is run.
This is a helper to make tests easier to read and remove hard-coded addresses that need to change.
Basically it is setting up the VM to produce newAddress
as the address of the transaction by creatorAddress
with creatorNonce
. The must useful application of this is to ensure contracts get deployed to a particular address which is then called later.
Deploy a smart contract
example:
Call a function on a particular smart contract address
example
Not a step in itself but the scCall
and scDeploy
steps can also include an expect
field with the following structure. The test will fail if these are not met.
The Logs field lists the events triggered in the call with the form:
This is non state mutating step that only checks the state of any of the accounts/contracts.
example:
"externalSteps"
allows us to import steps from another json file. This is very handy, because we can write test scenarios that branch out from each other without having to duplicate code. Here we will be reusing the deployment steps in all tests. These imported steps get executed again each time they are imported.
If the other steps are in a .json
rather than a .scen.json
they will not be run on their own by the test runner which is most likely the desired result.
It is also possible to write tests in Rust using the elrond-wasm-debug
crate. These do not run the contract logic after it is compiled to WASM but rather natively compile the rust code with a test simulated blockchain. Because of this they are not as true-to-real test.
Note: These are not practical at this stage as it is not possible to call function that accept BitUint as an argument.
Elrond deals exclusively in raw bytes. For fixed length types such as u32
, bool
etc it is easy to write these when encoding parameters for a contract call.
Things get a bit more interesting when passing variable length types such BigUint which are variable length.
In this case the first 4 bytes encode the length (big endian). The remainder of the encoding should contain as many bytes as specified and these encode the number. In the Mandos tests these can be written using concatenation syntax. 0x00000003|0x112233
represents a BigUint that is three bytes long and those three bytes are 0x112233 (1,122,867 in decimal).
Rust structs can be used as parameters for contract calls and the macro will automatically deal with deserializing these from the raw calling bytes. In a struct the fields are effectively concatenated together to form the calling bytes.
e.g.
is expressed as ''bob_____________________________|0x001|0