My Journey So Far With Clarity As A Solidity Developer

Introduction

The D_D Hiro workshop "Learn Stacks as a Solidity Developer: An Intro to Developing Bitcoin Smart Contracts" introduced me to the Clarity programming language. Clarity is a smart contract language for the Stacks blockchain that enables developers to create safe and secure smart contracts. Coming from a background of writing smart contracts in Solidity, the syntax of Clarity was intimidating at first, but like anything else, understanding improves with practice. This article summarises my experience with Clarity and how it compares to Solidity.

Installation of Tools and Problems Encountered

To begin writing Clarity, install Clarinet on your system. Clarinet's Githhub page demonstrates how to install Clarinet for Windows and Mac. I used WSL for development on Windows, so I had to install it from source with Cargo.

I cloned the repository and ran cargo to install clarinet, but I got the following error:


  failed to load source for dependency `clar2wasm`
  Unable to update https://github.com/stacks-network/clarity-wasm.git?rev=2a28698#2a28698a
  failed to update submodule `stacks-core`
  failed to fetch submodule `stacks-core` from https://github.com/stacks-network/stacks-core.git
  network failure seems to have happened
  if a proxy or similar is necessary `net.git-fetch-with-cli` may help here
  https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli
  SSL error: received early EOF; class=Ssl (16); code=Eof (-20)

I was able to resolve this error after consulting AI. The solution was to set Cargo to use the command-line git client rather than its internal implementation. I opened the file./cargo/config in a text editor and entered the following lines:

[net] 
git-fetch-with-cli = true

This fixed the issue, and all was not set for me to start experimenting with Clarity.

Smart Contract Creation

Clarinet is the development tool used to create smart contracts. It is equivalent to Hardhat and Foundry in the Solidity ecosystem. Getting started with Clarinet is as simple as it is in the Solidity ecosystem.

Following a successful Clarinet installation on your development machine, the clarinet command is available for use in the terminal. Here are some useful commands to run when creating Clarity smart contracts:

creating a folder structure to house your smart contract: clarinet new contracts

The above command creates a folder called contracts that contains some useful folders and files. Inside the contract folder, we have folders named contracts, test and settings. The settings folder contains three configuration files namely Devnet.toml, Mainnet.toml and Testnet.toml. These configuration files contain the wallet addresses used for deployment. The files could be likened to the hardhat.config file of Hardhat in Solidity.

To create a new smart contract:

navigate into the contracts folder and run;

clarinet contract new hello

This creates a new contract file inside the contracts directory called hello.char

You can spill up a local testnet environment in the console by running;

clarinet console and execute your contract function from the console.

There are a host of other clarinet commands used for verifying contract syntax and also for the deployment of your contract to either the mainnet or the testnet of the Stacks blockchain. I did notice a cool thing in the generated deployment file when you run;

clarinet deployment generate command.

It shows how much it will cost you to deploy the contract.

Variables and Function Declaration

Clarity functions can be read-only, private, or public. A private function can only be accessed through the contract in which it is defined. A public function definition in Clarity can be accessed from outside the contract by sending a transaction to it. In Clarity, a public function must return one of two response objects: ok or error. The ok response indicates that all computation was completed successfully and that new state values were written to the blockchain, whereas err indicates an error and that all previous state changes made in the function were reverted.

Let's see how to define a read-only function in Clarity;

(define-read-only (addTwoNumbers (a uint) (b uint))
      (ok (+ a b))
)

The function definition is a read-only function that can be called from outside the contract. It is a simple contract that takes two unsigned numbers as input and returns the sum of the two numbers. There is no state change involved in the contract, so in Solidity, we can simply define the function as a pure function.

This is the equivalent of the above function in Solidity.

function addTwoNumbers(uint a, uint b) public pure returns (uint result) {
        result = a + b;
}

State variables in Clarity are defined using this format (define-data-var [variableName] variableType [defaultValue]). A variable of type signed integer is defined this way;

(define-data-var counter uint u0)

This creates an unsigned integer state variable called counter with an initial value of 0. We can set and get the value of the counter variable using the function var-set and var-get.

Look at an example here:

(define-data-var counter uint u0)
;;function to increment a counter variable
(define-public (incrementCounter) 
   (begin 
        (var-set counter u10)
        (print (var-get counter))
        (ok (var-get counter))
   )
)

This is a valid smart contract that increases the value of a counter variable. The counter variable is defined and given a value of 0. We then define a public function called incrementCounter. We defined a begin block used to evaluate more than one expression in the function, as it allows us to chain expressions together. var-set changes the counter from 0 to 10, and the next expression uses the print function to write event logs. The Solidity equivalent of print is emitting an event. Finally, we return a response of ok indicating that all went well, thereby saving the counter value on the blockchain.

A public function in Clarity must return a response of either ok or err

Below is the equivalent Solidity code:

contract ContractA {
   event CounterEvent(uint256);
    uint256 public counter;
    function incrementCounter() public 
    {
        counter = 10;
        emit CounterEvent(10);
    }
}

A takeaway from the implementation of the function in both languages is that Clarity allows you to return a value to the caller of the function as we know that in Solidity, a function that mutates state cannot return a value.

Traits in Clarity

