Comparing Solidity With Clarity
Writing smart contracts for Bitcoin was limited in the past, but with Stacks, this changed. Its Clarity programming language allows you to build Bitcoin smart contacts similar to those on the Ethereum network while being more secure than Solidity at the same time. At first glance, Clarity might seem a bit foreign, but the language has many similarities with Solidity, so don't get discouraged by its syntax.
This article adds to our first Stacks workshop with Hiro, where we compared the two languages.
What is Stacks?
Stacks is a layer-2 blockchain network for Bitcoin that reads data from Bitcoin, settles on Bitcoin, and allows the creation of smart contracts similar to the ones on Ethereum.
Besides being a smart contract platform, Stacks also offers several features and services you might know from the Ethereum ecosystem. For example, Stacking DAO is a liquid staking service similar to Lido, and ALEX is a DEX similar to Uniswap.
What is Clarity?
Stacks smart contracts are written in Clarity, a statically typed language with Lisp syntax that favors functional programming styles, including pure functions and immutable data. Its first release date is 2020, so it’s a rather young language, but it benefits from learning from Solidity’s mistakes.
Clarity focuses on security and predictability. It is not Turing complete, which allows for more precise gas estimations and security checks. Conditional jumps or, in high-level languages, if-statements, loops, and recursion are often the cause of complexity in Turing complete languages. Clarity doesn’t support loops or recursion, so there is no way to run indefinitely, making it non-Turing complete. However, you can still review multiple elements with map, filter, and fold functions.
How Does Clarity Differ from Solidity?
Let's review Clarity and Solidity examples to highlight their differences. If you want a more concise visual comparison, check out this site.
We will touch on the following points:
Syntax differences
Immutable variables
Function access
External function calls
Event emission
Token transfer
Syntax Differences
Clarity’s syntax is inspired by Lisp, meaning you will see many parentheses. Clarity favors kebab-case instead of camelCase; everything is an expression since everything is done with functions. There are no statements like if or for that just define code blocks but don’t “return” a value.
Take this example of a function call in Solidity:
functionName(argument1, argument2, …, argumentN);
Compared to the Clarity version:
(function-name argument1 argument2 … argumentN)
Also, no commas or colons are needed.
Assigning Variables
By default, you can change variables directly in Solidity. In Clarity, you can’t; variables are immutable, and you must set them with a call to a built-in function.
In the following Solidity example, we see the use of an assignment operator to change a contract variable.
contract User {
uint public userAge = 42;
function updateAge(unit age) public {
userAge = age;
}
}
In Clarity, no operator is involved; instead, you call the define-data-var
function for contract variables and override it with a var-set
function call.
(define-data-var userAge uint 42)
(define-public (update-age (age uint))
(begin
(var-set userAge age)
(ok age)
)
)
Working With Variables
These variables are immutable; they get replaced by a new instance. This might not be obvious with primitive values like numbers, so let’s look at a more complex example with composite values.
In Solidity, you can use structs to define composite values. The following example illustrates how to define a struct, create an instance, and mutate it.
contract UserContract {
struct User {
string name;
uint age;
}
User public user = User("Ryan", 42);
function updateUser(string memory name, unit age) public {
user.name = name;
user.age = age;
}
}
The Clarity equivalent to a struct is the immutable tuple. Again, it’s created via a built-in function.
(tuple (name "Ryan") (age 42))
This time, some syntactic sugar allows you to define a tuple similar to object literals in JavaScript.
{name: "Ryan", age: 42}
To get data from a tuple, you use the get function. In the following example, we define variables local to the let block and set them to the content of our tuple stored in the user
reference.
(define-constant user {name: "Ryan", age: 42})
(let
(
(userName (get name user))
(userAge (get age user))
)
(print {name: userName, age: userAge})
)
If you want to change a tuple value, you have to replace the whole tuple with a new one. Maps can help here, allowing you to reference a tuple by key. This way, you can use a map and a key as a constant reference to a changing value.
(define-map users uint { name: (string-ascii 50), age: uint })
(define-public
(add-or-update-user
(userId uint) (userName (string-ascii 50)) (userAge uint)
)
(map-set users userId { name: userName, age: userAge })
(ok true)
)
Defining Function Access
To define a function in Solidity, you use the function
keyword, followed by parenthesis with the arguments, a list of modifiers, and finally, the function body.
contract UserManagement {
uint private age;
function incrementAge() private {
age += 1;
}
function getAge() public view returns (uint) {
return age;
}
function setAge(uint newAge) public {
age = newAge;
incrementAge();
}
}
In Clarity, there are no modifiers but different built-in functions for function definition. Built-in functions define private, public, and view functions called read-only in Clarity. Also, Clarity doesn’t have external functions.
(define-data-var userAge uint u0)
(define-private (increment-age)
(var-set userAge (+ (var-get userAge) u1))
)
(define-read-only (get-user-age)
(var-get userAge)
)
(define-public (set-user-age (newAge uint))
(var-set userAge newAge)
(ok newAge)
)
Solidity also allows for the definition of custom modifiers to enable you to manage access control.
contract Owned {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
function changeOwner(address newOwner) public onlyOwner {
owner = newOwner;
}
}
In Clarity, you would use the built-in asserts!
function, which will revert your contract automatically if an assertion fails.
(define-data-var owner principal tx-sender)
(define-public (change-owner (newOwner principal))
(begin
(asserts! (is-eq tx-sender (var-get owner)) (err u1))
(var-set owner newOwner)
(ok true)
)
)
Calling Functions of Other Contracts
To call a function of another contract in Solidity, you need the address of the contract you want to call and its definition or ABI.
contract User {
uint public age;
function setAge(uint newAge) public {
age = newAge;
}
}
contract Caller {
function callSetAge(address userAddress, uint newAge) public {
UserManagement user = User(userAddress);
userManagement.setAge(newAge);
}
}
In Clarity, you need the address (called principal
) since everything is handled with built-in functions; classes or contract definitions don’t exist.
(define-data-var age uint u20)
(define-private (increment-age)
(var-set age (+ (var-get age) u1))
)
(define-public (update-age)
(increment-age) ;; Internal call
(ok (var-get age))
)
(define-public (call-set-age (contractA principal) (newAge uint))
(contract-call? contractA set-age newAge)
)
Emitting Events
In Solidity, similar to structs, you have to define events up-front and explicitly emit
them.
contract UserManagement {
event UserUpdated(string name, uint age);
function updateUser(string memory name, uint age) public {
emit UserUpdated(_name, _age);
}
}
In Clarity, you don’t need to define or emit events explicitly. Built-in functions like stx-transfer
emit events implicitly when called. However, if you want a custom event, you can call the print
function with a tuple, which will emit an event.
(define-public (update-user (name (string-ascii 32)) (age uint))
(print { action: "User updated", name: name, age: age })
(stx-transfer? amount tx-sender recipient)
(ok true)
)
Transferring Native Tokens
To transfer ETH with Solidity, you’d call the transfer method of a payable address.
contract SendETH {
function sendViaTransfer(address payable _to) public payable {
_to.transfer(msg.value);
}
}
In Clarity, you’d use the built-in stx-transfer
function to transfer the native token STX. It checks automatically if the sender has enough tokens.
(define-public (send-stx (recipient principal) (amount uint))
(stx-transfer? amount tx-sender recipient)
)
Transferring Non-Native Tokens
What would a smart contact-enabled blockchain be without custom tokens? Stacks’ ERC-20 equivalent is the SIP-010 standard.
To transfer a custom (non-native) fungible token in Solidity, you need an interface and call its transfer method with an address and an amount.
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract SendERC20 {
function transferToken(address tokenContract, address to, uint256 _amount) public {
IERC20 tokenContract = IERC20(_tokenContract);
tokenContract.transfer(_to, _amount);
}
}
You can do the same in Clarity by using the built-in contract-call
function. No interface is needed since Clarity is not object-oriented and doesn’t support inheritance.
(define-public (transfer-token (recipient principal) (amount uint))
(contract-call? .token transfer amount tx-sender recipient)
)
Transferring non-fungible tokens in Solidity is similar to the fungible version; you just need an ID instead of an amount.
interface IERC721 {
function transferFrom(address from, address to, uint256 _tokenId) external;
}
contract SendERC721 {
function transferNFT(address nftContract, address to, uint256 _tokenId) public {
IERC721 nftContract = IERC721(_nftContract);
nftContract.transferFrom(msg.sender, to, tokenId);
}
}
The same goes for Clarity, again a built-in function with no interface. Also, SIP-009 is Stacks’ equivalent of ERC-721 on Ethereum.
(define-public (transfer-nft (nft-contract principal) (token-id uint) (recipient principal))
(contract-call? nft-contract transfer token-id tx-sender recipient)
)
Missing Features of Clarity
While this might not be an issue in practice, some missing features make 1:1 ports of smart contracts from Solidity to Clarity a bit harder.
First, Clarity contracts can’t directly modify the state of other contracts, which means building contract factories isn’t possible.
Second, Clarity does not have libraries. In Solidity, you can import an ERC-20 library like OpenZeppelin and extend the interfaces. In Clarity, you would copy and modify code from a SIP-009 template.
Hiro Development Tools for Stacks
Hiro supplies you with all the tools you need to build smart contracts on Stacks.
Clarinet, a development environment similar to Hardhat, for building and testing Clarity contracts.
Stacks.js, a frontend library similar to web3.js, is used to access Stacks from a web application.
The Hiro Platform, a cloud IDE similar to Remix, deploys and debugs Clarity smart contracts.
Chainhook, an indexing service similar to the Graph, to get onchain data quickly.
Summary
Clarity certainly is a unique language for smart contract development. It focuses on the functional programming paradigm and even ditches Turing completeness to make gas estimations and security checks straightforward.
While Clarity might seem quite different at first glance, it still has many conceptual similarities with Solidity, so porting most contracts shouldn’t be a big deal.
Finally, with the tools and services provided by Hiro, you should have your first contract deployed in no time! So, sign up to their platform and tap into the power of Bitcoin!
Ask Hiro on Discord if you have questions about Stacks or Hiro services.
Subscribe to my newsletter
Read articles from Ϗ directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Ϗ
Ϗ
♂️ he/him 🎇 C̶̣̑h̵͖͋â̷̟ö̵̪́t̸͉̑i̴̪͝c̸͙͂ ̸͖̍G̵̠̈́ö̸̳́o̴̳̕d̶̨̐ 📦 Cargo Cultist 🔄 Word Rotator 💞 Polyamorist