Understanding Stacks Post Conditions

krgrs

kenny

Posted on March 11, 2022

Understanding Stacks Post Conditions

What Are Post Conditions?

Post conditions are one of my favorite features of Stacks. They are one of the many unique features of Stacks and Clarity that helps make it easier for developers to build safe applications and for users to be safer users of those applications.

If you aren't familiar with Stacks or Clarity, you can check out my Intro to Stacks tutorial, where we walk through the basics of what Stacks is and how to build a full-stack app on it.

Now, what exactly are post conditions?

Put simply, post conditions are a set of conditions that must be met before a user's transaction will execute. The primary goal behind post conditions is to limit the amount of damage that can be done to a user's assets due to a bug, intentional or otherwise.

How Post Conditions Work

Post conditions allow the developer of an application to append a set of conditions that must be met. If these conditions are not met, the transaction will abort and the user will only be out the transaction fee.

How might this look in practice?

Say we are building an NFT marketplace and a user wants to purchase an NFT for 20 STX. As the developers of that application, we can embed post conditions stating that the purchaser's transaction should not transfer more than 20 STX and that they should own one NFT of that specific type.

If contract execution would result in either of these two things not happening, the entire thing aborts and the purchaser loses nothing but the transaction fee.

It's crucial to note that post conditions are not something that is defined in a Clarity smart contract. They are meant to be separate from the contract so that the user has some control over what happens when they initiate a transaction outside of the contract itself.

So they are sent as part of the transaction when the user initiates it.

The post conditions will be shown prior to a user initiating a transaction in their wallet.

As web3 developers, it is our job to make sure we write code with good post conditions to safeguard our user's assets as much as possible.

Types of Post Conditions

We have three different types of post conditions to cover the three main types of assets on Stacks: NFTs, fungible tokens, and STX tokens.

All of these post conditions correspond to the transaction sender's account and assets.

Let's look at a few code examples pulled from the Stacks.js docs.

STX Post Condition

import {
  FungibleConditionCode,
  makeStandardSTXPostCondition,
  makeContractSTXPostCondition,
} from '@stacks/transactions';

// With a standard principal
const postConditionAddress = 'SP2ZD731ANQZT6J4K3F5N8A40ZXWXC1XFXHVVQFKE';
const postConditionCode = FungibleConditionCode.GreaterEqual;
const postConditionAmount = 12345n;

const standardSTXPostCondition = makeStandardSTXPostCondition(
  postConditionAddress,
  postConditionCode,
  postConditionAmount
);

// With a contract principal
const contractAddress = 'SPBMRFRPPGCDE3F384WCJPK8PQJGZ8K9QKK7F59X';
const contractName = 'test-contract';

const contractSTXPostCondition = makeContractSTXPostCondition(
  contractAddress,
  contractName,
  postConditionCode,
  postConditionAmount
);
Enter fullscreen mode Exit fullscreen mode

Here we've got the basic setup for a post condition for a STX transfer.

After we import the necessary packages, we are setting up a condition that says that the user with the specified address will transfer an amount of STX that will be greater than or equal to the specified amount.

So as the user, if we were to initiate this transaction with the Hiro Wallet, we would be presented with a condition that stated that we will be transferring at least 12,345 STX or the transaction will abort.

The second section is doing something similar, but in this case it is being applied to the contract, so we're saying that the contract will be transferring at least the specified amount, rather than the user.

Fungible Token Post Condition

import {
  FungibleConditionCode,
  createAssetInfo,
  makeStandardFungiblePostCondition,
} from '@stacks/transactions';

// With a standard principal
const postConditionAddress = 'SP2ZD731ANQZT6J4K3F5N8A40ZXWXC1XFXHVVQFKE';
const postConditionCode = FungibleConditionCode.GreaterEqual;
const postConditionAmount = 12345n;
const assetAddress = 'SP62M8MEFH32WGSB7XSF9WJZD7TQB48VQB5ANWSJ';
const assetContractName = 'test-asset-contract';
const fungibleAssetInfo = createAssetInfo(assetAddress, assetContractName);

const standardFungiblePostCondition = makeStandardFungiblePostCondition(
  postConditionAddress,
  postConditionCode,
  postConditionAmount,
  fungibleAssetInfo
);

// With a contract principal
const contractAddress = 'SPBMRFRPPGCDE3F384WCJPK8PQJGZ8K9QKK7F59X';
const contractName = 'test-contract';
const assetAddress = 'SP62M8MEFH32WGSB7XSF9WJZD7TQB48VQB5ANWSJ';
const assetContractName = 'test-asset-contract';
const fungibleAssetInfo = createAssetInfo(assetAddress, assetContractName);

