Skip to content
helixLaunch

SDK

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.

Cargo.toml
[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.

lib.rs: entry
#![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),
    }
}
lib.rs: before_transfer
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(())
}
  1. 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.
  2. Decode the arguments with TokenHookArgs::decode; they hold the amount, both owners, both balances, both holdings' 64 bytes and the context.
  3. 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.
  4. Decide. Context::Buy and Context::Sell are trades on the HELIX DEX, and only they pay here. Context::Plain is a transfer between wallets, Context::Liquidity a pool deposit or withdrawal, and Context::Bridge tokens moving into or out of the bridge's vault.
  5. Answer. A cut is a Delta: an amount and the index of a holding among the callback's accounts. New hook data is an Option<[u8; 64]>. encode it and set_return_data. To change nothing, set no return data.
IndexAccountFrom
0HELIX’s signer for this hookHELIX
1MintHELIX
2Source holdingHELIX
3Destination holdingHELIX
4AuthorityHELIX
5The hook’s config, PDA ["tax", mint] of the hook program (read-only)the client
6The collector’s holding (writable): COLLECTOR_INDEXthe 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.

shell
cargo build-sbf --manifest-path programs/my-hook/Cargo.toml
solana program deploy target/deploy/my_hook.so

The 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 customHook and customHookFlags to ix.createLaunch. The hook and its flags are then fixed for ever. The example launches with 193 (BEFORE_TRANSFER | TRANSFER_RETURNS_DELTA | WRITES_HOOK_DATA). See Launch from code.
  • A mint of your own: name the hook in ix.createMint. Keep a hookAuthority if you want to change the hook later with ix.setHook; set it to null to fix it for good.
mint-with-hook.ts
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:

[count u8] then count × [ writable u8 (1 = writable), address [u8; 32] ]

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 context to 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.