There's no inheritance in Clarity; contracts cannot be inherited from another contract, as it is done in Solidity, so Clarity makes use of traits. A trait is like a templates for smart contracts. It is a collection of public functions, definition of function name, input type, and the output type of the function. They act as blueprints that other contracts can inherit from.

A trait in Clarity is similar to an interface in Solidity, but there are some differences:

  • Traits are deployed separately to the blockchain in Clarity.

  • A developer can implement traits in Clarity either implicitly or explicitly.

Let's look at an example:

(define-trait mathOperation
(
   (multiply (uint uint ) (response uint uint))
   (addition (uint uint) (response uint uint))
)
)

We have a simple trait definition called mathOperation that has two functions that both take two parameters of unsigned integers and return an unsigned integer response of ok or err. If this trait is deployed to the blockchain, an implementing contract will include the address where this trait can be found on the blockchain.

Assuming the trait was deployed using this principal ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE account and the name of the contract is .hello-stacks, a developer can implement the mathOperation trait thus;

;;explicit implementation of the mathOperation
(impl-trait ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.hello-stacks.mathOperation)

(define-public (multiply (a uint) (b uint)) 
    (ok (* a b))
)
(define-public (addition (a uint) (b uint)) 
    (ok (+ a b))
)

The implementing contract can have other functions defined in the contract, but it must also implement all the functions of the mathOperation trait.

In Solidity, the implementing contract inherits the interface and defines the methods within. The main difference with traits is that they are deployed to the blockchain.

 interface MathOperation {
    function multiply(uint a , uint b) external view returns(uint);
    function addition(uint a , uint b) external view returns(uint);
}

contract ContractA is MathOperation {
   function multiply(uint a, uint b) public view returns (uint){
        return a * b;
    }
      function addition(uint a, uint b) public view returns (uint){
         return a + b;
    }

}

Fungible Tokens Standards

Clarity's implementation of fungible tokens is governed by the SIP010 standard, which is a trait that contracts must implement when creating a token. This is equivalent to Ethereum's ERC20 token standard. The Clarity language includes a feature for creating custom Stack tokens, making token operations safe and simple. This is a fundamental difference between Ethereum ERC20 tokens and Clarity, as token implementation and transfer are not built into the language.

To create a token in Clarity, the developer simply implements the SIP10 traits that are hosted on the blockchain. The following is the token trait:

(define-trait sip-010-trait
  (
    ;; Transfer from the caller to a new principal
    (transfer (uint principal principal (optional (buff 34))) (response bool uint))

    ;; the human readable name of the token
    (get-name () (response (string-ascii 32) uint))

    ;; the ticker symbol, or empty if none
    (get-symbol () (response (string-ascii 32) uint))

    ;; the number of decimals used, e.g. 6 would mean 1_000_000 represents 1 token
    (get-decimals () (response uint uint))

    ;; the balance of the passed principal
    (get-balance (principal) (response uint uint))

    ;; the current total supply (which does not need to be a constant)
    (get-total-supply () (response uint uint))

    ;; an optional URI that represents metadata of this token
    (get-token-uri () (response (optional (string-utf8 256)) uint))
  )
)

This trait interface, though having some similarities with the ERC20 standard, is different in a sizeable way. Notice the get-token-uri method that points to a URI that gives a further description of a token. Developers could implement this method to describe their token properly. Another noticeable difference I saw was the lack of a token allowance. Remember, the ERC20 token standard has an allowance mapping that allows user A to transfer tokens on behalf of another user B, provided that user A has been approved by user B to do so.

The SIP10 lacks an approval method, meaning only a token owner can transfer their token. Another key difference in Clarity is that developers must handle the return value of a token transfer operation. This is not the case in Solidity, as the developer can either choose to handle the token transfer successfully or not.

ERC20(tokenAddress).transfer(addressToTransferTo, amountToTransfer);

The above is a valid Solidity code. We are not checking the return value from the token transfer. This could cause problems with our protocol.

(transfer (uint principal principal (optional (buff 34))) (response bool uint))

We can see that the transfer method of the SIP10 trait returns a response of ok or an err of type uint . Let's see how the transfer method is implemented in the transfer of a token, clarity-coin in Clarity.

(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34))))
    (begin
        (asserts! (is-eq tx-sender sender) err-not-token-owner)
        (try! (ft-transfer? clarity-coin amount sender recipient))
        (match memo to-print (print to-print) 0x)
        (ok true)
    )
)

The function is a public function, which means it can be called with a transaction. The parameters are the amount to transfer, the sender address, the recipient address, and an optional buffer of data called memo.

We first assert that the sender and the receiver are not the same. Then we wrap the transfer in a try (the try! function takes an optional or a response type and attempts to unwrap it). If the transfer was successful, we check if any buffer data was passed to the function; if it was, it is printed as an event. At the end of the function, a bool response type of ok is returned to the caller. We can see that the mechanism to transfer the token is an inbuilt function, ft-transfer thereby enhancing the security of the tokens.

A lot of tokens have been lost as a result of developers not handling the return value of token transfer in Solidity.

Conclusion

The journey so far in learning Clarity has been bliss, and I intend to continue learning to unravel the full potential of the language. Thank you for reading!