Clarity is a non-Turing-complete smart contract language designed for the Stacks blockchain, emphasizing predictability and security. The design of Clarity is heavily influenced by lessons learned from common security exploits found in Solidity. Its purpose-built nature focuses on enhancing safety and security.
Firstly, let’s explore the differences in syntax between Clarity and Solidity.
Clarity is a programming language inspired by LISP, noted for its simplicity and power in dealing with symbolic data. In Clarity, every construct is represented as a list within a list or an expression inside an expression. This nested structure is a fundamental feature, allowing the language to be expressive and flexible. Functions, variables, and their parameters are all enclosed within parentheses, highlighting the language’s consistent syntax.
Here we have an example NFT contract in Clarity
(define-trait nft-trait
(
;; Last token ID, limited to uint range
(get-last-token-id () (response uint uint))
;; URI for metadata associated with the token
(get-token-uri (uint) (response (optional (string-ascii 256)) uint))
;; Owner of a given token identifier
(get-owner (uint) (response (optional principal) uint))
;; Transfer from the sender to a new principal
(transfer (uint principal principal) (response bool uint))
)
)
Implements below:
;; This contract implements the SIP-009 community-standard Non-Fungible Token trait
(impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait)
;; Define the NFT's name
(define-non-fungible-token Your-NFT-Name uint)
;; Keep track of the last minted token ID
(define-data-var last-token-id uint u0)
;; Define constants
(define-constant CONTRACT_OWNER tx-sender)
(define-constant COLLECTION_LIMIT u1000) ;; Limit to series of 1000
(define-constant ERR_OWNER_ONLY (err u100))
(define-constant ERR_NOT_TOKEN_OWNER (err u101))
(define-constant ERR_SOLD_OUT (err u300))
(define-data-var base-uri (string-ascii 80) "https://your.api.com/path/to/collection/{id}")
;; SIP-009 function: Get the last minted token ID.
(define-read-only (get-last-token-id)
(ok (var-get last-token-id))
)
;; SIP-009 function: Get link where token metadata is hosted
(define-read-only (get-token-uri (token-id uint))
(ok (some (var-get base-uri)))
)
;; SIP-009 function: Get the owner of a given token
(define-read-only (get-owner (token-id uint))
(ok (nft-get-owner? Your-NFT-Name token-id))
)
;; SIP-009 function: Transfer NFT token to another owner.
(define-public (transfer (token-id uint) (sender principal) (recipient principal))
(begin
;; #[filter(sender)]
(asserts! (is-eq tx-sender sender) ERR_NOT_TOKEN_OWNER)
(nft-transfer? Your-NFT-Name token-id sender recipient)
)
)
;; Mint a new NFT.
(define-public (mint (recipient principal))
;; Create the new token ID by incrementing the last minted ID.
(let ((token-id (+ (var-get last-token-id) u1)))
;; Ensure the collection stays within the limit.
(asserts! (< (var-get last-token-id) COLLECTION_LIMIT) ERR_SOLD_OUT)
;; Only the contract owner can mint.
(asserts! (is-eq tx-sender CONTRACT_OWNER) ERR_OWNER_ONLY)
;; Mint the NFT and send it to the given recipient.
(try! (nft-mint? Your-NFT-Name token-id recipient))
;; Update the last minted token ID.
(var-set last-token-id token-id)
;; Return a success status and the newly minted NFT ID.
(ok token-id)
)
)
Declaring Storage
;; Define the NFT's name
(define-non-fungible-token Your-NFT-Name uint)
;; Keep track of the last minted token ID
(define-data-var last-token-id uint u0)
;; Define constants
(define-constant CONTRACT_OWNER tx-sender)
(define-constant COLLECTION_LIMIT u1000) ;; Limit to series of 1000
(define-constant ERR_OWNER_ONLY (err u100))
(define-constant ERR_NOT_TOKEN_OWNER (err u101))
(define-constant ERR_SOLD_OUT (err u300))
(define-data-var base-uri (string-ascii 80) "https://your.api.com/path/to/collection/{id}")
Global variables in Clarity, similar to those in Solidity, are defined within the contract.
In the nft contract above, we see a type called principal
, which is somewhat similar to the address
type in Solidity. Let’s break down their differences.
In Solidity, both contract addresses and externally owned account (EOA) addresses are essentially SHA256 hashes. An EOA address is derived from a user’s private key, while a contract address is determined by the user’s address, nonce, and the create2 bytecode. The complex calculations in Solidity make it difficult to differentiate between externally owned account (EOA) addresses and contract addresses within the Solidity ecosystem. In Clarity, the contract address is the concatenation of the sender’s address and the contract name. let’s take an example.
If Alice writes a Clarity smart contract counter.clar
and deploys it using the address SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9
, the contract address will beSP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.counter
.
Note: Alice cannot use the same address to deploy another contract named counter.
The type system in Clarity contains the following types:
Types | Notes |
int | signed 128-bit integer |
uint | unsigned 128-bit integer |
bool | boolean value (true or false) |
principal | object representing a principal (whether a contract principal or standard principal) |
(buff max-len) | byte buffer of maximum length max-len. |
(string-ascii max-len) | ASCII string of maximum length max-len |
(string-utf8 max-len) | UTF-8 string of maximum length max-len (u”A smiley face emoji \u{1F600} as a utf8 string”) |
(list max-len entry-type) | list of maximum length max-len, with entries of type entry-type |
{label-0: value-type-0, label-1: value-type-1, …} | tuple, group of data values with named fields |
(optional some-type) | an option type for objects that can either be (some value) or none |
(response ok-type err-type) | object used by public functions to commit their changes or abort. May be returned or used by other functions as well, however, only public functions have the commit/abort behavior. |
Clarity simplifies function visibility compared to Solidity’s options like public, external, internal, private, view, and pure. In Clarity, you only have define-public, define-private, and define-readonly.
(define-private (max-of (i1 int) (i2 int))
(if (> i1 i2)
i1
i2))
;; Private functions may not be called from other smart contracts, nor may they be invoked directly by users.
(define-public (hello-world (input int))
(begin
(print (+ 2 input))
(ok input)))
;; Public functions are callable from other smart contracts and may be invoked directly by users by submitting a transaction to the Stacks blockchain.
(define-read-only (just-return-one-hundred)
(* 10 10))
;;Read-only functions may return any type and not perform any datamap modifications or call any functions which perform any modifications.
Composition vs. Inheritance
(impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait)
Clarity adopts a composition over inheritance. It means that Clarity smart contracts do not inherit from one another like you see in languages like Solidity. Developers instead define traits which are then implemented by different smart contracts. It allows contracts to conform to different interfaces with greater flexibility. There is no need to worry about complex class trees and contracts with implicit inherited behavior.
Comparing the NFT standards between Clarity and Solidity, it appears that we lack some functions,why?
(define-trait nft-trait
(
;; Last token ID, limited to uint range
(get-last-token-id () (response uint uint))
;; URI for metadata associated with the token
(get-token-uri (uint) (response (optional (string-ascii 256)) uint))
;; Owner of a given token identifier
(get-owner (uint) (response (optional principal) uint))
;; Transfer from the sender to a new principal
(transfer (uint principal principal) (response bool uint))
)
)
interface ERC721 {
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
function setApprovalForAll(address _operator, bool _approved) external;
function getApproved(uint256 _tokenId) external view returns (address);
function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}
Clarity has built-in support for fungible tokens (FT) and non-fungible tokens (NFT). This allows you to create and manage tokens using native functions like ft-mint
, ft-burn
, ft-transfer
, nft-mint
, nft-burn
, and nft-transfer
,etc.
https://docs.stacks.co/reference/functions
To facilitate contract interaction, Stacks offers two proposals: SIP-9, which corresponds to ERC-721, and SIP-10, which corresponds to ERC-20.
SIP-9 : https://github.com/stacksgov/sips/blob/main/sips/sip-009/sip-009-nft-standard.md
(define-trait nft-trait
(
;; Last token ID, limited to uint range
(get-last-token-id () (response uint uint))
;; URI for metadata associated with the token
(get-token-uri (uint) (response (optional (string-ascii 256)) uint))
;; Owner of a given token identifier
(get-owner (uint) (response (optional principal) uint))
;; Transfer from the sender to a new principal
(transfer (uint principal principal) (response bool uint))
)
)
SIP-10: https://github.com/stacksgov/sips/blob/main/sips/sip-010/sip-010-fungible-token-standard.md
(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))
)
)
A system or language is non-Turing complete if it cannot perform all computations possible by a Turing machine, an abstract model for computation. Non-Turing complete systems have limited capabilities compared to Turing complete ones. Turing complete languages can emulate any computation a Turing machine can perform. Examples of non-Turing complete systems include finite state machines and some specific languages like Clarity.
Non-Turing complete languages can’t express every algorithm, especially those needing unbounded loops or recursion. This is crucial for Clarity, as it inherently prevents infinite loops and reentrancy, enhancing predictability and security.
In SIP-2, the following definition is provided:
map
, filter
, or fold
(define-public (is-even (numbers (list uint 5)))
(map-each n numbers (is-eq (mod n u2) u0))
(filter not (list true false true false)) ;; Returns (false false)
(define-public (pow (base uint) (exp uint))
(fold-for (x u1) exp (* x base)))
Solidity permit the use of low level calls without requiring the return value to be checked. But in Clarity, public contract calls must return a so-called response that indicates success or failure. Any contract that calls another contract is required to properly handle the response. Clarity contracts that fail to do so are invalid and cannot be deployed on the network.
;; example.clar
(define-public (unwrap-panic-example (input (response (string-ascii 30) uint)))
(begin
(unwrap-panic input)
(ok "end of the function")
)
)
;; (contract-call? .example unwrap-panic-example (ok "Calling Unwrap Panic with ok"))
;; (contract-call? .example unwrap-panic-example (err u200))
In the development of Clarity, developers considered various security issues that occurred with Solidity. We touched on some of these concerns in the introduction to Clarity. Here, we will delve deeper into the specific changes Clarity implemented to address these security problems, ensuring a “What you see is what you get” experience.
In Clarity, the code you write runs on the blockchain exactly as you’ve written it, with no compilation into byte-code. In contrast, languages like Solidity compile code into byte-code before it’s executed on the blockchain. This process introduces two main risks:
In Solidity, the constructor function of a smart contract is defined by the constructor function. In Clarity, there is no specific function declaration for the constructor function, and any code written outside the scope of the function definition will be executed when the contract is deployed.
contract Foo {
string public name;
constructor(string memory _name) {
name = _name;
}
}
In Clarity, there is no declaration for construction functions. To implement some initialization variables, you can directly write them in the code.
(define-constant name 0x00) ;; This line of code will execute when the contract is deployed.
(define-public (foo (token-id uint) (sender principal) (recipient principal))
(if (and
;; other conditions
;; ...
nft-not-owned-err)
)
)
In Solidity, the Fallback/Receive functions are added even if you have not written any implementation for them. These two hidden functions are invisible to developers when not implemented, which introduces some risks. In contrast, in Clarity, these two functions and their functionalities do not exist, serving to prevent the aforementioned scenario.
For a example:
Here we have a contract hello-world.clar
;; A read-only function that returns a message
(define-read-only (say-hi)
(ok "Hello World")
)
;; A read-only function that returns an input number
(define-read-only (echo-number (val int))
(ok val)
)
;; A public function that conditionally returns an ok or an error
(define-public (check-it (flag bool))
(if flag (ok 1) (err u100))
)
If we try to call a function that isn’t implemented in the contract.
clarity-repl v2.11.2
Enter "::help" for usage hints.
Connected to a transient in-memory database.
+-------------------------------------------------------+-------------------------+
| Contract identifier | Public functions |
+-------------------------------------------------------+-------------------------+
| ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.hello-world | (check-it (flag bool)) |
| | (echo-number (val int)) |
| | (say-hi) |
+-------------------------------------------------------+-------------------------+
+-------------------------------------------+-----------------+
| Address | uSTX |
+-------------------------------------------+-----------------+
| ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM | 100000000000000 |
+-------------------------------------------+-----------------+
>> (contract-call? .hello-world no-exist-fun)
<stdin>:1:1: error: contract 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.hello-world' has no public function 'no-exist-fun'
(contract-call? .hello-world no-exist-fun)
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If we directly transfer STX to the contract, nothing will prevent us.
>> (stx-transfer? u60 tx-sender .hello-world)
Events emitted
{"type":"stx_transfer_event","stx_transfer_event":{"sender":"ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM","recipient":"ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.hello-world","amount":"60","memo":""}}
(ok true)
>> ::get_assets_maps
+-------------------------------------------------------+-----------------+
| Address | uSTX |
+-------------------------------------------------------+-----------------+
| ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM | 99999999999940 |
+-------------------------------------------------------+-----------------+
| ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.hello-world | 60 |
+-------------------------------------------------------+-----------------+
The security of random numbers has always been one of the key factors affecting the security of smart contracts and, especially in the Solidity ecosystem, obtaining a safe and reliable random number has always been a difficult thing. In Clarity, you can access the Verifiable Random Function (VRF) of the Stacks blockchain. This can seed the random number generator of the contract. Although VRF can be manipulated in theory, a successful attack requires an attack on the Bitcoin chain itself. This makes manipulation extremely difficult. For detailed usage instructions, please refer to https://docs.stacks.co/clarity/functions#get-block-info.
(get-block-info? vrf-seed u0)
;; Returns (some 0xf490de2920c8a35fabeb13208852aa28c76f9be9b03a4dd2b3c075f7a26923b4)
Like Solidity 8.0, Stacks’ mathematical operations come with overflow detection.
>> (- u0 u1)
error: Runtime Error: Runtime error while interpreting ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.contract-1: Runtime(ArithmeticUnderflow, Some([FunctionIdentifier { identifier: "_native_:native_sub" }]))
In Solidity, there is a selfdestruct function for destroying smart contracts. In Clarity, this function is not provided, making the functionality unavailable.
The virtual machines running Solidity and Clarity are both stack-based. Solidity has a maximum stack depth of 1024, but only the top 16 items are accessible. Clarity’s stack depth is capped at 32. Exceeding the maximum depth will result in an error.
(define-public (test1)
(begin
(ok (+ 1 1))
)
)
(define-public (test2) (ok (test1)))
(define-public (test3) (ok (test2)))
(define-public (test4) (ok (test3)))
(define-public (test5) (ok (test4)))
(define-public (test6) (ok (test5)))
(define-public (test7) (ok (test6)))
(define-public (test8) (ok (test7)))
(define-public (test9) (ok (test8)))
(define-public (test10) (ok (test9)))
(define-public (test11) (ok (test10)))
(define-public (test12) (ok (test11)))
(define-public (test13) (ok (test12)))
(define-public (test14) (ok (test13)))
(define-public (test15) (ok (test14)))
(define-public (test16) (ok (test15)))
(define-public (test17) (ok (test16)))
(define-public (test18) (ok (test17)))
(define-public (test19) (ok (test18)))
(define-public (test20) (ok (test19)))
(define-public (test21) (ok (test20)))
(define-public (test22) (ok (test21)))
(define-public (test23) (ok (test22)))
(define-public (test24) (ok (test23)))
(define-public (test25) (ok (test24)))
(define-public (test26) (ok (test25)))
(define-public (test27) (ok (test26)))
(define-public (test28) (ok (test27)))
(define-public (test29) (ok (test28)))
(define-public (test30) (ok (test29)))
(define-public (test31) (ok (test30)))
(define-public (test32) (ok (test31)))
;; This code will fail because of an error
;; error: created a type which was deeper than maximum allowed type depth
From the introduction above, it’s not hard to see that Clarity lives up to its name in terms of clarity. As a result, many of the issues that need constant attention in Solidity no longer exist in Clarity. Here, we provide a checklist that may be helpful to everyone.
-panic
FunctionsIn Clarity smart contracts, when unwrapping values, it’s advisable to steer clear of using unwrap-panic and unwrap-err-panic. These functions lead to an abortion of the call with a runtime error if they fail to unwrap the supplied value, failing to provide meaningful information to the application interacting with the contract. Instead, prefer using unwrap! and unwrap-err! with explicit error codes.
(define-map names-map { name: (string-ascii 12) } { id: int })
(map-set names-map { name: "blockstack" } { id: 1337 })
(unwrap-panic (map-get? names-map { name: "blockstack" })) ;; Returns (tuple (id 1337))
(unwrap-panic (map-get? names-map { name: "non-existant" })) ;; Throws a runtime exception without any information
When performing division operations, accuracy loss is still a problem that needs attention. Accuracy errors in calculations can have a wide range of effects, leading to financial imbalances, inaccurate data, and errors in the decision-making process.
(define-public (calculate-shares (amount uint))
(ok (/ amount u10000))
)
>> (contract-call? .counter calculate-shares u100)
(ok u0)
>> (contract-call? .counter calculate-shares u10000)
(ok u1)
tx-sender
for VerificationIt is crucial to distinguish between the transaction sender (tx-sender) and the contract caller (contract-caller) to prevent potential security bugs. Clearly distinguishing between the two helps prevent malicious contracts from taking advantage of the risk of inconsistent transaction sources and contract callers.
For example, Alice initiates a transaction to call Contract A, which then calls Contract B. The process is as follows:
During this process, the tx-sender and contract-caller of Contract A are both Alice’s addresses. However, in Contract B, the tx-sender is still Alice’s address, while the contract-caller is the address of Contract A.
Attackers may use the difference between tx-sender and contract-caller to deceive contracts. An example is as follows:
(define-data-var contract-owner principal tx-sender)
(define-public (foo)
(begin
(asserts! (is-eq tx-sender contract-caller) (err u1))
;; do something
(ok u0)
)
)
Assuming Alice deploys the contract, an attacker can deploy a malicious contract and induce Alice to call the malicious contract, which then calls the vulnerable contract. Due to the vulnerability contract only checking tx-sender, it causes security issues. An example fix is as follows:
(define-data-var contract-owner principal tx-sender)
(define-public (foo)
(begin
(asserts! (is-eq tx-sender contract-caller ) (err u1)) ;; fix ed
;; do something
(ok u0)
)
)
To ensure the security and robustness of smart contracts, it is essential to follow the principle of least privilege. The visibility of functions must be carefully managed, using appropriate access control modifiers, define-public, define-private, and define-readonly.