Build a custom blockchain API

Learn how to build a custom API with Chainhook.

Introduction

In the previous hacks, you built some pretty complex smart contracts. But building a robust UI/UX around these smart contracts and their on-chain data can be challenging due to the nature of blockchains.

Let's take the decentralized grants program hack as an example. In this project, anyone can propose a grant, and these proposals are voted on by token holders. The details of each proposal, including the title, description, and the proposal contract, are stored on the blockchain.

However, directly querying the blockchain for this data every time you want to display it in your application's UI can be slow and inefficient. And that information might not always be formatted and organized in a way that makes sense for your use case.

This is where Chainhook comes in. Chainhook allows you to listen for specific on-chain events, such as the submission of a new grant proposal, and trigger actions in response - like inserting specific data into a database to query off-chain.

In this guide, you'll build a custom API with Chainhook using the Hiro Platform, with a focus on integrating it with the contracts you built for the decentralized grants program hack.

Creating a server

In this section, we'll briefly discuss setting up an Express server to handle incoming event data from Chainhook. While a detailed walkthrough of setting up an Express server is beyond the scope of this guide, you can reference an example app here.

To illustrate how to handle this event data, here is an example server.ts file:

const express = require("express")
const app = express()
app.use(express.json())

app.post("/api/events", (req, res) => {
  const events = req.body
  // Process the event data here
  res.status(200).send({ message: "Event received" })
})

app.listen(3000, () => {
  console.log("Server is running on port 3000")
})

In this example, the server listens for POST requests at the /api/events route. When a request is received from your chainhook (which you will set up in the next section), you can extract the event data from the request body and process it.

Parsing the event data

The Chainhook payload comes with a lot of information, so let's break down some of the more important parts inside of the req.body for this guide.

Here is a truncated look at the payload structure from Chainhook:

{
  "apply": [
    {
      "transactions": [
        {
          "metadata": {
            "receipt": "..."
          },
          "operations": [
            {
              "...": "..."
            }
          ]
        }
      ]
    }
  ]
}

Each item in the apply array should contain a transactions array. Each transaction object should have a metadata object with a receipt attribute and an operations array.

  • transaction.metadata.receipt: This attribute contains information about any events that were triggered from the function.
  • transaction.operations: This is an array that contains more specific information about what happened as a result of the function call. For instance if a token transfer occured, each object inside this array would contain various attributes and information regarding the account, amount, type, and status of the token transfer.

Here's what your POST route might look like after filtering for the relevant data in your server.ts file:

app.post("/api/events", async (req, res) => {
  const events = req.body
  // Loop through each item in the apply array
  events.apply.forEach((item: any) => {
    // Loop through each transaction in the item
    item.transactions.forEach((transaction: any) => {
      // If the transaction has operations, loop through them
      if (transaction.operations) {
        transaction.operations.forEach((operation: any) => {
          // Log the operation
          console.log({ operation })
        })
      }
    })
  })

  // Send a response back to Chainhook to acknowledge receipt of the event
  res.status(200).send({ message: "Proposal added!" })
})

For the next section, you will need to expose your local server via https so that Chainhook can deliver the payload. To do that, run the following command:

npx localtunnel --port <your-port-number>

Note

There are several tools for exposing localhost to the world so choose the one you prefer. For more information on localtunnel, click here.

Integrating with Chainhook

In this section, you will integrate Chainhook with your smart contracts. This involves creating a chainhook predicate that matches the specific on-chain events you want to respond to. For this process, you'll use the Hiro Platform, which provides a user-friendly interface for creating and managing your chainhooks. By the end of this section, you'll have a chainhook set up and ready to trigger actions in response to on-chain events.

Deploying your contracts to testnet

Before you begin, make sure you have logged in to the Hiro Platform and created or imported a project. If you need help with this, refer to the Create Project guide.

In order to create your chainhook, you need to deploy your contracts to testnet. From your projects page, click on the Deploy button near the top right of your screen. You'll see your deployment options: choose "Generate for Testnet."

Note

