Build · Rust
Write a hook
A HELIX hook is an ordinary Solana program that implements some of the six callbacks of the helix-hook crate. This page walks through trade_tax, the example hook in programs/example-hook: it takes a cut of buys and sells only, and remembers in each holding when its first tokens arrived.
The crate has no dependencies and is no_std. It defines the discriminators (disc), the flags (flags), the arguments (TokenHookArgs, Context, Op, Phase), the answer (HookReturn, Delta) and the seeds. The example is written with Pinocchio; Anchor works too, since both encodings are plain Borsh.
[package]
name = "my-hook"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
pinocchio = { version = "0.11.2", features = ["cpi"] }
# Not on crates.io yet: point at programs/helix-hook in the HELIX repository.
helix-hook = { path = "../helix-hook" }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }HELIX calls the hook with an 8-byte discriminator, then the arguments. Match the callbacks you implement; the example only asks for before_transfer in its flags, so the others are never called. Its own init instruction, which stores a config per mint, sits beside them.
#![no_std]
use helix_hook::{disc, Context, Delta, HookReturn, TokenHookArgs, HOOK_AUTHORITY_SEED, RETURN_MAX_LEN};
use pinocchio::{
cpi::{set_return_data, Seed, Signer},
error::ProgramError,
sysvars::{clock::Clock, rent::Rent, Sysvar},
AccountView, Address, ProgramResult,
};
/// `HLX49qz3CSKQrHrdzrrWygZBaKHWWSG8JwUMFHcZ47Fr`
pub const HELIX_ID: Address = Address::new_from_array([
242, 187, 2, 62, 19, 62, 25, 97, 44, 74, 127, 159, 46, 139, 204, 91, 50, 162, 195, 201, 73, 17, 15, 64, 10, 82, 238,
172, 22, 22, 48, 141,
]);
pub const INIT: [u8; 8] = [220, 59, 207, 236, 108, 250, 47, 100];
pub const CONFIG_LEN: usize = 1 + 1 + 2 + 32 + 32 + 32;
/// Index of the collector's holding in a callback's accounts.
pub const COLLECTOR_INDEX: u8 = 6;
#[cfg(target_os = "solana")]
mod entrypoint {
use super::*;
pinocchio::program_entrypoint!(process_instruction);
pinocchio::no_allocator!();
pinocchio::nostd_panic_handler!();
}
fn err() -> ProgramError {
ProgramError::InvalidArgument
}
pub fn process_instruction(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
if data.len() < 8 {
return Err(err());
}
let (d, rest) = data.split_at(8);
let d: [u8; 8] = d.try_into().map_err(|_| err())?;
match d {
INIT => init(program_id, accounts, rest), // the hook's own setup, per mint
disc::BEFORE_TRANSFER => before_transfer(program_id, accounts, rest),
disc::AFTER_TRANSFER => Ok(()),
_ => Err(ProgramError::InvalidInstructionData),
}
}fn before_transfer(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> ProgramResult {
// HELIX's five accounts, then this hook's extras: [config, collector_holding].
let [signer, mint, _source, _destination, _authority, config, collector, ..] = accounts else {
return Err(err());
};
// Only HELIX's signer for this hook may call it.
let expected = Address::try_find_program_address(&[HOOK_AUTHORITY_SEED, program_id.as_array()], &HELIX_ID)
.ok_or(err())?
.0;
if !signer.is_signer() || expected.as_array() != signer.address().as_array() {
return Err(ProgramError::MissingRequiredSignature);
}
let args = TokenHookArgs::decode(data).ok_or(err())?;
// Check its own accounts: the config is ours, for this mint, naming this collector.
if !config.owned_by(program_id) || config.data_len() < CONFIG_LEN {
return Err(err());
}
let c = unsafe { core::slice::from_raw_parts(config.data_ptr(), CONFIG_LEN) };
if &c[36..68] != mint.address().as_array() || &c[68..100] != collector.address().as_array() {
return Err(err());
}
let fee_bps = u16::from_le_bytes([c[2], c[3]]) as u128;
let collector_owner = &c[4..36];
let mut ret = HookReturn::default();
// A cut on buys and sells only: the hook is told which is which.
let trade = matches!(args.context, Context::Buy | Context::Sell);
let exempt = &args.source_owner[..] == collector_owner || &args.destination_owner[..] == collector_owner;
if trade && !exempt {
let cut = (args.amount as u128 * fee_bps / 10_000) as u64;
if cut > 0 {
ret.deltas[0] = Delta { amount: cut, account: COLLECTOR_INDEX };
ret.delta_count = 1;
}
}
// Remember when this holding first received tokens, in its 64 bytes.
if args.destination_hook_data[0..8] == [0u8; 8] {
let mut d = args.destination_hook_data;
d[0..8].copy_from_slice(&Clock::get()?.unix_timestamp.to_le_bytes());
ret.destination_hook_data = Some(d);
}
let mut out = [0u8; RETURN_MAX_LEN];
let n = ret.encode(&mut out);
set_return_data(&out[..n]);
Ok(())
}- Check the caller. Account 0 must be a signer, and must be HELIX's PDA
["hook-authority", this program]. Without this check anyone could call the hook with made-up arguments. - Decode the arguments with
TokenHookArgs::decode; they hold the amount, both owners, both balances, both holdings' 64 bytes and thecontext. - Check your own accounts. HELIX only checks the five it passes; the extras are the client's. Here: the config must be this program's, for this mint, and name this collector.
- Decide.
Context::BuyandContext::Sellare trades on the HELIX DEX, and only they pay here.Context::Plainis a transfer between wallets,Context::Liquiditya pool deposit or withdrawal, andContext::Bridgetokens moving into or out of the bridge's vault. - Answer. A cut is a
Delta: an amount and the index of a holding among the callback's accounts. New hook data is anOption<[u8; 64]>.encodeit andset_return_data. To change nothing, set no return data.
| Index | Account | From |
|---|---|---|
| 0 | HELIX’s signer for this hook | HELIX |
| 1 | Mint | HELIX |
| 2 | Source holding | HELIX |
| 3 | Destination holding | HELIX |
| 4 | Authority | HELIX |
| 5 | The hook’s config, PDA ["tax", mint] of the hook program (read-only) | the client |
| 6 | The collector’s holding (writable): COLLECTOR_INDEX | the client |
A cut must name an index of 5 or more, a writable HELIX holding of the same mint that is neither the source nor the destination, and the cuts together can't exceed the amount. The full list of checks is on Hooks.
cargo build-sbf --manifest-path programs/my-hook/Cargo.toml
solana program deploy target/deploy/my_hook.soThe example is deployed at AvY5wRP672DpNod6TWvMcNx9ekjgxoBjmj42ijbHenhz. Test the same way the SDK does: load your .so into LiteSVM next to helix.so, as packages/sdk/test/hook.test.ts does with the example.
Two ways, depending on whether the token is a launch.
- A launch: pass
customHookandcustomHookFlagstoix.createLaunch. The hook and its flags are then fixed for ever. The example launches with193(BEFORE_TRANSFER | TRANSFER_RETURNS_DELTA | WRITES_HOOK_DATA). See Launch from code. - A mint of your own: name the hook in
ix.createMint. Keep ahookAuthorityif you want to change the hook later withix.setHook; set it to null to fix it for good.
import { address, generateKeyPairSigner, type AccountMeta, type KeyPairSigner } from '@solana/kit';
import { HOOK_FLAGS, holdingAddress, hookAccounts, ix } from '@helixfi/sdk';
const MY_HOOK = address('<your hook program id>');
export async function mintWithHook(payer: KeyPairSigner, extras: AccountMeta[]) {
const mint = await generateKeyPairSigner();
const holding = await holdingAddress(mint.address, payer.address);
const hook = await hookAccounts(mint.address, { hookProgram: MY_HOOK }, extras); // [hook program, HELIX's signer, ...extras]
// send() as in the SDK quickstart: it signs with the payer and the new mint.
await send(payer, [
ix.createMint(
{ payer, mint },
{
decimals: 6,
maxSupply: 1_000_000_000_000_000n, // 1B tokens; 0n for no cap
mintAuthority: payer.address,
hookProgram: MY_HOOK,
hookFlags: HOOK_FLAGS.BEFORE_TRANSFER | HOOK_FLAGS.TRANSFER_RETURNS_DELTA,
hookAuthority: null, // null: nobody can ever change the hook
name: 'My Token',
symbol: 'MINE',
uri: 'https://example.com/mine.json',
},
),
ix.createHolding({ payer, holding, mint: mint.address, owner: payer.address }),
ix.mintTo({ mintAuthority: payer, mint: mint.address, destination: holding, hook }, 1_000_000_000_000n),
]);
return mint.address;
}Every operation on the token then passes the hook's accounts: hookAccounts(mint, decodedMint, extras) for transfers, mints, burns and liquidity, swapHookAccounts(decodedMint, extras) for swaps. A mint with a hook gets a DEX pool with ix.createPool.
Clients have to know which extra accounts your hook wants. The convention, which this site reads, is an account at the PDA ["extra-accounts", mint] of your hook program:
Create and fill it in your setup instruction. The SDK reads it with decodeExtraAccounts at extraAccountsAddress(hookProgram, mint). Without it this site can't build transactions for your token (it knows the example hook's layout only).
- Check that account 0 is HELIX's signer PDA and a signer, in every callback.
- Check every extra account you rely on: owner, the mint it is for, the address you expect.
- Use
contextto tell trades from transfers; don't guess from the accounts. - Remember that a failing
before_*blocks the operation, sells included. A bug in your hook can trap every holder of the token. Test the failure paths. - Keep it cheap: every transfer and every trade of the token pays your compute.
- With
WRITES_HOOK_DATA, a holding can be closed only when its 64 bytes are zero. Clear them when a balance reaches zero (answer zeros as the source's data). The example keeps the arrival time for ever, so its holdings can't be closed. - Decide who can upgrade your hook program. A launched token can't change its hook, but your program's upgrade authority can change what the hook does.