An Overview of the Solana SPL Token 2022 program (part 2) - The Transfer Hook
October 16 2023
In our previous post on Token2022 (find it here) we went over some of the simplest new features of the spl token 2022 program. This included extensions like transfer fees and interest rates, and how to implement those extensions on chain. In this post we will look at one of the most interesting, but more complex, new extensions, the transfer hook. This allows the developer to have a program on chain that is called into every time someone transfers a token from a particular mint. This could be used to manage fees for transferring NFTs, or could be used to instigate "events" in a game associated with transferring items where bandits might rob you and steal some of your items, or for updating metadata associated with a game item, such as evolving a pokemon like character on trade (We hope to have a final post in this series where we implement some of these examples).
There are three things that need to be considered when implementing the transfer hook:
Setting up the extension in the mint
Transferring tokens that make use of the extension
Writing the actual transfer hook program following the required interface
All of these make use of some recently added functionality within the Solana ecosystem:
Discriminators - A set of bytes that uniquely identify an instruction or struct
POD (Plain Old Data) - Types which allow easy conversion from bytes to supported types
Type-Length-Value (TLV) data structures - Used to store the set of additional accounts the transfer hook needs.
ExtraAccountMeta - The structure that actually holds the extra accounts as a TLV type using Discriminators
Therefore, before getting into the implementation of the transfer hook itself, we will start by briefly describing these four features, and provide some context for how they are going to be used when setting up the transfer hook.
Discriminators
The discriminator type is implemented in the spl_discriminator crate which you can find here.The crate provides macros that allow the users to define a set of bytes to uniquely identify things like program instructions that will be called externally, or structs that can be deserialised using TLV data types. We will be using them in both these use cases in order to set up the transfer hook program.
A discriminator is a set of eight bytes that can be defined by the user in any way they see fit. The crate provides some useful macros that allow us to construct them directly from strings, so for example when defining the instructions that the transfer hook program requires, we can use the discriminator_hash_input macro to convert the provided strings into the discriminator for the trivial structs that will be used to denote the instructions:
Using this macro provides the struct with the SPL_DISCRIMINATOR_SLICE property, which we can use to distinguish between instructions. For example, when our transfer hook program is used, we must unpack the input that has been sent to the program to check what instruction has been called, and can use this property to do this:
Likewise by using these discriminators, other programs can call our program and know how to specify which instruction they want to call. Similarly when defining a TLV struct, one can add a discriminator to identify its type as in the example here, where the discriminator is just set to the integer value 1.
In the above example you can see that we are making use of the 'Pod' attribute for struct MyPodValue. The POD data type is described in detail in the bytemuck crate here. Going into the weeds too much on this point is a bit out of scope for this post, but it is usefull to at least mention it's existence as you will see the code for the transfer hook makes use of things like PodBool, or PodAccountMeta types (i.e. take a type that you are familiar with and add Pod in front to make it new and mysterious). Essentially, by stating that a type has the Pod trait, you are stating that it satisfies a series of requirements, for example that the structure has no padding bytes, or that you can make a copy simply by copying the bytes. The default Bool type in Rust, and the AccountMeta type defined in the Solana crate do not satisfy these requirements, and so special versions have been implemented that do.
Type-Length-Value (TLV)
TLV structures are defined in the this crate, and are used in a wide range of applications. They provide a way of specifying the makeup of a slab of bytes, by specifying the type (8 bytes, using a discriminator), the length of the data (4 bytes), and then following that with the data itself as a slab of bytes of the given length. This way you can easily fill up an array of different data structures, each in the TLV format, and iterate through the structures to deserialise the data. The type then provides functionality to get the value (i.e to automatically deserialise the bytes to the specified format), or to get the discriminators to check the type. We will give an example of this below for the ExtraAccountMeta structure.
The ExtraAccountMeta struct
The ExtraAccountMeta struct is defined in the spl_tlv_account_resolution crate, and is used to hold all the extra account meta data for accounts involved in the transfer hook execution. In the simplest case this may not contain any accounts, or it might include accounts that contain meta data that should be updated as a result of the transfer. In our example below we will have a single account to decode which will contain the number of transfers that have occured for that mint. Each time a transfer occurs, our transfer hook program will increment that counter, and so everyone who tries to transfer one of the tokens for that mint will need to know what that account is, so that it can be included in the transfer instruction.
The combination of all the features discussed so far are used when unpacking this structure in the execute function of the transfer hook program, which we will discuss in more detail in the final part of this post:
// get the data from the account that contains the extra account metas
let data = extra_account_metas_info.data.borrow();
// unpack this as a TLV state object
let state =TlvStateBorrowed::unpack(&data[..]).unwrap();
// get the ExtraAccountMetas object from the TLV state
let extra_meta_list =ExtraAccountMetaList::unpack_with_tlv_state::<ExecuteInstruction>(&state)?;
let extra_account_metas = extra_meta_list.data();
Setting Up The Mint
Setting up the transfer hook extension in the token mint starts in the same fashion as setting up any of the other extensions. You can find the full code for this section in the git repo for the previous blog post here here. We first simply have to invoke the initialize method for the transfer hook extension:
Here transfer_hook_program_account is the address of the deployed transfer hook program that will be called whenever a transfer occurs, which we will describe in the final section below. The extra step that is required to set up the transfer hook extension is to then call the function that will set up the meta data that describes the additional accounts that should be passed in order to use the transfer hook. We follow the example interface and call this InitializeExtraAccountMetas.
// pack is defined in instruction.rs and just sets the discriminator for this function
let instruction_data =TransferHookInstruction::InitializeExtraAccountMetas.pack();
let init_accounts_idx =solana_program::instruction::Instruction{
program_id:*transfer_hook_program_account.key,
accounts: account_metas,
data: instruction_data,
};
invoke(
&init_accounts_idx,
&account_infos,
)?;
This follows a pretty standard setup for a CPI call. We have an extra optional account, mint_data_account_info, which we will use in this post to count the number of transfers for each mint created by the program. This is a unique account per mint, and uses the mint address and the string "mint_data" as the seeds (this will be shown explicitly in the transfer hook program section).
Transferring Tokens
Transferring a token that has the transfer hook extension isn't quite as trivial as for the other extensions. The Solana blockchain requires that all accounts involved in a transaction are included as part of that transaction, but the default transfer_checked function doesn't know anything about our transfer hook program, or the data account that we will be modifying when that program is called. We will therefore to modify the instruction before we can invoke it.
We start by creating an instance of the transfer_checked instruction as normal, except that we have made it mutable. This means we can modify the set of accounts the instruction knows about afterwards:
// in processor.rs
...
// create a transfer_checked instruction
// this needs to be mutable because we will manually add extra accounts
Once these have been pushed onto the accounts vector for the instruction we can just call invoke and the transfer will happen. Note that the spl_tlv_account_resolution crate does include some helper functions to do this more automatically (e.g. add_to_cpi_instruction) however in this case we found it simpler, and more instructive to just do it ourselves.
The Transfer Hook Program
In this final section we will implement the transfer hook program itself, the code for this section can be found in our git repo here. In our case we will be building two functions, both of which we have mentioned previously:
Execute - the function called whenever a transfer occurs
InitializeExtraAccountMetas - the function that will set up our ExtraAccountMeta data structure on chain
These two instructions are defined in instuctions.rs:
pubenumTransferHookInstruction{
Execute{
amount:u64,
},
InitializeExtraAccountMetaList,
}
InitializeExtraAccountMetas
We will start by going through InitializeExtraAccountMetas, the body of which is defined in process_initialize_extra_account_metas:
// in processor.rs
pubfnprocess_initialize_extra_account_metas<'a>(
program_id:&Pubkey,
accounts:&'a[AccountInfo<'a>],
)->ProgramResult{
// get and check the accounts
...
In our example the data account that will store the counter is optional, if we don't include it then the transfer hook program will still work but won't do anything. We therefore need to check if the account as been passed as an option. When creating the account that will hold the extra account meta data we will also need to know the total number of extra accounts to get the correct size, so we instantiate the mutable variable n_extra_accounts which we will increment if we have included the mint data account. If we have included the mint data account then we just get its size from a helper function defined in state.rs and then create the account as a PDA owned by the transfer hook program. Note that we use the mint address as part of the seed so that every mint that uses this program can have its own counter.
// we need to create the mint_data account so that it can hold a u64 to count the number
// of transfers
// get_mint_data_size is defined in state.rs
let data_size =state::get_mint_data_size();
// create the account, this is defined in utils.rs and just makes the PDA
Self::create_program_account(
authority_info,
mint_data_account_info,
program_id,
bump_seed,
data_size,
vec![b"mint_data",&mint_info.key.to_bytes()]
).unwrap();
Once the account has been created we then define the ExtraAccountMeta structure that will store information about that account on chain. The ExtraAccountMeta structure has several different methods for instantiation depending on the type of account that is being saved. In this case we have made a PDA that is owned by our transfer hook program, and so we use the new_with_seeds function, which makes use of the Seed enum to save how to construct the address of the account. In our case we have aLiteral type seed that comes from the text "mint_data", and a seed that comes from an AccountKey (the mint account) which is defined using the index of that account as it was passed to this function (in our case that was account 1). Using those seeds we can instantiate the ExtraAccountMeta data and increment n_extra_accounts:
...
// the ExtraAccountMeta needs to know this is a PDA and saves the seeds so that it can
// check the account. This is done using the Seed structure.
let seed1 =Seed::Literal{ bytes:b"mint_data".to_vec()};
let seed2 =Seed::AccountKey{ index:1};
// create the ExtraAccountMeta for this account from the seeds
let mint_account_meta =ExtraAccountMeta::new_with_seeds(&[seed1, seed2],false,true).unwrap();
extra_account_infos.push(mint_account_meta);
// increment the account counter
n_extra_accounts =1;
}
That is most of the hard work done. Now we know how many extra accounts we have, we can determine the size of the account that will hold that data using the size_of function provided by ExtraAccountMetaList structure, and then create the account as normal:
// given the number of extra accounts, get the size of the ExtraAccountMetaList
let account_size =ExtraAccountMetaList::size_of(n_extra_accounts)?;
let lamports =rent::Rent::default().minimum_balance(account_size);
// create the account
let ix =solana_program::system_instruction::create_account(
The last step of this function is then to actually initialize the data in this account using the ExtraAccountMeta data calculated previously (or using an empty array if we never passed the mint data account):
// finally instantiate the data in the account from our extra_account_infos vec
letmut data = extra_account_metas_info.try_borrow_mut_data()?;
The final function to implement then is the Execute function, which is the thing that will actually be called every time a transfer occurs. In our case because this either does nothing (if no mint_data was passed when the mint was being created), or simply incrments a counter in a data account. As such it is really quite simple. The body of the function is defined in the process_execute function. Note that this gets passed the amount that has been transferred, however we don't use it at all in this example.
// in processor.rs
pubfnprocess_execute<'a>(
program_id:&Pubkey,
accounts:&'a[AccountInfo<'a>],
_amount:u64,
)->ProgramResult{
let account_info_iter =&mut accounts.iter();
// get and check accounts
To determine whether we have a mint_data account we check the account that we created in the previous step that contains the ExtraAccountMetaList. This involves creating a type-length-value object from the data, and then unpacking the bytes as the ExtraAccountMetaList object. In our case we only care if there are any accounts so we just check the length. If it is greater than zero we know that there must have been a mint data account passed, and so we enter that code block.
All we then have to do is deserialise the data in that account, increment the value by one, and then serialize it again and we are done!
...
let data = extra_account_metas_info.data.borrow();
let state =TlvStateBorrowed::unpack(&data[..]).unwrap();
let extra_meta_list =ExtraAccountMetaList::unpack_with_tlv_state::<ExecuteInstruction>(&state)?;
let extra_account_metas = extra_meta_list.data();
if extra_account_metas.len()>0{
let meta = extra_account_metas[0];
let mint_data_account_info =next_account_info(account_info_iter)?;
The application below lets you select any combination of the extensions we have discussed in this and the previous post and to mint some tokens using the Token-2022 program. In particular the transfer hook extension will use our on chain program to count the number of transfers that have been made using that mint and display that to the user.
Hopefully you have learnt something new about using the transfer hook with the Token-2022 program in this post. We will aim to follow this up at some point with some more interesting examples, such as evolving pokemon type characters in a game. If you don't to miss that then go ahead and follow us on Twitter to keep up to date with future posts!