The Platform will provide a step for requesting Testnet STX, but you can also go straight to the Stacks Testnet Faucet. Once you have testnet STX tokens, you can deploy your contracts to testnet using the same methods you used for devnet, but with the network parameter set to testnet.

Creating a Chainhook

Now it's time to create your first Chainhook. Start by creating a predicate that matches the propose function in your proposal-submission contract. This function is called when a new grant proposal is submitted.

Here's an example of how you might define this predicate.json file for your contract:

{
  "chain": "stacks",
  "uuid": "1",
  "name": "New Grant Proposal",
  "version": 1,
  "networks": {
    "testnet": {
      "if_this": {
        "scope": "contract_call",
        "contract_identifier": "ST2BSV94A650WGZ2YZ5Y8HM93W01NGT4GY0MGJECG.proposal-submission",
        "method": "propose"
      },
      "then_that": {
        "http_post": {
          "url": "<your-https-server-url>",
          "authorization_header": "Bearer 12345"
        }
      },
      "start_block": 138339
    }
  }
}
Note

Make sure to swap out the details in your predicate.json file to match your contracts ABI.

The if_this section specifies the conditions for the events that this Chainhook will respond to. In this case, it's looking for calls to the propose method in the proposal-submission contract on the Stacks testnet.

The then_that section specifies what action the Chainhook should take when it detects an event that matches the if_this conditions. Here, it's set up to send a POST request to your specified URL with the event data.

The start_block field is used to specify the starting block for the Chainhook to start listening from. This is useful for ignoring blocks that were mined before the Chainhook was set up.

Once you have created a similar file for your specific contract. select the Chainhooks tab inside your project and upload your predicate file using the Upload Chainhook option. For more information, you can follow the Create Chainhooks section.

Testing your chainhook

Once you've successfully uploaded your chainhook predicate, it's time to test it out. You can do this by performing an action that aligns with your if_this - then_that logic.

For instance, if your Chainhook is set up to respond to a specific contract call, you can trigger that call and then check your /api/post endpoint. If everything is set up correctly, you should see any information you've requested to be logged.

The next section presents several challenges that will help you put the finishing touches on your API. With the foundation you've built, you're well-equipped to take on these challenges and continue your journey in building robust, decentralized applications.

Congratulations on reaching this point! You now have all the building blocks needed to create a custom API using Chainhook and integrate it with your smart contracts. This is a significant step towards enhancing the functionality and user experience of your decentralized applications.

Note

This is a crucial step in verifying that your Chainhook is working as expected. If you don't see the expected output, you may need to revisit your predicate and ensure it's correctly configured.

Challenges

The following challenges are additional features you can implement to further explore building on Stacks. Feel free to add any other features you want.

Starter

Create more than 1 chainhook: Don't stop at just 1! Follow the process above to create more than 1 chainhook for your dApp and parse the data accordingly.

Intermediate

Create consumer-facing API endpoints: Up until now, we've shown you how to capture and process the initial payload data sent from a chainhook. However, to provide a robust user experience, you'll want to do more than just output raw data. The challenge here is to create additional API endpoints that present the filtered and extracted data from the initial /api/events payload in a consumer-friendly format. This could involve adding a database layer for organizing the data, adding additional context, or transforming the data to match the needs of your application's frontend. For a quick db setup, you can try using something like Supabase.

// Example using Supabase

const supabase = createClient(
  "https://<project>.supabase.co",
  "<your-anon-key>"
)

app.post("/api/events", async (req, res) => {
  // ...

  item.transactions.forEach((transaction: any) => {
    if (transaction.operations) {
      transaction.operations.forEach((operation: any) => {
        const data = operation // Parse out the data you want
        // Insert and save it to db
        const { error } = await supabase.from("proposals").insert(data)
      })
    }
  })

  // ...
})

Advanced

UI integration: The final challenge is to integrate your consumer-facing API into your frontend. This involves taking the data you've processed and organized in your API and displaying it in a meaningful and user-friendly way in your application. This could involve creating dynamic components that update in response to new data, adding interactive elements that allow users to explore the data, or incorporating visualizations to help users understand the data.