Building a Boost SDK Plugin
Published: 2024-03-20 23:36:22

## Building a Boost SDK Plugin: A Step-by-Step Guide

![Boost](https://i.imgur.com/KTi4xMR.png)

Welcome to our tutorial on creating a Boost SDK plugin.

Our goal today is to build a plugin that translates complex swap actions into a format understandable by Boost Protocol. Once a plugin for your project is built, you will be able to launch boosts through the [**Boost Manager App**](https://manager.boost.xyz/).

![boost manager](https://i.imgur.com/MFiAlnt.png)

During this tutorial we will build a swap action plugin for [Aerodrome](https://aerodrome.finance/), which is a popular DEX on BASE. We will cover everything from research to getting your plugin ready to submit for review.

Let's dive in!

------

## Part 0: Research

Before we start building our plugin, it is essential to collect as much information as possible for the action we want to cover. This means getting the contract addresses and individual functions that we will want to filter in our plugin.


### Collect Contract Details and Transactions

The first step in creating our plugin is to gather necessary information about the swap action we aim to cover. This involves identifying the smart contracts and the specific functions related to our action. We will be making a plugin for Aerodrome, so we need to get the contract address for the Aerodrome swap router, and transaction examples for the various swap functions on that contract.

**Aerodrome Router:** [0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43](https://basescan.org/address/0xcf77a3ba9a5ca399b7c97c74d54e5b1beb874e43)

![Identifying swap functions on Arbiscan](https://i.imgur.com/Ai5qFRn.png)

There are a few different swap methods on this contract which are relevant to our plugin. For each transaction we want to record the following:

- hash
- tokenIn (address)
- amountIn
- tokenOut (address)

> The `feeOnTransfer` methods are nearly identical to the non-feeOnTransfer methods, so we can safely skip them. Just make sure to include these methods in the ABI which we will visit in Part 3 of this tutorial. 

**Transactions:**
- `swapExactETHForTokens`
  - **hash**: 0x553c5ab29791da1e5b8beb9aaebd517849c06f23356b3188f809062a65472157
  - **tokenIn**: ETH (0x0000000000000000000000000000000000000000)
  - **amountIn**: 0.0009
  - **tokenOut**: USDC (0x833589fcd6edb6e08f4c7c32d4f71b54bda02913)

- `swapExactTokensForETH`
  - **hash**: 0x9af9f272fbf6ecde7e58f3f16f851b51c60cef84e24bde7b77e0d4640835463a
  - **tokenIn**: USDC (0x833589fcd6edb6e08f4c7c32d4f71b54bda02913)
  - **amountIn**: 4
  - **tokenOut**: ETH (0x0000000000000000000000000000000000000000)

- `swapExactTokensForTokens`
  - **hash**: 0xd3a8f0d830d3f43db6ab70ee36c20b34dca587358cabc7d8c083f3d630b8b5f8
  - **tokenIn**: USDC (0x833589fcd6edb6e08f4c7c32d4f71b54bda02913)
  - **amountIn**: 5
  - **tokenOut**: DAI (0x50c5725949a6f0c72e6c4a641f24049a917db0cb)

A few more things we need to collect are the projects URL and a URL for an image link. This info is needed so the project can properly be displayed on Boost Frontends.

- The projects URL: `https://aerodrome.finance/`
- An image link for the project: `https://assets.coingecko.com/coins/images/31745/large/token.png`

With all the necessary information collected, we're ready to move on to the development phase of our Boost SDK plugin.

> **You can grab the transaction details from this gist file later in the tutorial:** https://gist.github.com/mmackz/4c84b9ce2baa3c84a48d02f132de57f6

----------

## Part 1: Plugin Creation and Setup

Now that we've gathered all the necessary information, it is time to start building the plugin. To quickly get the project setup, we can use the CLI Plugin Creator tool. But first we need to setup our dev environment.

**Setup Your Development Environment**: First, clone the `mmackz/questdk-plugins` repo, and checkout to the `plugin-tutorial` branch.

```bash
git clone https://github.com/mmackz/questdk-plugins.git
cd questdk-plugins
git checkout plugin-tutorial
npm install -g pnpm
pnpm nuke
pnpm install
pnpm build
```
> You may need to run the pnpm setup before you can run commands

> **Make sure to run the `nuke` command to get rid of any unnecessary files and folders.**

**Create Your Plugin**: Navigate to the root of the `questdk-plugins` folder and run `pnpm run create`. This command starts the CLI Plugin Creator tool, which will guide you through setting up your plugin.

![Setting up your environment](https://i.imgur.com/Iqbuut3.png)

When prompted by the CLI, enter the following details:
   - Name: `Aerodrome`
   - Chains: `BASE`
   - ActionType: `swap`
   - Make the package public?: `Yes`

![Plugin creation process](https://i.imgur.com/40b9rif.png)

> **You can enter the details from [the gist file](https://gist.github.com/mmackz/4c84b9ce2baa3c84a48d02f132de57f6) with the transaction info we saved earlier.**

**Input Transaction Hashes**: Next, you'll be asked for transaction hashes. Use the `swapExactETHForTokens` hash we noted earlier (`0x553c5ab29791da1e5b8beb9aaebd517849c06f23356b3188f809062a65472157`) and enter details for this transaction we noted down before. Do the same for the other 2 transactions. Once you have input the details for all 3 transactions you can confirm 'no' when asked if you have another transaction.

The final step here is to enter the URLs we collected earlier:
   - Project URL: `https://aerodrome.finance/`
   - Project Icon URL: `https://assets.coingecko.com/coins/images/31745/large/token.png`
   - Action-specific URL: `https://aerodrome.finance/swap`

After entering all details, your plugin will begin building. A successful build will display no errors in your terminal.

![Successful build output](https://i.imgur.com/40b9rif.png)

Finally, set up your project by running:
```bash
pnpm install
pnpm build
pnpm format
```

> After setting up the project, you can jump to different checkpoints of this tutorial by switching to different branches prefixed with `plugin-tutorial-aerodrome`.

----------

## Part 2: Setting up test cases

> **To start from this section:** `git checkout plugin-tutorial-aerodrome-2`

Upon successfully building your plugin, you'll find your newly built project within the plugins folder. 

Take a look in `packages/plugins/aerodrome/` to see the newly created project files. 

![new package](https://i.imgur.com/yvwZEdB.png)

Open the `test-transactions.ts` file in your editor and take a look at the tests that are built.

These tests were generated from the transaction details we entered into the CLI creator tool and should cover the basic swap transactions. However, we will also need to make our testing more robust in order to catch any edge cases and to make sure the plugin behaves as expected.

The `createTestCase` function allows us to set overrides, so lets set some additional tests to make sure the plugin works as expected when any of the incoming parameters are `undefined`. 

This will be expected if the boost creator wants to allow "any" token or amount to pass a certain boost.

```typescript
export const passingTestCases = [
  createTestCase(SWAP_TEST_0, 'when using swapExactETHForTokens'),
  createTestCase(SWAP_TEST_1, 'when using swapExactTokensForETH'),
  createTestCase(SWAP_TEST_2, 'when using swapExactTokensForTokens'),
  createTestCase(SWAP_TEST_0, 'when using tokenIn is "Any"', { tokenIn: undefined }),
  createTestCase(SWAP_TEST_1, 'when using tokenOut is "Any"', { tokenOut: undefined }),
  createTestCase(SWAP_TEST_2, 'when using amountIn is "Any"', { amountIn: undefined }),
]
```

We will also set some tests which are expected to fail, such as when the amount is not sufficient, or the expected token is not present.

```typescript
export const failingTestCases = [
  createTestCase(SWAP_TEST_0, 'when chainId is not correct', { chainId: 0 }),
  createTestCase(SWAP_TEST_1, 'when tokenIn is not correct', {
    tokenIn: '0x6982508145454ce325ddbe47a25d4ec3d2311933',
  }),
  createTestCase(SWAP_TEST_2, 'when tokenOut is not correct', {
    tokenOut: '0x6982508145454ce325ddbe47a25d4ec3d2311933',
  }),
  createTestCase(SWAP_TEST_2, 'when amountIn is insufficient', {
    amountIn: GreaterThanOrEqual(parseEther('1000000000')),
  }),
]
```

This should be a sufficient amount of test cases to prove our plugin is performing as expected. If we can get all these tests to pass we will know our plugin is ready to publish.

First let's run the test command and see where we are at.

Use the following command to run the test suite:

`pnpm test --filter=@rabbitholegg/questdk-plugin-aerodrome`

![Failing Tests](https://i.imgur.com/4b53cZA.png?)

The only tests that should be passing at this point are the tests in the `failingTestCases` array.

Once you are at this point, we can start implementing the transaction filter and start passing these tests!

------------

## Part 3: ABI and Contracts

> **To start from this section:** `git checkout plugin-tutorial-aerodrome-3`

Next we will want to grab the ABI fragments for each of the relevant functions on the aerodrome router contract. Head back over to the contact on arbiscan.

https://basescan.org/address/0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43#code

On the "Code" tab, scroll down until you see "ContractABI"

![Get the ABI](https://i.imgur.com/cJYbTPS.png)

Create a file called `constants.ts` in the `src` folder of your project. We will export the contract address and ABI fragments from this file.

Create a constant called `AERODROME_ROUTER` for the contract address of the aerodrome router. Create another constant called `ROUTER_ABI` and paste in the ABI.

```typescript
// constants.ts
export const AERODROME_ROUTER = '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43'

const ROUTER_ABI = [
  {
    inputs: [
      { internalType: 'address', name: '_forwarder', type: 'address' },
      { internalType: 'address', name: '_factoryRegistry', type: 'address' },
      { internalType: 'address', name: '_factory', type: 'address' },
      { internalType: 'address', name: '_voter', type: 'address' },
      { internalType: 'address', name: '_weth', type: 'address' },
    ],
    stateMutability: 'nonpayable',
    type: 'constructor',
  },
  { inputs: [], name: 'ETHTransferFailed', type: 'error' },
  { inputs: [], name: 'Expired', type: 'error' },
  ..., // the rest of the abi
]
```

> Our constants file should now look like this: https://gist.github.com/mmackz/8c2bddfcbb69728945c34c1557115ed6

Normally it would be fine to just use one big ABI, but in this case many of the function parameters overlap, which may cause immature branching in our plugin and cause it to otherwise not behave as expected.

To counter this, we will separate the relevant ABI for each function into fragments.

```typescript
//constants.ts

export const AERODROME_ROUTER = '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43'

const ROUTER_ABI = [
    {
    inputs: [
      { internalType: 'address', name: '_forwarder', type: 'address' },
      { internalType: 'address', name: '_factoryRegistry', type: 'address' },
      { internalType: 'address', name: '_factory', type: 'address' },
      { internalType: 'address', name: '_voter', type: 'address' },
      { internalType: 'address', name: '_weth', type: 'address' },
    ],
    stateMutability: 'nonpayable',
    type: 'constructor',
  },
  { inputs: [], name: 'ETHTransferFailed', type: 'error' },
  { inputs: [], name: 'Expired', type: 'error' },
  ..., // the rest of the abi
]

// separate ABI into fragments
export const ETH_FOR_TOKENS_FRAGMENTS = ROUTER_ABI.filter(({ name }) =>
  [
    'swapExactETHForTokens',
    'swapExactETHForTokensSupportingFeeOnTransferTokens',
  ].includes(name as string),
)
export const TOKENS_FOR_ETH_FRAGMENTS = ROUTER_ABI.filter(({ name }) =>
  [
    'swapExactTokensForETH',
    'swapExactTokensForETHSupportingFeeOnTransferTokens',
  ].includes(name as string),
)
export const TOKENS_FOR_TOKENS_FRAGMENTS = ROUTER_ABI.filter(({ name }) =>
  [
    'swapExactTokensForTokens',
    'swapExactTokensForTokensSupportingFeeOnTransferTokens',
  ].includes(name as string),
)
```

----------

## Part 4: Transaction filter

> **To start from this section:** `git checkout plugin-tutorial-aerodrome-4`

Next we can go into the `Aerodrome.ts` file and start implementing the transaction filter. The idea is to map the parameters from the standard SwapActionParams to the inputs of a real swap transaction.

```typescript
type SwapActionParams = {
  chainId: number
  contractAddress?: Address
  tokenIn?: Address
  tokenOut?: Address
  amountIn?: bigint | FilterOperator
  amountOut?: bigint | FilterOperator
  recipient?: Address
}
```

Inside of `Aerodrome.ts`, import the ABI fragments and the contract address from the constants file we made in the previous step.

Copy the following imports and paste them at the top of `Aerodrome.ts`

```typescript
import { 
  AERODROME_ROUTER,
  ETH_FOR_TOKENS_FRAGMENTS, 
  TOKENS_FOR_ETH_FRAGMENTS, 
  TOKENS_FOR_TOKENS_FRAGMENTS, 
} from './constants'
```

We can start by mapping the chainId from the `_params` with the chainId in the outgoing `TransactionFilter` along with `to` and `AERODROME_ROUTER`. 

```typescript
export const swap = async (
  _params: SwapActionParams,
): Promise<TransactionFilter> => {

  const { chainId, tokenIn, tokenOut, amountIn, amountOut, recipient } = _params

  return compressJson({
    chainId,
    to: AERODROME_ROUTER,
    input: {
      $or: [
        // using the $or operator will allow the filter to match any 
        // of the ABI fragments that are provided
      ]
    }, 
  })
}
```

We need to match the inputs of the transaction with the `SwapActionParams`. These will be the parameters specified in the ABI function that you see on Arbiscan.

![match tx inputs](https://i.imgur.com/xDJlkfW.png)

Let's start with `swapExactEthForTokens`. It has the following inputs

```typescript
{
  "amountOutMin": "3693913",
  "routes": [
    {
      "from": "0x4200000000000000000000000000000000000006",
      "to": "0x54bc229d1cb15f8b6415efeab4290a40bc8b7d84",
      "stable": false,
      "factory": "0x420dd381b31aef6683db6b902084cb0ffece40da"
    },
    {
      "from": "0x54bc229d1cb15f8b6415efeab4290a40bc8b7d84",
      "to": "0x940181a94a35a4569e4529a3cdfb74e38fd98631",
      "stable": false,
      "factory": "0x420dd381b31aef6683db6b902084cb0ffece40da"
    },
    {
      "from": "0x940181a94a35a4569e4529a3cdfb74e38fd98631",
      "to": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
      "stable": false,
      "factory": "0x420dd381b31aef6683db6b902084cb0ffece40da"
    }
  ],
  "to": "0x1e7fc21f03a9859b9f4d841b735e5b3508715f97",
  "deadline": "1710118519"
}
```

We will need to map the following parameters:
- `amountOutMin` -> `amountOut`
- `to` -> `recipient`
- `routes` -> `tokenIn` and `tokenOut`
- `amountIn` -> ??? _we will figure this out later_

Lets map out `amountOutMin` and `to` first.

```typescript
export const swap = async (
  _params: SwapActionParams,
): Promise<TransactionFilter> => {

  const { chainId, tokenIn, tokenOut, amountIn, amountOut, recipient } = _params

  return compressJson({
    chainId,
    to: AERODROME_ROUTER,
    input: {
      $or: [
        {
          // swapExactETHForTokens
          $abi: ETH_FOR_TOKENS_FRAGMENTS,
          amountOutMin: amountOut,
          to: recipient,
        }
      ]
    },  
  })
}
```

The `routes` parameter will be a bit more complex because the input parameters we are targeted are inside of an array of values. 
 
For the `swapExactEthForTokens` function we can expect `0x4200000000000000000000000000000000000006` (WETH) to always be the `to` parameter of the first route. 

Heres what the transaction looks like again, notice the `from` param in the first object in the array.

```typescript
{
  "amountOutMin": "3693913",
  "routes": [
    {
      "from": "0x4200000000000000000000000000000000000006",
      "to": "0x54bc229d1cb15f8b6415efeab4290a40bc8b7d84",
      "stable": false,
      "factory": "0x420dd381b31aef6683db6b902084cb0ffece40da"
    },
    {
      "from": "0x54bc229d1cb15f8b6415efeab4290a40bc8b7d84",
      "to": "0x940181a94a35a4569e4529a3cdfb74e38fd98631",
      "stable": false,
      "factory": "0x420dd381b31aef6683db6b902084cb0ffece40da"
    },
    {
      "from": "0x940181a94a35a4569e4529a3cdfb74e38fd98631",
      "to": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
      "stable": false,
      "factory": "0x420dd381b31aef6683db6b902084cb0ffece40da"
    }
  ],
  "to": "0x1e7fc21f03a9859b9f4d841b735e5b3508715f97",
  "deadline": "1710118519"
}
```

For token out, we need the `to` parameter in the final route in the routes array.

To target specific values inside of the `routes` array we can use the array operators `$first` and `$last`. 

Lets put it all together

```typescript
export const swap = async (
  _params: SwapActionParams,
): Promise<TransactionFilter> => {

  const { chainId, tokenIn, tokenOut, amountIn, amountOut, recipient } = _params

  return compressJson({
    chainId,
    to: AERODROME_ROUTER,
    input: {
      $or: [
        {
          // swapExactETHForTokens
          $abi: ETH_FOR_TOKENS_FRAGMENTS,
          amountOutMin: amountOut,
          routes: {
            $and: [
              {$first: { from: '0x4200000000000000000000000000000000000006' }},
              {$last: { to: tokenOut }},
            ]
          },
          to: recipient,
        }
      ]
    }, 
  })
}
```

The last thing we are missing for this function is the `amountIn` parameter. This is because `amountIn` when using raw ETH is represented by a `value` property which is a standard on every transaction (`tx.value`)

We will want to compare `value` to `amountIn` ONLY if `tokenIn` is ETH which is represented by the `zeroAddress`. `0x0000000000000000000000000000000000000000`

Add this line to the TransactionFilter

`value: tokenIn === zeroAddress ? amountIn : undefined,`

Our function should now look like this.

```typescript
import {
  type TransactionFilter,
  type SwapActionParams,
  compressJson,
} from '@rabbitholegg/questdk'
import { type Address, zeroAddress } from 'viem'
import { Chains } from '@rabbitholegg/questdk-plugin-utils'
import { 
  AERODROME_ROUTER,
  ETH_FOR_TOKENS_FRAGMENTS, 
  TOKENS_FOR_ETH_FRAGMENTS, 
  TOKENS_FOR_TOKENS_FRAGMENTS, 
} from './constants'

export const swap = async (
  _params: SwapActionParams,
): Promise<TransactionFilter> => {

  const { chainId, tokenIn, tokenOut, amountIn, amountOut, recipient } = _params

  return compressJson({
    chainId,
    value: tokenIn === zeroAddress ? amountIn : undefined,
    to: AERODROME_ROUTER,
    input: {
      $or: [
        {
          // swapExactETHForTokens
          $abi: ETH_FOR_TOKENS_FRAGMENTS,
          amountOutMin: amountOut,
          routes: {
            $and: [
              {$first: { from: '0x4200000000000000000000000000000000000006' }},
              {$last: { to: tokenOut }},
            ]
          },
          to: recipient,
        }
      ]
    }, 
  })
}
```

And that should be it for our first function! If we run our tests now, we should expect the first function to pass.

`pnpm test --filter=@rabbitholegg/questdk-plugin-aerodrome`

![tests](https://i.imgur.com/vNrvSFt.png)

If it does we can start working on the next function `swapExactTokensForETH`

-------------

## Part 5: More Transactions

> **To start from this section:** `git checkout plugin-tutorial-aerodrome-5`

Let's add the other two functions. They are very similar with some minor nuances.

```typescript
export const swap = async (
  _params: SwapActionParams,
): Promise<TransactionFilter> => {

  const { chainId, tokenIn, tokenOut, amountIn, amountOut, recipient } = _params

  return compressJson({
    chainId,
    value: tokenIn === zeroAddress ? amountIn : undefined,
    to: AERODROME_ROUTER,
    input: {
      $or: [
        {
          // swapExactETHForTokens
          $abi: ETH_FOR_TOKENS_FRAGMENTS,
          amountOutMin: amountOut,
          routes: {
            $and: [
              {$first: { from: '0x4200000000000000000000000000000000000006' }},
              {$last: { to: tokenOut }},
            ]
          },
          to: recipient,
        },
        {
          // swapExactTokensForETH
          $abi: TOKENS_FOR_ETH_FRAGMENTS,
          amountIn: amountIn,
          amountOutMin: amountOut,
          routes: {
            $and: [
              {$first: { from: tokenIn }},
              {$last: { to: '0x4200000000000000000000000000000000000006' }},
            ]
          },
          to: recipient,
        },
        {
          // swapExactTokensForTokens
          $abi: TOKENS_FOR_TOKENS_FRAGMENTS,
          amountIn: amountIn,
          amountOutMin: amountOut,
          routes: {
            $and: [
              {$first: { from: tokenIn }},
              {$last: { to: tokenOut }},
            ]
          },
          to: recipient,
        },
      ]
    }, 
  })
}
```

Awesome, now we can run our tests again and if we did everything correct, we should see them all passing.

`pnpm test --filter=@rabbitholegg/questdk-plugin-aerodrome`

![tests passing](https://i.imgur.com/3UJEqIv.png)

Great!, now we can move onto the final steps and get our plugin ready to submit for review.

---------

## Part 6: Finishing up

> **To start from this section:** `git checkout plugin-tutorial-aerodrome-6`

**Implement the `getSupportedTokenAddresses` function**

This function shows which tokens are available on Boost Protocol frontend and will need to be implemented in the `Aerodrome.ts` file.

You can either use our built-in default token list, or you can make a mapping of your choosing. For this tutorial I will show you how to import and use the default tokenlist.

You can import `CHAINS_TO_TOKENS` from the `questdk-plugin-utils` package which will give you access to a supported token list on each supported chain.

```typescript
import { Chains, CHAIN_TO_TOKENS } from '@rabbitholegg/questdk-plugin-utils'

// ... your swap function

export const getSupportedTokenAddresses = async (
  _chainId: number,
): Promise<Address[]> => {
  // Given a specific chain we would expect this function to return a list of supported token addresses
  return CHAIN_TO_TOKENS[_chainId] ?? []
}
```

**Format your Code**

Run the format command to format your code

`pnpm format`

**Generate changeset**

`pnpm changeset`

Follow the instructions in the cli.

![create a changeset](https://i.imgur.com/IZ8znLr.png?)

and that is it!! Congrats, you have made it to the end. 🎉

> To see the code for the finished plugin: `git checkout plugin-tutorial-aerodrome-7`

You can check out aerodrome on Boost Protocol by launching a boost on [Boost Terminal](https://manager.boost.xyz/boosts/create).

> **Please join the #💻-dev-chat channel in our [Discord](https://discord.gg/boost-manager) if you have any questions or need assistance in building your Boost SDK Plugin.**