This is the second part is a series of posts about building a chat app with Rust on the Near blockchain. You can find the first post in the series here.
In this post we will focus on the smart contract itself. We’ll see the near-sdk library that makes our code work on Near. We’ll also see Near state access patterns and the smart contract development principles in action by reviewing the code of this smart contract. You can find the complete repository with all the code we’ll discuss today on my GitHub.
Near’s smart contract Rust SDK
At its core Near’s smart contract runtime uses WebAssembly (Wasm). Wasm is a well-established bytecode format that is used outside of blockchain as well, such as in web apps. This is good for Near because its runtime can benefit from the work that is done in broader Wasm community.
The Rust compiler does a good job at generating Wasm output, but there needs to be some scaffolding around it for the Wasm bytecode to work properly with its “host” (the Near runtime in our case, or a web browser’s JavaScript engine in the case of a web app). This scaffolding can be automatically generated using convenient Rust libraries: wasm-bindgen in the case of browser integration, and near-sdk in the case of Near. The smart contract we are working with today is written using near-sdk.
Both libraries make use of Rust procedural macros (proc macros). This is a kind of metaprogramming where the library defines small annotations we can use to trigger Rust code to be automatically generated for us. Rust’s proc macros are used to reduce the amount of boilerplate code the developer needs to write to get their business logic working. For example, the derive proc macro is core to the Rust language. It can automatically define common functionality on new data types you create. You can see this used in the following simple code snippet from the chat smart contract:
You can see many traits listed in the derive
annotation. To call out some specific ones: Debug
means the MessageStatus
type can be converted to a string to help in debugging code; Clone
means it is possible to create an identical MessageStatus
instance from the current one, and Copy
means that Clone
operation is cheap; PartialEq
and Eq
mean that you can compare two MessageStatus instances to see if they are the same. The Serialize
and Deserialize
traits come from the serde library, which is ubiquitous in the Rust ecosystem for encoding/decoding data from formats like JSON or CBOR. We’ll come back to the Borsh traits later.
Thus far, this has all been standard Rust that you will find in any project. The Near-specific proc macro is near_bindgen
which you can see used in the following code snippet:
The near_bindgen
proc macro automatically generates the extra code we need so that when we compile to Wasm we get an output that the Near runtime understands how to use. It is used in multiple places where such glue code is needed. Here it marks the MessengerContract
struct as having the state needed to execute the methods of the contract. An instance of the MessengerContract
struct will be created each time we call a method on our smart contract. We’ll discuss what some of these fields are used for later.
The near_bindgen
macro is also used over the impl block for the MessengerContract
struct:
Here it means that the functions defined in this block are the methods we want exposed on our smart contract. It allows users of the Near blockchain to submit transactions calling these functions by name. For example, the method for sending a message is defined in this block. We’ll look at some other methods from this block in more detail below.
In summary, the near-sdk rust library provides a proc macro called near_bindgen
to automatically generate glue code that makes the Wasm output work with the Near runtime. This macro can be used on a struct to define the state of your contract and on that struct’s impl block to define the public methods on your contract. Near-sdk provides other useful functions and structures as well, which we’ll see in the subsequent sections.
Smart contract state
Essentially all non-trivial smart contracts require some state to operate correctly. For example, a token contract needs to maintain the balances of the various token holders. Our chat contract is no different. We saw in the previous section that the MessengerContract
struct contained many fields. In this section we discuss some general features of state in Near’s runtime as well as some specifics as to how it is used in the example smart contract.
The most important thing to know about smart contract state in Near is that it is a simple key-value storage. You can see this in the low-level storage_read and storage_write functions exposed by near-sdk. However, you can build some more sophisticated data structures on top of this simple foundation, and near-sdk provides some of these in its collections module. For this reason, our example contract does not use the key-value storage directly; instead it makes use of the higher-level collections offered by near-sdk.
For example, the smart contract keeps track of the status of the accounts it knows about (which ones are contracts, which we have sent a contact request to, etc.). The accounts field of MessengerContract
is a LookupMap
structure from near-sdk. This is pretty close to directly using the key-value storage since the map is also simply a way of looking up a value from a key, but the LookupMap
does two important things above the raw key-value storage interface. First it has a prefix it includes on all storage keys related to this map. Using a prefix prevents mixing up keys from this map with keys from another (for example the last_received_message
map, which is also keyed on AccountId
). Second, the LookupMap
lets us work with higher level Rust types whereas the raw storage interface works with bytes only. This is achieved by using Borsh serialization to convert the types to/from binary strings. Borsh is a serialization format designed by Near to be useful in blockchain applications specifically. This usage of Borsh is why you see BorshDeserialize
and BorshSerialize
derived on many types throughout the code.
A more interesting example of a collection used here is the UnorderedSet
used in the unread_messages
field. This is used for the contract to keep track of which messages are still unread. The UnorderedSet
is still built on the underlying key-value storage, but it effectively only uses the keys as we only care if an element is in the set or not. The structure also keeps metadata about what keys it is using to allow us to iterate over all the keys in the set.
Checking the environment and calling other contracts
In this section we discuss general features of Near’s runtime environment and making cross-contract calls. To keep us grounded, this is done in the context of how users add each other as contacts in our chat application. Let’s take a look at the add_contact
function definition (this definition is in the MessengerContact
impl block, with the near_bindgen
annotation mentioned above, because it is a main entry point for our contract).
There is a lot to unpack in these few lines of code. As additional framing to guide our discussion, recall the three principles of smart contract development outlined in the previous post:
- an adversarial mindset,
- economics,
- ensure invariants before making cross-contract calls.
Go back and review the first post if you need a refresher on what these principles were about. Each of these principles makes an appearance in this function.
An adversarial mindset
All smart contract methods are public and we must enforce access control when the method makes a sensitive action, otherwise someone will misuse the functionality. In this case we do not want anyone to be able to add contacts on the owner’s behalf; only the owner should be able to decide who to connect with (if someone else wants to make contacts in the chat network they can deploy this contract to their own account!). Therefore, we have the require_owner_only()
call right at the top of the function body. The implementation of this function is simple:
It makes use of the predecessor_account_id
function rom the env module of near-sdk. The env modules contains many useful functions for querying aspects of the Near runtime environment our contract is executing in. For example here we are checking which account made the call to our contract. The env module contains other useful functions such as for checking the account ID of our contract itself, and how many Near tokens were attached to this call. I recommend reading the module’s documentation to see all the functions that are available.
For efficiency reasons the require_owner_only
function also returns the predecessor account (to avoid multiple calls to env::predecessor_account_id()
in case an owner-only function also needs the predecessor account for another reason).
Economics
The very first line of the add_contact
code snippet above includes the payable
attribute. Using this annotation is enabled by the function being defined as part of a near_bindgen
impl block. It means that this method will accept Near tokens from the users that call it. These tokens are needed because we made the decision that users are paying for actions like creating state on-chain. Since adding another account as a contact creates state in their contract as well as ours (we need to let them know we want to connect), we must ensure the user initiating this connection is paying for that storage. The deposit attached to this payable function is used to cover that storage cost.
You can see a few lines down where we check that the deposit is indeed present. This makes use of the attached_deposit
function from the env module. The fact we are making this check early segues perfectly into the third principle.
Ensure invariants before making cross-contract calls
The type signature of the add_contact
function is important to notice. First, the arguments to the function (&mut self, account: AccountId)
mean that this is a mutable call (it will change the state of the contract) and it takes one argument called “account” which must be a Near Account ID. When near_bindgen
does its magic, it will mean users of the Near blockchain can call this function by making a transaction which takes a JSON encoded argument like { "account": "my.account.near" }
. Second, the return type is Promise
, which means that we are making a cross-contract call at the end of this function. Cross-contract calls on Near are asynchronous and non-atomic, therefore we must ensure everything is in a good state before we make the call. This is why we include the owner-only and deposit check first in the function body. The asynchronous nature of the cross-contract calls also means there is no return value from this function immediately. The asynchronous call will be performed and the result will only come later, after this call happens.
You can see the details of the cross-contract call at the bottom of the function. It uses the high-level API from near-sdk (though the low-level API is also available in the env module) where the ext
function is automatically generated by near_bindgen
and it returns a data structure to construct the cross-contract call. You can see first we use ext(account)
to call the account we want to add as a contact. The call includes our deposit via with_attached_deposit
and is calling the ext_add_contact
function (which is defined in the same impl block in this case, but in general it could be defined anywhere). Finally, we call then
which means to include a callback. The callback is itself another Promise
, so we use the same ext
function again, but this time calling on our own account ID. This is done so that our contract can know what the response was from the contract we are trying to add as a contact. I won’t go into the details of the ext_add_contact
or add_contact_callback
implementations here (they just manipulate the storage depending on the current status of the account), but I encourage you to read through them in the source code on GitHub if you are interested.
Summary
In this post we dove head first into some code! We saw how near_bindgen
is used to automatically generate code needed to run our contract in the Near runtime, as well as other features of near-sdk to interact with storage, the runtime environment and other contracts. In the next post we’ll continue the deep dive into code, but change gears to look at the off-chain component of this application. A smart contract alone does not constitute a dapp, stay tuned to see why!
If you want some hands-on experience with this code then try out some of the exercises! In a few places in the smart contract code I included a comment tagged as EXERCISE
. For example, in the types definition I call out the fact that a Blocked
user status is available, but there is no way to block someone currently implemented. Adding this functionality to block another user is one suggested exercise, and a good one to get started with. All the exercises are suggestions of ways to extend the functionality of the contract, giving you an opportunity to try writing some smart contract code for yourself. Perhaps in a future post in this series I’ll discuss some solutions to the exercises.
If you are enjoying this series of blog posts please get in touch with us at Type-Driven consulting. We are happy to provide software development services for dapps, as well as training materials for your own engineers.
Great !!!