const contractFungiblePostCondition = makeContractFungiblePostCondition(
  contractAddress,
  contractName,
  postConditionCode,
  postConditionAmount,
  fungibleAssetInfo
);
Enter fullscreen mode Exit fullscreen mode

The process here is similar. The main difference is that in addition to supplying the conditions for the transfer itself, since this corresponds to a custom SIP-010 token, we are also specifying the token info by referencing the contract name and address that defines the token.

NFT Post Condition

import {
  NonFungibleConditionCode,
  createAssetInfo,
  makeStandardNonFungiblePostCondition,
  makeContractNonFungiblePostCondition,
  bufferCVFromString,
} from '@stacks/transactions';

// With a standard principal
const postConditionAddress = 'SP2ZD731ANQZT6J4K3F5N8A40ZXWXC1XFXHVVQFKE';
const postConditionCode = NonFungibleConditionCode.Sends;
const assetAddress = 'SP62M8MEFH32WGSB7XSF9WJZD7TQB48VQB5ANWSJ';
const assetContractName = 'test-asset-contract';
const assetName = 'test-asset';
const tokenAssetName = bufferCVFromString('test-token-asset');
const nonFungibleAssetInfo = createAssetInfo(assetAddress, assetContractName, assetName);

const standardNonFungiblePostCondition = makeStandardNonFungiblePostCondition(
  postConditionAddress,
  postConditionCode,
  nonFungibleAssetInfo,
  tokenAssetName
);

// With a contract principal
const contractAddress = 'SPBMRFRPPGCDE3F384WCJPK8PQJGZ8K9QKK7F59X';
const contractName = 'test-contract';

const contractNonFungiblePostCondition = makeContractNonFungiblePostCondition(
  contractAddress,
  contractName,
  postConditionCode,
  nonFungibleAssetInfo,
  tokenAssetName
);
Enter fullscreen mode Exit fullscreen mode

This one is a bit different. Rather than specifying the amount that the transfer should be compared to, we are setting the condition that after this transaction executes, the specified address should own the specified NFT.

We could combine a couple of post conditions for this as well, and in fact this is what we are going to do in just a moment when we look at how to use post conditions in a sample app.

Let's say we are purchasing an NFT for 50 STX. We can add two post conditions that require that after the transaction executes we will transfer no more than 50 STX from our principal and that our principal will own the specified NFT.

Note that post conditions don't allow us to specify that we own a NFT with a particular identifier, only that we own one NFT of that particular name and contract.

We can see the different comparator codes for each type in the @stacks/transactions source code.

Sample App

Let's take everything we've learned about post conditions and use them to create a basic sample app using Stacks.js.

As always, reach out and let me know your feedback or if you notice any errors or potential improvements.

Our sample app is a very simple app where we can buy and sell a custom SIP-009 token. This will allow us to look at how to implement the sample post condition scenario outlined above.

Since we are focusing on the post conditions in this tutorial, I've created a sample repo with a completed frontend and contracts, the only thing missing is the set of post conditions.

In that same repo, I also have a separate branch with the completed code so you can compare.

First, download the starter code from GitHub and let's get it fired up.

You'll need npm installed on your system to follow along. I'm using Node 17. You can use nvm if you need to switch versions.

Switch into the frontend directory and run npm install and then npm run dev, then switch into the backend directory and run clarinet integrate to get everything up and running.

If you open up the fabulous-frogs.clar file you can see we have a simple contract setting up a SIP-009 NFT.

Pay particular attention to the mint function, as that is the function that we will be utilizing and to which we will be attaching post conditions.

(define-public (mint (recipient principal))
    (let
        (
            (token-id (+ (var-get last-token-id) u1))
        )
        (try! (stx-transfer? u50000000 tx-sender (as-contract tx-sender)))
        (try! (nft-mint? fabulous-frogs token-id recipient))
        (var-set last-token-id token-id)
        (ok token-id)
    )
)
Enter fullscreen mode Exit fullscreen mode

This is a basic mint function that will first increment that token-id variable. Then, using that value as the context for the rest of the expressions (this is what the let function does) it will execute the other expressions.

In this case, we are first attempting to transfer 50 STX from the current user, tx-sender to the contract, (as-contract tx-sender).

If that succeeds, we then attempt to mint a new NFT using the newly calculated token-id, set a new last-token-id for the next round of minting, and return.

Now let's shift our focus to the frontend code and see what we need to do.

