Building actions

How to create new actions?

BYOB is an open source project and becomes more powerful with more protocols integrated. However, I can't add all SDKs of all protocols because of time constraints (I can't learn and integrate every SDK of every protocol of Solana 😄 ). So in order to make BYOB a powerful product, SDK builders can very easily integrate with BYOB!

Here is the action folders that contain all the code from below: https://github.com/rockooor/byob/tree/main/src/actions

Every action follows the same code template, which has the following typescript interface:

export interface BaseState {
    outputs?: {
        name: string;
        value: string | number;
    }[];
}

export enum ActionType {
    MINT,
    NUMBER,
    STRING,
    DROPDOWN,
    CHECK
}

type ActionInput = {
    set: (x: any) => void;
    defaultValue: () => Token | number | string | boolean | undefined;
    name: string;
    type: ActionType;
    values?: { name: string; value: string }[] | string; // string means it's a state thing
    boundTo: string; // For linking number inputs
};

type CreateAtaInstruction = {
    ata: string;
    ix: TransactionInstruction
}

type CreateTxOutput = {
    setupTxs?: (CreateAtaInstruction | TransactionInstruction | ((x?: any) => TransactionInstruction) | undefined)[];
    mainTxs?: (TransactionInstruction | ((x?: any) => TransactionInstruction) | undefined)[];
    cleanupTxs?: (TransactionInstruction | ((x?: any) => TransactionInstruction) | undefined)[];
    lutAccounts?: AddressLookupTableAccount[];
    extraSigners?: Signer[];
};

type Protocol = {
    name: string;
    site: string;
};

export interface Action {
    protocol: Protocol;
    name: string;
    description: string;
    initialize: (
        c: Connection,
        w: AnchorWallet
    ) => Promise<{
        inputs: ActionInput[];
        createTx: () => Promise<CreateTxOutput>;
        state: typeof this.state;
    }>;
}

This looks a bit complex maybe, but a code example makes it a lot clearer.

First of all, an action has a few base properties:

  • protocol is a name and site which can be displayed in the Action dialog and serve as a description of the protocol. The name is also used for grouping in the Action dialog.

  • name and description are also used in the Action dialog and screen to explain what the action does

  • initialize is an async function that return what the Action should do when added to the transaction.

The inputs, createTx and state are where the magic happens and where the actual functionality is defined.

The state field is the state of the action where information is stored that is necessary to be accessed later (when the transaction is executed or when something is selected for example). If the state contains an outputs array of objects with a name and value , those values will be displayed to the user.

The inputs property is an array of definitions that will create input fields for the user to define, such as amount of tokens, addresses, etc. An example of an action input is the following, which takes in a LUT address and puts it in the state when the field is updated (given by the set function).

{
    set: (tokenAccount: string) =>
        state.setState({
            tokenAccount,
        }),
    defaultValue: () => state.getState().tokenAccount,
    name: 'LUT address',
    type: ActionType.STRING
}

There are 4 components to an ActionInput: the name, which is a user friendly name above the input field, the type, which can be a string, number, dropdown, checkbox etc to render the right form element and the set function, which is called whenever the input field is updated. This function can be async so you can do RPC calls here for example. Examples will be given later below. Finally the defaultValue function, which is the value this input should take when loaded from a shared workflow. In 99% of the cases it is just what the set function sets, like in the example above.

The createTx property is an async function that will be called when the transaction is executed. It should return Transactions. It can return the following things (all are optional):

type CreateTxOutput = {
    setupTxs? ==> TransactionInstructions called first
    mainTxs?: ==> TransactionInstructions called after the setupTxs. Most go here
    cleanupTxs?: ==> TransactionInstructions called after mainTxs
    lutAccounts?: ==> If you have LUT accounts available, you can put them here
    extraSigners?: ==> Additional signers that could be necessary
};

One of the simplest Actions in BYOB is to give a prioritization fee to the transaction, which is basically adding

ComputeBudgetProgram.setComputeUnitPrice({
    microLamports,
});

to the transaction, where the user can define microLamports (try it yourself)!

The full Action file is this:

import create from 'zustand/vanilla';
import { ComputeBudgetProgram, Connection, PublicKey } from '@solana/web3.js';
import { AnchorWallet } from '@solana/wallet-adapter-react';
import { Action, ActionType, BaseState } from '../types';
import { createCloseAccountInstruction } from '@solana/spl-token';

interface State extends BaseState {
    microLamports: number;
}

