Write a hook
A hook is an ordinary Solana program. The crate bordrless-hook is the whole interface: the callbacks, their arguments, the answer and the registry.
Callbacks#
Implement the ones you need, each as an instruction of your program named after it. The caller invokes it by CPI with Anchor’s discriminator for that name and Borsh arguments (TokenHookArgs, PoolHookArgs).
- Token hook
before_transferafter_transferbefore_mintafter_mintbefore_burnafter_burn- Pool hook
before_initializeafter_initializebefore_add_liquidityafter_add_liquiditybefore_remove_liquidityafter_remove_liquiditybefore_swapafter_swap
The first account is the caller’s signer, PDA(["hook-authority", your_program], caller). Accept only that one (hook_signer in the crate gives it): a signer another hook received and passes on vouches for nothing.
The answer#
A before_* callback, and a pool’s after_swap, returns a HookReturn (Ok(HookReturn { .. }) from an Anchor instruction). The caller checks it (read_answer) and applies it: at most three cuts, never more than the amount, each to a writable holding your hook named among its extras.
pub struct Delta {
pub amount: u64, // above zero
pub account: u8, // index in the callback's accounts; must be an extra
}
pub struct HookReturn {
pub deltas: Vec<Delta>, // up to MAX_DELTAS (3) cuts from the amount
pub burn: u64, // pool swap callbacks only
pub lp_fee_bps: Option<u16>, // before_swap only
pub source_hook_data: Option<[u8; HOOK_DATA_LEN]>, // token callbacks only (64 bytes)
pub destination_hook_data: Option<[u8; HOOK_DATA_LEN]>,
}
Flags#
A mint’s flags say which token callbacks run and what they may answer; a pool’s, the same for pool callbacks. A field a callback may not fill refuses the whole answer (UnsupportedHookReturn).
pub mod token_flags {
pub const BEFORE_TRANSFER: u16 = 1 << 0; pub const AFTER_TRANSFER: u16 = 1 << 1;
pub const BEFORE_MINT: u16 = 1 << 2; pub const AFTER_MINT: u16 = 1 << 3;
pub const BEFORE_BURN: u16 = 1 << 4; pub const AFTER_BURN: u16 = 1 << 5;
pub const TRANSFER_RETURNS_DELTA: u16 = 1 << 6; // before_transfer may answer cuts
pub const WRITES_HOOK_DATA: u16 = 1 << 7; // before_* may answer the 64 bytes
}
Extra accounts#
Five fixed accounts come first. Anything else your hook needs goes in a registry at PDA(["bordrless-hook-accounts", mint-or-pool], your_program): fixed keys, and PDAs whose seeds may be literals, accounts already in the list, or the owners of the two holdings. write_registry creates it; the SDK resolves it (fetchTokenHook, resolveHookAccounts).
pub enum Seed { Literal(Vec<u8>), Account(u8), SourceOwner, DestinationOwner }
pub enum AccountSource { Key(Pubkey), Pda { program: Pubkey, seeds: Vec<Seed> } }
pub struct ExtraAccount { pub writable: bool, pub source: AccountSource }
pub struct HookAccountList { pub version: u8, pub accounts: Vec<ExtraAccount> }
// tax_hook publishes two: its config (a PDA on the mint, account 1 of the prefix), then the collector.
let list = HookAccountList::new(vec![
ExtraAccount { writable: true, source: AccountSource::Pda { program: crate::ID, seeds: vec![Seed::Literal(b"tax".to_vec()), Seed::Account(1)] } },
ExtraAccount { writable: true, source: AccountSource::Key(collector_holding) },
]);
Naming and locking it#
A mint names its hook at creation (hook_program, hook_flags in create_mint). While it keeps a hook authority, set_hook changes either; revoking that authority with set_authority fixes which program runs, as every kit launch is made.
Deploying it#
Deploy it like any Solana program. To launch it from a config, make it immutable: the launch program refuses a config naming a hook anyone but Bordrless (Studio’s key or the protocol’s) can upgrade. Studio keeps the upgrade key of every hook it deploys.
solana program set-upgrade-authority <PROGRAM_ID> --final
Example: tax_hook#
programs/tax_hook is a token hook with one callback. install records the fee and the cap, publishes the registry and sets the mint’s hook to itself; prepare does the same for a mint that does not exist yet, so a launch can create it with the hook. before_transfer answers one cut to the collector and refuses a transfer that would leave a wallet over the cap. The collector is exempt, and the fee is 0 until its holding exists.
pub const FLAGS: u16 = token_flags::BEFORE_TRANSFER | token_flags::TRANSFER_RETURNS_DELTA;
pub const COLLECTOR_INDEX: u8 = TOKEN_PREFIX_ACCOUNTS as u8 + 1; // prefix of 5, the config, the collector
pub fn before_transfer(ctx: Context<BeforeTransfer>, args: TokenHookArgs) -> Result<HookReturn> {
let tax = &mut ctx.accounts.tax;
require_keys_eq!(args.mint, tax.mint, TaxError::WrongMint);
let exempt = args.source_owner == tax.collector_owner
|| args.destination_owner == tax.collector_owner;
let delta = if exempt {
0
} else {
fee_amount(args.amount, tax.fee_bps).ok_or(TaxError::Overflow)?.min(args.amount)
};
if tax.max_wallet_bps > 0 && tax.max_wallet_bps < 10_000
&& args.destination_owner != tax.collector_owner
{
let cap = u128::from(args.supply) * u128::from(tax.max_wallet_bps) / u128::from(BPS);
let after = u128::from(args.destination_balance) + u128::from(args.amount - delta);
require!(after <= cap, TaxError::WalletTooLarge);
}
// A delta must be above zero, so a free transfer answers none.
let deltas = if delta > 0 {
vec![Delta { amount: delta, account: COLLECTOR_INDEX }]
} else {
vec![]
};
Ok(HookReturn { deltas, ..HookReturn::default() })
}
Example: the kit#
programs/bordrless_kit is the full example: a token hook whose 64 bytes do the holder accounting, installed by the launchpad, itself a pool hook (programs/bordrless_launch). The SDK’s kitTokenHook, kitHookSlice and launch.swap show how a client carries a hook with several extras. The docs walk through both: the kit, the launchpad hook.