Building a Boost SDK Plugin: A Step-by-Step Guide
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.
During this tutorial we will build a swap action plugin for Aerodrome, 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
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.
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.
When prompted by the CLI, enter the following details:
- Name:
Aerodrome
- Chains:
BASE
- ActionType:
swap
- Make the package public?:
Yes
You can enter the details from the gist file 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.
Finally, set up your project by running:
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.
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.
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.
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
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"
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.
// 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.
//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.
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
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
.
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.
Let's start with swapExactEthForTokens
. It has the following inputs
{
"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
andtokenOut
-
amountIn
-> ??? we will figure this out later
Lets map out amountOutMin
and to
first.
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.
{
"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
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.
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
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.
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
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.
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.
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.
Please join the #💻-dev-chat channel in our Discord if you have any questions or need assistance in building your Boost SDK Plugin.
Top comments (0)