const tx = async (anchorWallet: AnchorWallet, microLamports: number) => {
    return ComputeBudgetProgram.setComputeUnitPrice({
        microLamports,
    });
};

const props = {
    protocol: {
        name: 'System / Misc',
        site: 'https://www.solana.com'
    },
    name: 'Prioritize transaction',
    description: 'Add prioritization fee'
};
export const prioritizeTx = (): Action => {
    return {
        ...props,
        initialize: async (connection: Connection, anchorWallet: AnchorWallet) => {
            const state = create<State>(() => ({
                microLamports: 0,
            }));

            return {
                inputs: [
                    {
                        set: (microLamports: number) =>
                            state.setState({
                                microLamports,
                            }),
                        defaultValue: () => state.getState().microLamports,
                        name: 'Microlamports',
                        type: ActionType.NUMBER
                    }
                ],
                createTx: async () => ({
                    setupTxs: [await tx(anchorWallet, state.getState().microLamports)]
                }),
                state,
            };
        }
    };
};

As you see it contains a state field that has only one property, the microLamports which is an input field of type Number. When the transaction is executed, the instruction is added to the setupTxs.

And that's it!

A more complex example: minting Solend cTokens

This is the full code of the Action that mints Solend cTokens. It can mint any cToken from any lending pool.

import create, { StoreApi } from 'zustand/vanilla';
import { Connection, PublicKey } from '@solana/web3.js';
import { AnchorWallet } from '@solana/wallet-adapter-react';
import { Action, ActionType, BaseState } from '../types';
import { getATA, getToken, tokenMints } from '../../helpers/token';
import {
    SolendMarket,
    SolendAction,
    SolendReserve
} from '@solendprotocol/solend-sdk';
import { TOKENS } from './_tokens';
import { BN } from 'bn.js';

interface State extends BaseState {
    amountToDeposit: number;
    market: string;
    reserve: string;

    _possibleReservesForMarket: {
        name: string;
        value: string;
    }[];

    outputs: [{
        name: string;
        value: number;
    }]
}

type Cache = {
    _reserve?: SolendReserve;
    _market?: SolendMarket;
}

const cache: Cache = {}

const props = {
    protocol: {
        name: 'Solend',
        site: 'https://solend.fi/'
    },
    name: 'Mint cTokens',
    description: 'Mint cTokens from a particular pool. You probably want the main pool.'
};

const mintTx = async (connection: Connection, anchorWallet: AnchorWallet, state: State) => {
    const ata = await getATA(connection, new PublicKey(state.reserve), anchorWallet.publicKey);
    const market = cache._market || await SolendMarket.initialize(connection, 'production', state.market);
    const reserve = cache._reserve || market.reserves.find((__reserve) => __reserve.config.liquidityToken.mint === state.reserve);
    if (!reserve?.stats) {
        await reserve?.load()
    }

    const token = await getToken(state.reserve);
    if (!token || !reserve) {
        throw Error('Could not find token or reserve');
    }

    const reserveStats = reserve.stats;
    if (!reserveStats) {
        throw Error('Could not load reserve stats')
    }

    const cTokenAta = await getATA(connection, new PublicKey(reserve.config.collateralMintAddress), anchorWallet.publicKey);

    const txs = await SolendAction.buildDepositReserveLiquidityTxns(
        connection,
        new BN(state.amountToDeposit * 10 ** reserve.config.liquidityToken.decimals),
        reserve.config.liquidityToken.symbol!,
        anchorWallet.publicKey,
        'production',
        new PublicKey(state.market)
    )

    return {
        setupTxs: [ata.createTx, cTokenAta.createTx, ...txs.preTxnIxs],
        mainTxs: [...txs.lendingIxs],
        cleanupTxs: [...txs.postTxnIxs, ...txs.cleanupIxs],
    }
};

const updateOutput = async (connection: Connection, state: StoreApi<State>) => {
    // Update amount to deposit
    const market = cache._market || await SolendMarket.initialize(connection, 'production', state.getState().market);
    const reserve = cache._reserve || market.reserves.find((__reserve) => __reserve.config.liquidityToken.mint === state.getState().reserve);
    if (!reserve) return
    if (!reserve.stats) {
        await reserve.load()
    }
    const reserveStats = reserve.stats;
    if (!reserveStats) return
    state.setState((state) => ({
        outputs: [
            {
                name: state.outputs[0].name,
                value: state.amountToDeposit * 10 ** reserve.config.liquidityToken.decimals / (reserveStats.cTokenExchangeRate * (10 ** reserveStats.decimals))
            }
        ]
    }));
}

