Intermediate

 

Smart contract annotations

Welcome to the tutorial on Smart Contract Annotations on MultiversX. In this tutorial, you will learn how to annotate your smart contracts on MultiversX to make them more user-friendly and accessible. Annotation is the process of adding meaningful descriptions and explanations to your smart contract code, making it easier for others to understand and interact with it.

Smart contracts are a critical component of decentralized applications (dApps) and are widely used in various industries, including finance, supply chain management, and gaming. With the help of annotations, developers can make their smart contracts more accessible to a wider audience, reducing the barriers to entry for users who are not familiar with the underlying code.

In this tutorial, you will learn how to add annotations to your smart contracts on MultiversX, including how to use the built-in tools and libraries to create, manage, and display annotations. Whether you are an experienced developer or just starting your journey in smart contract development, this tutorial will provide you with the necessary knowledge and skills to effectively use annotations in your projects.

Let’s get started!

Trait annotations

#[multiversx_sc::contract]

The contract annotation must always be placed on a trait and will automatically make that trait the main container for the smart contract endpoints and logic. There should be only one such trait defined per crate.

Note that the annotation takes no additional arguments.

#[multiversx_sc::module]

The module annotation must always be placed on a trait and will automatically make that trait a smart contract module.

Note that the annotation takes no additional arguments.

CAUTION : Only one contract, module or proxy annotation is allowed per Rust module. If they are in separate files there is no problem, but if several share a file, explicit mod module_name { ... } must enclose the module.

#[multiversx_sc::proxy]

The proxy annotation must always be placed on a trait and will automatically make that trait a smart contract call proxy. More about smart contract proxies in the contract call reference.

In short, contracts always get an auto-generated proxy. However, if such an auto-generated proxy of another contract is not available, it is possible to define such a “contract interface” by hand, using the proxy attribute.

Note that the annotation takes no additional arguments.

CAUTION : Only one contract, module or proxy annotation is allowed per Rust module. If they are in separate files there is no problem, but if several share a file, explicit mod proxy_name { ... } must enclose the module.

Method annotations

#[init]

Every smart contract needs one constructor that only gets called once when the contract is deployed. The method annotated with init is the constructor.

#[multiversx_sc::contract]
pub trait Example {
    #[init]
    fn this_is_the_constructor(
        constructor_arg_1: u32,
        constructor_arg_2: BigUint) {
        // ...
    }
}

NOTE : When upgrading a smart contract, the constructor in the new code is called. It is also called only once, and it can also never be called again.

#[endpoint]and#[view]

Endpoints are the public methods of contracts, which can be called in transactions. A contract can define any number of methods, but only those annotated with #[endpoint] or #[view] are visible to the outside world.

#[view] is meant to indicate read-only methods, but this is currently not enforced in any way. Functionally, #[view] and #[endpoint] is currently perfectly synonymous. However, there are plans for the future to enforce views to be verified at compile time to be read-only. When that happens, smart contracts that will already have been correctly annotated will be easier to migrate. Until then, there is still value in having 2 annotations, since they indicate intent.

If no arguments are provided to the attribute, the name of the Rust method will be the name of the endpoint. Alternatively, an explicit endpoint name can be provided in brackets.

Example:

#[multiversx_sc::contract]
pub trait Example {
    #[endpoint]
    fn example(&self) {
    }

    #[endpoint(camelCaseEndpointName)]
    fn snake_case_method_name(&self, value: BigUint) {
    }

    fn private_method(&self, value: &BigUint) {
    }

    #[view(getData)]
    fn get_data(&self) -> u32{
        0
    }

In this example, 3 methods are public endpoints. They are named example, camelCaseEndpointName and getData. All other names are internal and do not show up in the resulting contract.

NOTE : All endpoint arguments and results must be either serializable or special endpoint argument types such as MultiValueEncoded. They must also all implement the TypeAbi trait. There is no such restriction for private methods.

Callbacks

There are 2 annotations for callbacks: #[callback] and #[callback_raw]. The second is only used in extreme cases.

Callbacks are special methods that get called automatically when the response comes after an asynchronous contract call. They give the contract the possibility to react to the result of a cross-shard call, but for consistency they get called the same way if the asynchronous call happens in the same shard.

They also act as closures, since they can retain some of the context of the transaction that performed the asynchronous call in the first place.

A more detailed explanation on how they work in the contract calls reference.

Storage

It is possible for a developer to access storage manually in a contract, but this is error-prone and involves a lot of boilerplate code. For this reason, multiversx-sc offers storage annotations that manage and serialize the keys and values behind the scenes.

Each contract has a storage where arbitrary data can be stored on-chain. This storage is organized as a map of arbitrary length keys and values. The blockchain has no concept of storage key or value types, they are all stored as raw bytes. It is the job of the contract to interpret these values.

All trait methods annotated for storage handling must have no implementation.

#[storage_get("key")]

This is the simplest way to retrieve data from the storage. Let’s start with an example of usage:

#[multiversx_sc::contract]
pub trait Adder {
    #[view(getSum)]
    #[storage_get("sum")]
    fn get_sum(&self) -> BigUint;

