For a space that says the word “trustless” a lot, trust is still critically important in Web3. It’s just “who” you trust that’s changed. In Web2, you trust someone, whether that’s an individual or a company, but in Web3, you trust something—the code.
And in Web3, that trust is often abused. Every week, a new user posts on X about how their wallet was drained and all of their assets stolen. Contract exploits happen all the time.
For new users, the confusing UX and risks can be daunting, and even Web3 veterans become victims once in a while. Users want to transact safely and confidently, and we want that for them too!
Post-conditions are a tool unique to the Stacks ecosystem that enable you to better protect your users and offer them an additional layer of security when interacting with your apps.
What Are Post-Conditions?
Post-conditions are assertions about an on-chain transaction that must be met; otherwise, the transaction will abort during execution. In other words, post-conditions act as a safety net, allowing you to specify what state changes can occur in a transaction. This logic helps limit the amount of damage that can be done to a user and their assets, whether due to a bug or malicious behavior.
From a user's perspective, post-conditions build confidence in their transactions. Rather than users signing transactions and trusting that the code is sound, users can review explicit post-conditions before signing a transaction, giving them more confidence in knowing exactly what the transaction can and cannot do.
Post-conditions act as a safety net, allowing you to specify what state changes can occur in a transaction.
For example, a post-condition can specify that a transaction will transfer no more than <code-rich-text>n<code-rich-text> tokens. And if some bug or malicious code tries to drain the wallet and transfer more than <code-rich-text>n<code-rich-text> tokens, the transaction will abort, even if it would have otherwise succeeded.
Understanding Post-Condition Structure
Post-conditions are enforced by the Stacks protocol itself but do not exist in the smart contracts themselves. Instead, they are programmatically constructed in your front-end application code using Stacks.js, specifically by passing them in as options to the transaction payload construction.
By having post-conditions in the frontend code, Stacks-enabled wallets, such as Leather and Xverse, are able to display the post-conditions in a human-readable format for the user when confirming their transactions. Once a user confirms the transaction, the post-conditions get carried along with the transaction payload where eventually the Stacks protocol will evaluate them together.
If there were no post-conditions in the front-end application code, a user’s wallet will display an abstract warning message, where it would be up to the user to decide whether they want to blindly proceed with the transaction or not. And whatever the underlying contract code wants to do, it will do without any post-condition restrictions. So if a contract tries to send your STX tokens to a drainer wallet, it will without you knowing.
Even with post-conditions set up on the frontend code, a user is still blind to the underlying Clarity smart contract code, but at least they know what to expect will happen in the transaction. And if that expectation is not met, the transaction will abort and fail.
Let’s look at an example of a sneaky malicious contract. The Clarity smart contract code below shows a donate function that accepts a specified donation amount as the parameter that the user intends to donate.
In line 4 where <code-rich-text>stx-transfer?<code-rich-text> is being called, the contract will maliciously add 100 STX to the specified <code-rich-text>amount<code-rich-text> argument, essentially transferring out more STX than intended from the user’s wallet.
If a post-condition below, stating ONLY have the user donate 5 STX to the contract, was constructed and added as an option to the transaction payload, the transaction would abort and fail.
The post-condition above is saying that the user should expect the intended donation amount, which is 5 STX, will be the ONLY amount sent out from the user’s wallet. If the actual amount sent out deviates from that or if any other asset transfer happens, the transaction will abort.
This will also be communicated to the user in the wallet confirmation popup modal like so:
These post-conditions statements will also be visible to the user in their Explorer page for the transaction. It will show what post-condition statements were attached to the transaction payload as well as the result of the transaction status:
This example clearly shows how post-conditions can give users the expectation of what will happen in a contract call transaction, and if that expectation is not met, the transaction will abort and fail, safeguarding the users’ assets.
Deny Mode (Default)
If you have noticed, there is also a <code-rich-text>PostConditionMode<code-rich-text> option passed in with the property <code-rich-text>.Deny<code-rich-text>. Deny mode is the default for post-conditions. Deny is a more-strict setting for post-conditions, and it says that any other asset transfers that do not meet the criteria of the post-condition are denied.
In deny mode, any transaction that does not meet the post-condition criteria will fail.
This setting is useful when you want to limit any transfer events to a specific set of criteria. This setting is ultimately what makes post-conditions powerful, hence the reason why this is defaulted as <code-rich-text>.Deny<code-rich-text> if you don’t or forget to pass in a <code-rich-text>PostConditionMode<code-rich-text> option.
Allow Mode
Allow mode is a less-strict setting, in which it “allows” any transaction to execute as long as it meets the criteria of the specified post-conditions. In other words, this <code-rich-text>.Allow<code-rich-text> mode enables additional transactions to occur as long as the post-condition is met in that process.
In allow mode, all transfer events will execute as long as they meet the specified post-condition criteria.
This setting is useful when you want to allow other unknown or dynamic transfers to happen. But usually you wouldn’t want to have this happen as this can open up unintended consequences for the user. In most cases <code-rich-text>.Allow<code-rich-text> mode is rarely used.
Are you a visual learner? Watch this tutorial to dive into post-condition construction:
Other Examples of Post-Conditions Structures
So we saw an example of constructing a post-condition onto a native STX transfer, let's look at some other examples of constructing post-conditions on different types of Stacks assets.
Specifying a FT Token Transfer
In this example, we have a post-condition that ensures exactly 100 tokens or less will be sent in a transaction, no more, no less. This is useful for enabling simple payments or transfers and can limit the risk of transfers of unexpected volumes (such as a malicious bug draining a user wallet unexpectedly).
Specifying a NFT Transfer
You can also use post-conditions to transfer a specified NFT. This can ensure that you transfer only the NFT you intend to sell and can prevent phishing attacks, transferring multiple NFTs at once, and other kinds of errors. This type of post-condition can be especially helpful for NFT marketplaces and gaming applications that make use of a high volume of NFTs.
Specifying a SFT Transfer
It’s also possible to use post-conditions on an SFT (semi-fungible-token). SFTs are a hybrid of an NFT and an FT token: where a unique tuple represents the NFT id and the amount of units represented as fungible tokens. Therefore, at least two post-conditions are needed to handle the transfer of an SFT.
Other Use Cases for Post-Conditions
Controlling Transfer Slippage
Post-conditions can also protect users from excessive slippage. When interacting with DeFi applications, you can set post-conditions that state you will transfer no more than a certain token amount in exchange for `n` tokens. When prices can be extremely volatile, this type of post-condition can offer some assurance that the trade that you agreed to is the trade you’ll get.
Affirming purchases on NFT marketplaces
Post-conditions can help ascertain buy/sell orders on an NFT marketplace. Specifying that the user should expect to only pay a specific amount of STX for a specific NFT can assure users of their purchases.
Limitations of Post-Conditions
While powerful, post-conditions have some limitations you should keep in mind. Post-conditions only track who sends an asset, and how much. They do not monitor who owns any set of assets when the transaction finishes, nor do they monitor the sequence of owners an asset might have during transaction execution.
Alongside those limitations, it should be obvious, but it’s worth explicitly stating that post-conditions are not a catch-all. Just because you implement post-conditions doesn’t mean your contract or next transaction are guaranteed to be safe. Bugs can still occur, and you still need to build with security in mind. Debugging and extensive tests are still your best friend.
Conclusion
Post-conditions are a unique feature of Stacks that provide a powerful degree of transparency and security to transactions. Post-conditions are like bouncers for transactions: they check them to make sure they meet the necessary criteria before letting them through.
By understanding and properly implementing them, you can build more secure apps that protect users from unexpected state changes and potential vulnerabilities.
To learn more about post-conditions:
- Review Stacks docs
- Read our deep-dive on post-conditions
- Watch our tutorial videos (part 1 & part 2)
- Join the Stacks Discord and chat with other developers
Remember: in blockchain development, security isn't optional. Post-conditions are a tool that can help ensure transactions behave exactly as intended, every time.