Over the past few months, our team has conducted extensive research on the Bitcoin ecosystem and its developments. We also audited several Bitcoin projects and smart contracts based on different languages, including OKX’s BRC-20 wallet and MVC DAO’s sCrypt smart contract implementation.
Now, our focus has shifted to Clarity. After the successful completion of several Clarity bug bounty projects, during which we acquired additional insights into security issues and common practices, we are now in a position to share our insights.
Clarity is a smart contract language developed collaboratively by Hiro PBC, Algorand, and other stakeholders. It is currently utilized on the Stacks chain (Bitcoin sidechain). The primary goal of Clarity is to provide a high level of predictability and security, ensuring that smart contracts behave as intended without any unexpected side effects.
In this article, we explore the concept behind Clarity smart contracts, as well as best practices and security checklists for programming with Clarity.
Clarity’s design stems from a deep analysis of vulnerabilities in smart contract engineering — particularly those observed in Solidity. Its key features include the following:
Clarity distinguishes itself by taking inspiration from LISP, a language known for its simplicity and power in handling symbolic information. In Clarity, everything is represented as a list inside of a list, or an expression within an expression. This nested structure is a core feature, making the language highly expressive and flexible. Function definitions, variable declarations, and function parameters are all encapsulated within parentheses, emphasizing the language’s syntactic uniformity.
Here is an example of defining a simple clarity function:
(define-data-var count int 0) //State Variable Declaration (define-public (increase-number (number int)) //Function definition (let ( (current-count count) ) (var-set count (+ 1 number)) (ok (var-get count)) ) ) (increase-number 1) //Function call
By understanding and leveraging these nested expressions, developers can create secure and efficient smart contracts tailored to the Stacks blockchain’s capabilities. This approach not only enhances readability, but also ensures that the contracts are deterministic and predictable — key features for maintaining security and trust in decentralized applications.
1. Interpretation vs. Compilation
2. No Dynamic Dispatch
Security has always been a paramount concern in DeFi, particularly within the Bitcoin DeFi ecosystem where the Stacks network plays a critical role. Robust security measures are even more important now, given that the TVL in the Stacks ecosystem is approximately $80 million, as of August, 2024.
Up to now, Stacks has experienced multiple incidents, resulting in more than $2 million in losses. These incidents underscore the necessity for security audits in Clarity smart contracts.
Past Incidents
On April 11, 2024, Zest Protocol, a lending protocol on the Stacks network (Bitcoin L2), suffered a significant exploit targeting its Borrow Pool, resulting in a loss of 322,000 STX (~ $1M). This hack is by far one of the largest hacks in the Bitcoin DeFi ecosystem.
Zest Protocol’s borrow function, defined in contract pool-borrow.clar
, allows users to borrow assets by providing collateral. The function parameters include the pool reserve, price oracle, the asset to be borrowed, liquidity provider token, a list of collateral assets, the amount to be borrowed, fee calculator, interest rate mode, and owner:
(define-public (borrow (pool-reserve principal) (oracle <oracle-trait>) (asset-to-borrow <ft>) (lp <ft>) (assets (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> })) (amount-to-be-borrowed uint) (fee-calculator principal) (interest-rate-mode uint) (owner principal))
Borrow function in pool-borrow-v1-1.clar
The attacker exploited the assets parameter, a list of up to 100 assets used as collateral. Its vulnerability stemmed from the contract’s failure to verify the uniqueness of the assets provided. More specifically, the contract did not check for duplicate entries while verifying the existence of assets. This oversight allowed the attacker to manipulate the collateral value by listing the same asset multiple times.
Other protocols have suffered from similar security exploits. Notably, in October 2021, an attacker drained approximately 400,000 STX and 740,000 USDA (equivalent to approximately $1.5 million) from Arkadiko Swap. The attacker exploited an issue in Arkadiko Swap’s smart contract code that failed to validate LP tokens correctly during the creation of new trading pairs. This vulnerability enabled the attacker to mint a large amount of LP tokens at zero cost, and subsequently drain the underlying assets from the STX/USDA pool, affecting 25% of its total pool value.
Best Practices and Checklist in Clarity Smart Contracts
We have summarized our learnings from extensive research, and compiled best practices and checklists for Clarity smart contract developers. Here are the key points:
Avoid Using -panic
Functions
When unwrapping values in Clarity smart contracts, avoid using unwrap-panic
and unwrap-err-panic
. These functions abort the call with a runtime error if they fail to unwrap the supplied value, which does not provide meaningful information to the application interacting with the contract. Instead, opt for unwrap!
and unwrap-err!
with explicit error codes. This approach not only improves error handling, but also facilitates debugging and enhances the resilience of your smart contracts. Using specific error codes allows the calling application to handle errors gracefully and take appropriate actions based on the context.
Avoid Using tx-sender
for Verification
Misuse of the ‘tx-sender’ variable during authentication in Clarity smart contracts can lead to security vulnerabilities, mirroring vulnerabilities listed in SWC-115 from Solidity. The tx-sender
variable identifies the initiating caller of a call chain, which acts similar to the tx.origin
in Solidity. Using tx-sender
for verification could lead to phishing attacks, which can trick users into performing authenticated actions on the vulnerable contract.
Comparison between tx-sender
and contract-caller
On the other hand, contract-caller
represents the sender of the current call. By avoiding the use of tx-sender
for authentication and employing more secure alternatives like contract-caller
, developers can reduce the risk of phishing attacks and cross-site scripting.
Modular Contract Design for Enhanced Flexibility and Future Upgradability
Once a smart contract is deployed on a blockchain, it becomes immutable and cannot be modified. This immutability presents a challenge compared to conventional application development, where updates and bug fixes can be readily implemented. In smart contract development, ensuring flexibility and future-proofing requires a strategic approach, as there is no direct method to update the contract code once deployed.
To address these challenges, developers should consider the following principles:
Keep Logic Separate: Avoid creating a single, monolithic contract that handles all functionalities. Instead, modularize your smart contract by breaking it down into smaller, distinct components that can interact with each other. This approach not only makes the contract easier to manage and understand, but also allows individual components to be replaced or upgraded without affecting the entire system.
Make Contracts Stateless: Stateless contracts store minimal data on the blockchain, reducing the complexity and potential impact of future changes. By keeping the state outside the contract and passing it as input parameters, you can update the logic without having to modify the contract’s state.
Avoid Hard Coded Variables: Hard coding values directly into your contract code can lead to inflexibility and hinder future updates. Instead, define key variables as configurable parameters that can be set or adjusted through contract functions.
Avoid Time Calculation Based on block-hight
In Clarity smart contracts, avoid relying on the block-height
keyword for time-sensitive calculations. Stacks chain block time can change with network upgrades, such as the Nakamoto Release
, which will reduce block time. Instead, use the burn-block-height
keyword, which reflects the current block height of the underlying Bitcoin blockchain. Bitcoin’s block time is more stable and less likely to change, ensuring greater accuracy and reliability in your contract’s operations. This practice helps maintain consistency and prevents potential issues caused by fluctuations in the Stacks block time.
Properly Handle Return Values in Functions
When developing Clarity smart contracts, it is essential to correctly handle the boolean return values of functions, especially when dealing with functions like verify-mined()
.
This function can return three possible values: (ok true), (ok false), or an error. If the function returns (ok true), it indicates that the transaction was mined at the specified block. If it returns (ok false), the transaction was not mined, and an error signifies an issue with the merkle proof.
A common issue arises when using try!
to check for ok/error
, but failing to verify the boolean value wrapped in the response type. This oversight can lead to scenarios where the function does not fail, even if the transaction is not mined in the block (when the return value is (ok false)). Consequently, validators can potentially cooperate to sign a non-mined transaction, allowing it to pass through the indexer unchecked. This loophole enables unmined and potentially malicious transactions to be processed without proper validation, leading to security breaches and unauthorized actions within the system.
To mitigate this risk, ensure that your code checks for errors and explicitly verifies the boolean value returned by the function. This practice helps maintain the integrity and security of the contract by ensuring that only valid, mined transactions are processed.
Proper Use of contract-call?
in Clarity
When developing Clarity smart contracts, it is essential to correctly implement inter-contract calls using the contract-call?
function, which returns a Response type result from the called smart contract.
There are two types of contract-call?
:
Static Call: The callee is a known, invariant contract available on-chain when the caller contract is deployed. The principal of the callee is provided as the first argument, followed by the method name and its arguments.
(contract-call? .registrar register-name name-to-register)
Dynamic Call: The callee is passed as an argument and typed as a trait
reference. This allows for more flexible and reusable code by referencing traits.
(define-public (swap (token-a <can-transfer-tokens>) (amount-a uint) (owner-a principal) (token-b <can-transfer-tokens>) (amount-b uint) (owner-b principal))) (begin (unwrap! (contract-call? token-a transfer-from? owner-a owner-b amount-a)) (unwrap! (contract-call? token-b transfer-from? owner-b owner-a amount-b))))
Note the following limitations when dealing with contract calls:
contract-call?
are for inter-contract calls only. Attempts to execute when the caller is also the callee will abort the transaction.CertiK has conducted extensive research on Clarity smart contract security. As a leading auditing firm with vast experience in smart contract security, CertiK has identified and reported vulnerabilities in various Clarity-based bug bounty projects. For more information about our previous risk analyses, such as those involving Ordinals/BRC-20 and the sCrypt smart contract, visit our blog here.
We’re excited to support the Stacks ecosystem. For a thorough audit of your smart contract code or to consult with our team of experienced auditors and security experts, please get in touch with us at CertiK.com.