On February 16, 2023 I gave a workshop at the University of Waterloo about smart contract development on Near using Rust. I enjoyed putting it together and I thought it would be fun to also present the content here as a series of blog posts. In this first post I’ll give an analogy to connect blockchain development to a pattern used in normal web applications, introduce the smart contract example we’ll be using throughout this series, and discuss some general principles of smart contract development that are unique to blockchain relative to other domains of programming.
A mental model for creating a distributed application (dapp)
The purpose of this section is to draw an analogy between developing on top of a blockchain (apps backed by blockchain technology are often called “dapps” in the space) and a more common technology for web apps that you may have encountered before. This analogy may be helpful when thinking about how users interact with smart contracts.
The idea is that dapps are very similar to web apps based on a “serverless architecture“. The term “serverless” is a little misleading because of course servers are still involved, but the reason for the name is that the underlying hardware (i.e. server) executing the code is abstracted away from the developer. This has benefits over other cloud computing infrastructure in terms of cost and scalability because you only pay for exactly what resources you use as opposed to paying to run a VM that may sit idle if traffic is low, or may become unresponsive if there is too much traffic. Each time a user interacts with the web app, a new instance of the “serverless function” is invoked on the backend to serve the user’s request without the developer having to think about exactly what hardware this function is running on.
Dapps abstract away hardware in a similar way. A smart contract is deployed to the blockchain and executed on the nodes (servers) that form the peer-to-peer network of that blockchain. When a user interacts with the dapp it makes a call to the blockchain (a transaction) to execute the smart contract. Each transaction creates a new instance of the smart contract (in the sense that there is no in-memory state that is persisted between transactions), just like with serverless functions.
Below is an image taken directly from the Amazon Web Services (AWS) website for Lambda (their version of a serverless compute offering).
It is easy to modify this image to see how the workflow in a dapp is similar.
Another similarity between serverless compute and smart contracts is the fact that each transaction has a cost to it. In the case of AWS the developer’s AWS account is charged for the resources consumed, whereas in the case of blockchain whoever signed the transaction is charged for its execution.
With this analogy as a reference point, let’s discuss the dapp development example we’ll be using throughout this series.
Our example: blockchain-based chat app
The example we will use throughout this series is a blockchain-based chat app. This is not a real-world example in the sense that there is not a good business case to use a public blockchain for chat (in my opinion). The fact that all the messages will be completely public and irreversibly included into a permanent record is a drawback, not a feature. However, the reason to choose this example is that it illustrates various important concepts in dapp development while being logically easy to follow for anyone that has used something like Facebook Messenger, Telegram, or Signal.
The code for this example is available on my GitHub. The README on that repository gives some instructions for setting up a development environment for interacting with the code and some basic idea of how to use the contract. This series of posts will be a much deeper dive into the code and how it works.
To ground the discussion of principles of smart contract development, here is an overview of how the chat contract works.
- Each individual who wants to participate in the chat network deploys their own version of the smart contract.
- Each instance of the contract maintains a list of accounts it knows about (contacts, pending contact requests, etc.). It also stores the messages it has received (and some metadata about those messages).
- To send a message to someone else, you must first have them as a “contact”. This works as you would expect: Alice sends Bob a contact request, if Bob accepts then Alice and Bob become contacts of one another, otherwise they are not contacts.
- Each instance of the contract has an “owner” who is able to send messages and send/accept contact requests.
Principles of smart contract development
There are three related concepts I want to emphasize that are important to smart contract development, but may not appear in typical software development. They are:
- an adversarial mindset,
- ensure invariants before making cross-contract calls.
An adversarial mindset
The first important thing to remember when deploying to a public blockchain is that anyone in the whole world can interact with your code. If there is some sensitive action your smart contract can take (for example when sending messages in the chat contract you wouldn’t want someone to be able to impersonate you) then you must explicitly check for authorization so that only authorized accounts can successfully perform the action (this is why our chat contract has the “owner” property). If you have any method that takes input then you must validate it before proceeding to any business logic because any random user could submit any input they like. Indeed, the idea of an adversarial mindset goes even further; not only might a user submit garbage input, but they might carefully craft input to trigger a vulnerability in your code. The only way to prevent this from happening is to not have such vulnerabilities in the first place.
Similarly, smart contract logic often depends on some protocol to coordinate different components together (for example the protocol to add contacts in our chat contract). Does a user have agency over in this protocol? What happens if they do not follow it correctly? These are questions that you must answer when developing a smart contract because hackers will be trying to exploit your contract.
Long story short, you should always assume that any external input is byzantine and explicitly verify otherwise before proceeding. You should practice noticing what assumptions you are making and always think “how could I break this assumption?” whenever you realize you are making one.
The economics of a typical web app are pretty simple. You need to generate enough revenue to cover the cost of hosting whatever server contains the code and data your app uses. The revenue you need to generate could come from a number of sources, but the most common are ad revenue and paid user subscriptions.
For blockchain the situation is a little more complicated because every single transaction needs to be paid for independently. Newer blockchain products are looking to simplify this story, for example Aurora+ provides something like a “blockchain subscription” which allows a number of transactions for free. But until this becomes standard in the blockchain space it’s still important to answer the question “who is paying for this?”.
Often it is the user who pays for each transaction because payment is tied to the signing account (i.e. payment is tied up with identity / authorization). An alternative model is to use “meta-transactions” (transactions within transactions) so that the payment is done by the “outer signer” while the authorization is based on the “inner signer”. This is how Aurora+ works for example. Unfortunately, since this is not the default way blockchain transactions operate it does require extra work on the developer’s part to make it happen.
For the sake of our chat app example, we will follow the path of least resistance and each user will have to pay for the costs they incur through their usage. After having made this decision, we need to review what possible costs there could be and make sure they are being covered appropriately. For example, on Near, storage payment is handled by “storage staking“. Essentially this means that each account has some of its balance locked depending on how much storage it is using. This is relevant in our chat contract because it stores messages received from other users, therefore we need to make sure those other users are covering the storage staking cost by attaching a sufficient deposit with their message. Similarly, contact requests create an entry in storage so those too must come with a deposit. If we did not enforce these deposit requirements then users could effectively steal money from one another by sending many messages and locking up the victim’s entire balance (notice how this ties in with the adversarial mindset above).
In summary, when designing a dapp it is always important to think about what costs will be involved and how they are being paid for, whether this means adding checks for deposits or using meta-transactions.
Ensure invariants before making cross-contract calls
This last point is subtle. In a typical application all code is linked into the same binary. When you call a function in a library, this is not usually triggering any communication, but rather just adding a new frame on the stack and executing some code from another part of the binary. In a blockchain setting things are a little different.
Making a call to another contract is more like performing a call to a whole other process than it is like calling a library. Again we must apply an adversarial mindset and realize that we have no idea what that other process might be doing; indeed, it might be trying to do something purposely malicious. A common attack vector is to have the other process call back into our contract and exploit it because our contract was not expecting a new call to come in while it was waiting for a response to the call it initiated. This is called a “reentrency attack” and it was the source of one of the most famous hacks on Ethereum, the one that resulted in “Ethereum Classic” being created (Ethereum classic rejected the “hard fork” that was the Ethereum Foundation’s response to the hack).
On Near this problem is even more pronounced because there is the additional issue of atomicity. In the Ethereum Virtual Machine (EVM) each transaction is “atomic” in the sense that all actions as a result of the transaction are committed to the blockchain state or none of them are (the whole transaction is “reverted”). This means a reentrancy attack can be thwarted by using a revert; everything that has happened will be undone, keeping the contract safe. This pattern is even included in the Mutex example in the official Solidity documentation. However, in Near’s runtime the execution of contracts is independent of one another; they are not atomic. So if a transaction causes contract A to call contract B, and B encounters an error then the state changes that happened in A will remain.
This has been a lot of history and theory, but what is the practical take-away? The point is that you must ensure your contract is in a “good state” when it makes a call to another contract. That is to say, if there are invariants your contract’s logic relies on then they must be correct at the time the call is made. As a simple example, suppose we have a contract with a transfer function. The invariant to maintain is that tokens are not created or destroyed in a transfer. If for some reason there needed to be a call to another contract during the transfer, it would be incorrect to debit one account and then make the call without crediting the other first. This is because the invariant about tokens not being destroyed would be broken when the external call was made and this might be exploitable. An example along these lines is also included in Near’s documentation.
Wrapping up, in this blog post we are introducing a new series of posts giving an introduction to smart contract development on Near using Rust. Here we discussed the chat contract example we will use throughout the series as well as some general principles to keep in mind when developing on blockchain-based applications. In the next post we’ll dive more into the code to discuss the technical details of how the contract is implemented. This will serve as an example of Near’s Rust SDK, illustrating concepts that will apply to all kinds of real-world contracts you may want to write.
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.