Here's what the initial index.tsx file looks like:

import { useState, useEffect } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import { StacksMocknet } from '@stacks/network'
import {
  AppConfig,
  UserSession,
  showConnect,
  openContractCall,
} from '@stacks/connect'
import {
  NonFungibleConditionCode,
  FungibleConditionCode,
  createAssetInfo,
  makeStandardNonFungiblePostCondition,
  makeStandardSTXPostCondition,
  bufferCVFromString,
  standardPrincipalCV,
} from '@stacks/transactions'

const Home: NextPage = () => {
  const appConfig = new AppConfig(['publish_data'])
  const userSession = new UserSession({ appConfig })
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(true)
  const [userData, setUserData] = useState({})
  const [loggedIn, setLoggedIn] = useState(false)

  // Set up the network and API
  const network = new StacksMocknet()

  function authenticate() {
    showConnect({
      appDetails: {
        name: 'Fabulous Frogs',
        icon: 'https://assets.website-files.com/618b0aafa4afde65f2fe38fe/618b0aafa4afde2ae1fe3a1f_icon-isotipo.svg',
      },
      redirectTo: '/',
      onFinish: () => {
        window.location.reload()
      },
      userSession,
    })
  }

  useEffect(() => {
    if (userSession.isSignInPending()) {
      userSession.handlePendingSignIn().then((userData) => {
        setUserData(userData)
      })
    } else if (userSession.isUserSignedIn()) {
      setLoggedIn(true)
      setUserData(userSession.loadUserData())
    }
  }, [])

  const mint = async () => {
    const assetAddress = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'

    const functionArgs = [
      standardPrincipalCV(
        userSession.loadUserData().profile.stxAddress.testnet
      ),
    ]

    const options = {
      contractAddress: assetAddress,
      contractName: 'fabulous-frogs',
      functionName: 'mint',
      functionArgs,
      network,
      appDetails: {
        name: 'Fabulous Frogs',
        icon: 'https://assets.website-files.com/618b0aafa4afde65f2fe38fe/618b0aafa4afde2ae1fe3a1f_icon-isotipo.svg',
      },
      onFinish: (data: any) => {
        console.log(data)
      },
    }

    await openContractCall(options)
  }

  return (
    <div className="flex min-h-screen flex-col items-center justify-center py-2">
      <Head>
        <title>Fabulous Frogs</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className="flex w-full flex-1 flex-col items-center justify-center px-20 text-center">
        <h1 className="text-6xl font-bold">Mint Your Fabulous Frog</h1>

        <p className="mt-4 w-full text-xl md:w-1/2">
          Fabulous Frogs are the most fabulous amphibians this side of the
          Mississippi. You can mint yours for only 50 STX.
        </p>

        <div className="mt-6 flex max-w-4xl flex-wrap items-center justify-around sm:w-full">
          {loggedIn ? (
            <button
              onClick={() => mint()}
              className="rounded bg-indigo-500 p-4 text-2xl text-white hover:bg-indigo-700"
            >
              Mint
            </button>
          ) : (
            <button
              className="bg-white-500 mb-6 rounded border-2 border-black py-2 px-4 font-bold hover:bg-gray-300"
              onClick={() => authenticate()}
            >
              Connect to Wallet
            </button>
          )}
        </div>
      </main>
    </div>
  )
}

export default Home

Enter fullscreen mode Exit fullscreen mode

We have a basic auth system and a simple mint function. Right now there are no post conditions but everything else is working just fine.

You'll need to modify the assetAddress to the address of the NFT contract that Clarinet generates when you run clarinet integrate.

Also, if you have not already, you need to hook your local Hiro Wallet account to one of the Devnet accounts to get some test STX and interact with the site.

I show how to do this in my Stacks 101 article under the heading titled "Adding STX to Your Local Account".

If you try to mint an NFT using this, you'll notice that we have a default post condition of not transferring anything.

Image description

So if we try to run this now, it will fail.

We need to add two conditions:

  1. We need to ensure that we are being transferred an instance of the Fabulous Frog NFT
  2. We need to ensure that we are not transferring more than 50 STX

Let's do that now. Remember, if you want to compare you can view the completed code in this branch.

We can create and add both post conditions with the following code. Add this right below the line where we are declaring the assetAddress variable.

const postConditionAddress =
    userSession.loadUserData().profile.stxAddress.testnet
const nftPostConditionCode = NonFungibleConditionCode.Sends
const assetContractName = 'fabulous-frogs'
const assetName = 'fabulous-frogs'
const tokenAssetName = bufferCVFromString('fabulous-frogs')
const nonFungibleAssetInfo = createAssetInfo(
    assetAddress,
    assetContractName,
    assetName
)

