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
anddescription
are also used in the Action dialog and screen to explain what the action doesinitialize
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 ofstate._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 thesetupTxs
to ensure the ATA you need always exists during themainTxs
step. The code for this helper function can be found here: https://github.com/rockooor/byob/blob/main/src/helpers/token.tsAnother 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