    #[storage_get("example_map")]
    fn get_value(&self, key_1: u32, key_2: u32) -> SerializableType;

First off, please note that a storage method can also be annotated with #[view] or #[endpoint]. The endpoint annotations refer to the role of the method in the contract, while the storage annotation refers to its implementation, so there is no overlap.

Then, also note that there are 2 ways to use this annotation. In the first example, we simply specify the key in the annotation and from here on the method will always read from the same storage key, "sum" in this case.

In the second example the get method also takes some arguments. Any number of arguments is allowed. These get concatenated to the base key to form a composite key, effectively turning a section of the contract storage into a dictionary or map.

For instance calling self.get_value(1, 2) will retrieve from the storage key "example_map\x00\x00\x00\x01\x00\x00\x00\x02" or 0x6578616d706c655f6d61700000000100000002. self.get_value(1, 3) will read from a different place in storage, and so on.

This is the easiest way to get the equivalent of a HashMap in a smart contract.

Lastly, storage getters must always return a deserializable type. The framework will automatically deserialize the object from whatever bytes it finds in the storage value.

#[storage_set("key")]

This is the simplest way to write data to storage. Example:

#[multiversx_sc::contract]
pub trait Adder {
    #[storage_set("sum")]
    fn set_sum(&self, sum: &BigUint);

    #[storage_set("example_map")]
    fn set_value(&self, key_1: u32, key_2: u32, value: &SerializableType);

It works very similarly to storage_get, with the notable difference that instead of returning a value, the value must be provided as an argument. The value to store is always the last argument.

Again, just like for the getter, an arbitrary number of additional map keys can be specified, as for set_value in the example. This is how we can write values to a section of our storage that behaves like a map.

#[storage_mapper("key")]

Storage mappers are objects that can manage multiple storage keys at once. They are in charge with both reading and writing values. Some of them read and write values to multiple storage keys at once.

There are many storage mappers in the framework and more can be custom-defined.

Example:

#[storage_mapper("user_status")]
fn user_status(&self) -> SingleValueMapper<UserStatus>;

#[storage_mapper("list_mapper")]
fn list_mapper(&self, sub_key: usize) -> LinkedListMapper<u32>;

The SingleValueMapper is the simplest of them all, since it only manages one storage key. Even though it only works with one storage entry, its syntax is more compact than storage_get/storage_set so it is used quite a lot.

In the LinkedListMapper we are dealing with a list of items, each with its own key.

Also note that additional sub-keys are also allowed for storage mappers, the same as for storage_get and storage_set.

#[storage_is_empty("key")]

This is very similar to storage_get, but instead of retrieving the value, it returns a boolean indicating whether the serialized value is empty or not. It does not attempt to deserialize the value, so it can be faster and more resilient than storage_get, depending on type.

#[storage_is_empty("opt_addr")]
fn is_empty_opt_addr(&self) -> bool;

Nowadays, it is more common to use storage mappers. The SingleValueMapper has an is_empty() method that does the same.

#[storage_clear("key")]

This is very similar to storage_set, but instead of serializing and writing the storage value, it simply clears the raw bytes. It does not do any serializing, so it can be faster than storage_set, depending on type.

#[storage_clear("field_to_clear")]
fn clear_storage_value(&self);

Nowadays, it is more common to use storage mappers. The SingleValueMapper has an clear() method that does the same.

Events

Events are a way of returning data from a smart contract, by leaving a trace of what happened during the execution. Event logs are not saved on the blockchain, but a hash of them is. This means that we can always check whether certain events were emitted by a transaction or not.

Because they are not saved on the chain in full, they are also a lot cheaper than storage.

In smart contracts we define them as trait methods with no implementation, as follows:

#[event("transfer")]
fn transfer_event(
    &self,
    #[indexed] from: &ManagedAddress,
    #[indexed] to: &ManagedAddress,
    #[indexed] token_id: u32,
    data: ManagedBuffer,
);

The annotation always requires the name of the event to be specified explicitly in brackets.

Events have 2 types of arguments:

  • “Topics” are annotated with #[indexed]. When saving event logs to a database, indexes will be created for all these fields, so they can be searched for efficiently.

  • The “data” argument has no annotation. There can be only one data field in an event, and it cannot be indexed later.

Event arguments (fields) can be of any serializable type. There is no return value for events.

Events (legacy)

There is a legacy annotation, #[legacy_event] still used by some older contracts. It is deprecated and should no longer be used.

#[proxy]

This is a simple getter, which provides a convenient instance of a contract proxy. It is used when wanting to call another contract.

#[multiversx_sc::module]
pub trait ForwarderAsyncCallModule {
    #[proxy]
    fn vault_proxy(&self, to: Address) -> vault::Proxy<Self::Api>;

    // ...
}

There is no need for arguments, the annotation will figure out the contract to call by the provided return type.

INFO : Proxy types need to be specified with an explicit module. In the example vault:: is compulsory.

#[output_names]

This one is used for ABI result names. In Rust, it is impossible to write Rust Docs for method returns, so we are using this annotation to optionally name the outputs of an endpoint.

By this, you complete this workshop successfully!!