const stxConditionCode = FungibleConditionCode.LessEqual;
const stxConditionAmount = 50000000; // denoted in microstacks

const postConditions = [
    makeStandardNonFungiblePostCondition(
    postConditionAddress,
    nftPostConditionCode,
    nonFungibleAssetInfo,
    tokenAssetName
    ),
    makeStandardSTXPostCondition(
    postConditionAddress,
    stxConditionCode,
    stxConditionAmount
    )
]
Enter fullscreen mode Exit fullscreen mode

What we are doing here is first setting all the variables we will need in order to create the post conditions.

After we declare the variables, we are creating the actual post conditions and putting them in an array. We use the makeStandardNonFungiblePostCondition and makeStandardSTXPostCondition from the @stacks/transactions package to actually create this.

Most of this is pretty self-explanatory, we are just defining the type of asset that should be transferred and referencing it's contract and name in the NFT case.

All we are doing here is taking the identifying information from our NFT and putting into a format that the Stacks blockchain can understand.

That involves passing the result of a function called createAssetInfo, a function included in stacks.js into the post conditions. This takes the token name, contract name, and asset contract address and formats it to send with the transaction to the chain itself.

If you are curious, you can see what each of these pieces of data correspond to on chain by reading the SIP.

Why do we need to convert the token name to a buffer by using the bufferCVFromString function?

From the document linked above, referring to one of items required to be passed into the post condition body for an NFT condition:

"A variable-length asset name, which is the Clarity value that names the token instance, serialized according to the Clarity value serialization format."

Everything we are doing here is to convert our data into a format that can be passed to the chain to evaluate the post conditions that must be met.

I got the address here from the local Clarinet Devnet chain, so be sure to add yours by setting the assetAddress variable if you haven't already.

And in the case of the STX transfer, we are defining how much should be transferred (in microstacks) and the comparator.

For fungible and STX token conditions, we have 5 possible operators:

  • Equal
  • Greater
  • GreaterEqual
  • Less
  • LessEqual

And for the NFT conditions we only have two:

  • Sends
  • DoesNotSend

These come from constants in the @stacks/transactions package called FungibleConditionCode and NonFungibleConditionCode respectively.

The last thing we need to do is actually pass these conditions into our function call by adding them to the options object:

const options = {
    contractAddress: assetAddress,
    contractName: 'fabulous-frogs',
    functionName: 'mint',
    functionArgs,
    network,
    // Passing the post conditions here
    postConditions,
    appDetails: {
    name: 'Fabulous Frogs',
    icon: 'https://assets.website-files.com/618b0aafa4afde65f2fe38fe/618b0aafa4afde2ae1fe3a1f_icon-isotipo.svg',
    },
    onFinish: (data: any) => {
    console.log(data)
    },
}
Enter fullscreen mode Exit fullscreen mode

And now if we try to run this again, it should work as expected.

Image description

These two simple examples show how we can construct post conditions for all sorts of scenarios so we can help to safeguard our apps against unexpected behavior.

If you were to change the NFT condition code from Sends to DoesNotSend, and try to Mint again, you'll notice that it fails by aborting due to supplied post conditions.

Why Post Conditions Are Useful

One of the most common frustrations with web3 and DeFi applications is that it is easy for people to screw up irreversibly. Post conditions allow the developer of a UI or the user (if their wallet allows for it) to declare up front what they expect to happen in clear language.

If that thing does not happen, they don't have to worry about losing their assets.

This adds one extra layer of security for users of our application. As an example, let's say that we were very careless in writing our smart contract and we accidentally priced the NFT at 500 STX instead of 50 by adding an extra 0.

If our UI says that it costs 50 STX, but the contract actually tries to transfer 500, we can add an extra layer of security and help safeguard against that error with our post conditions.

Post conditions are one of the many unique security features that makes Stacks an ideal chain on which to build robust decentralized software.

In my opinion, we are only beginning to see the benefits of post conditions, and as the UX for web3 apps gets better and better, I envision post conditions as being an essential security feature that will help both developers and users to be able to better protect their assets and not have to exclusively trust the developer of the smart contract.

It adds an additional layer of security to help secure users' assets that, as far as I'm aware (correct me if I'm wrong here), does not exist in other chains.

As always, please feel free to reach out to me directly or hop in the Stacks Discord if you have any questions or feedback.

💖 💪 🙅 🚩
krgrs
kenny

Posted on March 11, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related