export const mintCTokens = (): Action => {
    return {
        ...props,
        initialize: async (connection: Connection, anchorWallet: AnchorWallet) => {
            const state = create<State>(() => ({
                amountToDeposit: 0,
                market: '',
                reserve: '',

                _possibleReservesForMarket: [],

                outputs: [{ name: 'Amount received', value: 0 }]
            }));

            state.subscribe((newState, prevState) => {
                if (newState.amountToDeposit !== prevState.amountToDeposit) {
                    updateOutput(connection, state)
                }
            })

            return {
                inputs: [
                    {
                        set: async (market: string) => {
                            state.setState({ market });

                            // Get markets and populate the reserve dropdown
                            const marketInfo = await SolendMarket.initialize(connection, 'production', market);

                            cache._market = marketInfo;
                            state.setState({
                                _possibleReservesForMarket: marketInfo.reserves
                                    // Filter wSOL because wrapping
                                    .filter((reserve) => reserve.config.liquidityToken.mint !== tokenMints.wSOL)
                                    .map((reserve) => ({
                                        name: reserve.config.liquidityToken.symbol,
                                        value: reserve.config.liquidityToken.mint
                                    }))
                            });
                        },
                        defaultValue: () => state.getState().market,
                        name: 'Lending market',
                        type: ActionType.DROPDOWN,
                        values: [
                            {
                                name: 'Main pool',
                                value: TOKENS.markets.main
                            },
                            {
                                name: 'Turbo SOL pool',
                                value: TOKENS.markets.turboSol
                            }
                        ]
                    },
                    {
                        set: async (reserve: string) => {
                            const market = cache._market || await SolendMarket.initialize(connection, 'production', state.getState().market);
                            if (!market) return

                            const _reserve = market.reserves.find((__reserve) => __reserve.config.liquidityToken.mint === reserve)
                            if (!_reserve) return
                            await _reserve.load();

                            cache._reserve = _reserve;
                            state.setState({
                                reserve,
                            });
                        },
                        defaultValue: () => state.getState().reserve,
                        name: 'Reserve',
                        type: ActionType.DROPDOWN,
                        values: '_possibleReservesForMarket' // read values from state
                    },
                    {
                        set: async (amountToDeposit: number) => {
                            state.setState({ amountToDeposit });

                            updateOutput(connection, state)
                        },
                        defaultValue: () => state.getState().amountToDeposit,
                        name: 'Amount to deposit',
                        type: ActionType.NUMBER
                    }
                ],
                createTx: () => mintTx(connection, anchorWallet, state.getState()),
                state,
            };
        }
    };
};


It's a bit more complex but the structure remains the same. The state contains information about which lending market and what type of token you're going to deposit. The action inputs are a bit more complex now as well:

  • The first input is a dropdown that specifies which lending market. These addresses are hardcoded in another file. When the lending market is selected the state is set, but we also immediately do an RPC call to fetch the market info of that market and set it to the state as well.

  • The second input is also a dropdown to select which type of collateral you're going to deposit. However, this is dependent on the lending market. The Turbo SOL pool only has SOL and USDC while the Main pool has many more. The dropdown values in this case is not an array but a string. If you give a string, it will interpret that as the state key. Therefore if, in this example, the _possibleReservesForMarket state field is updated, the second dropdown updates is values with the value of state._possibleReservesForMarket. And that state field is updated at the moment the lending market is selected!

  • The third input asks how much collateral to deposit. It saves the amount in the state, but compensates for the amount of decimals in the set function as well.

  • You might have noticed the getATA function. This is a helper function that returns the associated token account of a mint and authority and an optional instruction to create it if it doesn't exist. You can add this instruction in the setupTxs to ensure the ATA you need always exists during the mainTxs step. The code for this helper function can be found here: https://github.com/rockooor/byob/blob/main/src/helpers/token.ts

  • Another caveat here is that I filter out SOL as minting because I ran into wrapping issues. However this is easily fixable, but I want to focus on the product and hope that protocols will add their SDKs, improving also the quality of the actions.

When the user executes the transaction, the createTx function is called, which calls the mintTx function. This is where the Solend SDK specific code is implemented and it returns a set up setup, main and cleanup transactions.

As you hopefully see, the amount of boilerplate is fairly small and if you are the maintainer of a protocol's SDK, it's quite trivial to create an action file like this: just set up the input fields that set the state and define the createTx function. Using this setup, hopefully many protocols will open PRs to add their SDK into BYOB!

Last updated