Skip to content
helixLaunch

Docs

Hooks

The hook protocol

A hook is an ordinary Solana program. A mint names it in hook_program, and its hook_flags say which callbacks run and what they may answer. HELIX calls the hook by cross-program invocation, signed by its own PDA, tells it what is happening and why, and checks its answer before applying it.

The hook's instructions are named after six callbacks. HELIX selects one with an Anchor-style discriminator, the first 8 bytes of sha256("global:<name>"), so an Anchor program whose instructions carry these names matches without extra work.

CallbackRunsDiscriminator
before_transferbefore a transfer; may answer cuts and hook data[23, 118, 189, 23, 179, 222, 94, 146]
after_transferafter it, with balances after[153, 128, 220, 5, 209, 90, 39, 89]
before_mintbefore a mint; may answer the destination’s hook data[67, 27, 57, 7, 28, 168, 109, 153]
after_mintafter it[134, 114, 182, 161, 82, 69, 14, 154]
before_burnbefore a burn; may answer the source’s hook data[7, 177, 19, 160, 28, 229, 57, 73]
after_burnafter it[6, 153, 69, 120, 1, 248, 63, 0]

Instruction data is the discriminator followed by the Borsh encoding of TokenHookArgs: 373 bytes in all (365 for the arguments). A callback whose flag is off is not called. Any callback can refuse by returning an error: the whole transaction fails.

IndexAccountAccess
0Hook signer: HELIX’s PDA ["hook-authority", hook_program]read-only, signer
1Mintread-only
2Source holding (the mint, for a mint)read-only
3Destination holding (the mint, for a burn)read-only
4Authorityread-only
5…The hook’s extra accounts, in the order the client passed themas passed (writable or not), never signers
A callback receives at most 16 accounts: the 5 fixed ones and up to 11 extras.

The five fixed accounts are read-only to the hook: it never moves tokens itself. It tells HELIX what to do in its answer, and HELIX does it.

Everything HELIX knows about the operation, as plain little-endian concatenation (Borsh). In the before phase balances and hook data are those before the operation; in the after phase, those after it.

FieldOffsetTypeMeaning
op0u80 transfer, 1 mint, 2 burn.
phase1u80 before, 1 after.
mint2PubkeyThe mint.
source34PubkeyThe source holding. For a mint: the mint account.
destination66PubkeyThe destination holding. For a burn: the mint account.
source_owner98PubkeyOwner of the source holding (the mint’s address for a mint).
destination_owner130PubkeyOwner of the destination holding (the mint’s address for a burn).
authority162PubkeyWho signed: the owner, a delegate, the mint authority, a HELIX pool when the pool sends, or a mirror when the bridge’s vault sends.
authority_is_delegate194boolWhether the authority is the source’s delegate.
amount195u64Tokens the operation moves, mints or burns.
delta203u64What the cuts took. Zero in the before phase.
source_balance211u64Before the operation in the before phase; after it in the after phase. Zero when absent.
destination_balance219u64Same, for the destination.
decimals227u8The mint’s decimals.
supply228u64The mint’s supply.
source_hook_data236[u8; 64]The source holding’s 64 bytes (zeros when absent).
destination_hook_data300[u8; 64]The destination holding’s 64 bytes (zeros when absent).
context364u80 Plain, 1 Buy, 2 Sell, 3 Liquidity, 4 Bridge. See below.
Offsets are into the arguments, after the 8-byte discriminator. Total: 365 bytes.

A Token-2022 hook has to guess whether a transfer is a trade. A HELIX hook is told. HELIX sets the context from the instruction that is running, never from the caller's arguments: the transfer instruction always passes Plain.

ValueContextUsed for
0PlainThe transfer, mint_to and burn instructions; the launch’s one mint of its supply into the pool; the burn of unused tokens at graduation.
1BuyA buy on the HELIX DEX: the transfer from the pool to the trader, and the burn rule’s burn from the pool’s holding. The authority is the pool.
2SellA sell: the transfer from the trader to the pool, and the burn rule’s burn from the pool’s holding.
3Liquiditycreate_pool and add_liquidity (tokens into the pool), remove_liquidity (tokens out of it).
4BridgeTokens moving into or out of the bridge’s vault: bridge_out (the owner’s holding to the mirror’s vault holding) and bridge_back (the vault back to the owner; the authority is the mirror).

A before_* callback may answer by setting return data (set_return_data) to a Borsh HookReturn. No return data, or empty return data, means “go ahead as is”. HELIX reads the answer only if it was set by the hook program itself.

u32 n number of cuts, 0 to 3 n × { u64 amount, u8 account } a cut: tokens, and the account index to credit u8 0 | u8 1, [u8; 64] new hook data for the source holding, or none u8 0 | u8 1, [u8; 64] the same for the destination holding

At most 161 bytes. A cut's account is an index into the callback's account list and must point past the five fixed accounts, at one of the hook's extras. The recipient receives the amount less every cut; each cut is credited to its holding directly, without calling the hook again. after_* callbacks can't answer: they should set no return data (a well-formed answer there is ignored).

In helix-hook the answer is the struct HookReturn (deltas: [Delta; 3], delta_count, two Option<[u8; 64]>) with encode and decode; on the wire it is the Borsh shape above, a Vec<Delta> followed by two options.

BitValueFlagMeaning
01BEFORE_TRANSFERCall before_transfer.
12AFTER_TRANSFERCall after_transfer.
24BEFORE_MINTCall before_mint.
38AFTER_MINTCall after_mint.
416BEFORE_BURNCall before_burn.
532AFTER_BURNCall after_burn.
664TRANSFER_RETURNS_DELTAbefore_transfer may answer up to three cuts.
7128WRITES_HOOK_DATAbefore_* callbacks may answer hook data, and write_hook_data is allowed. Holdings must be all zeros to close.
Bits above 7 are refused. A mint with no hook program stores its flags as zero. A launched token’s flags are fixed: it has no hook authority.

HELIX signs every callback with the PDA ["hook-authority", hook_program], derived from the HELIX program id (HLX49qz3CSKQrHrdzrrWygZBaKHWWSG8JwUMFHcZ47Fr). It is account 0 and always a signer. A hook must check both, or anyone could call it directly and feed it invented arguments. The SDK gives the address as hookSignerAddress(hookProgram).

verify the caller (Rust)
// programs/example-hook/src/lib.rs: only HELIX's signer for this hook may call it.
let [signer, mint, _source, _destination, _authority, config, collector, ..] = accounts else {
    return Err(err());
};
let expected = Address::try_find_program_address(
    &[HOOK_AUTHORITY_SEED, program_id.as_array()], // ["hook-authority", this program]
    &HELIX_ID,                                     // derived from the HELIX program 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())?;

A second PDA goes the other way: ["hook-authority"] derived from the hook program id is how a hook signs write_hook_data (hookSelfAddress(hookProgram) in the SDK).

A hook decides which other accounts it needs: its config, a collector's holding, anything it reads or credits. Clients append them to the HELIX instruction after the hook program and the signer: [hook_program, hook_signer, …extras]. HELIX checks that the hook program is the mint's and executable, and that the signer is the right PDA; missing accounts fail with HookAccountsMissing.

  • Token operations (transfer, mint_to, burn, the liquidity instructions): hookAccounts(mint, decodedMint, extras).
  • Swaps: swapHookAccounts(decodedMint, extras). It is empty for a token without a hook and for the kit, which runs on the swap's own launch account.

Publishing them

HELIX doesn't read it, but clients need to know a hook's extras. The convention is a PDA of the hook program, ["extra-accounts", mint] (extraAccountsAddress(hookProgram, mint)), holding:

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

decodeExtraAccounts(data) reads it. This site trades a custom-hook token only if it can resolve the hook's accounts this way (the example hook is resolved from its own config).

HELIX refuses the whole operation, and the transaction fails, if any of these doesn't hold:

  1. The return data decodes exactly: at most 3 cuts, no bytes left over. Otherwise UnsupportedHookReturn.
  2. Hook data is answered only if the mint has WRITES_HOOK_DATA, and only for a holding the operation touches: source data on a transfer or burn, destination data on a transfer or mint. Otherwise UnsupportedHookReturn.
  3. Cuts are answered only by before_transfer, and only if the mint has TRANSFER_RETURNS_DELTA. Otherwise UnsupportedHookReturn.
  4. Every cut is more than zero (UnsupportedHookReturn).
  5. Every cut points at one of the hook's extras (index 5 or more), no extra is named twice, and the account is writable, is neither the source nor the destination, is a HELIX holding of the same mint and is not frozen. Otherwise InvalidDeltaAccount.
  6. The cuts add up to no more than the amount (DeltaTooLarge).

Every holding carries 64 bytes that belong to the mint's hook. They start as zeros. Only the hook can change them: in a before_* answer, or through write_hook_data. HELIX hands the source's and destination's bytes to every callback in source_hook_data and destination_hook_data, so a hook can read a holder's state without an extra account.

  • With WRITES_HOOK_DATA set, a holding can only be closed when its 64 bytes are all zero. A hook that wants holders to be able to close should clear them when a balance reaches zero.
  • On a mint whose hook can still be changed (it has a hook authority), the bytes stay when the hook changes: the new hook sees what the old one wrote.

Tag 22. Lets a hook update a holding's bytes outside a token operation, for example when a holder claims something from the hook's own program.

write_hook_data(data: [u8; 64]) accounts: hook_self (signer), mint, holding (writable)
  • hook_self is the PDA ["hook-authority"] of the hook program, so only the hook can sign it, by CPI with invoke_signed.
  • The mint must have an external hook (not none, not the kit) with WRITES_HOOK_DATA, and the holding must be of that mint.
  • The SDK has no builder for it: it is called from inside a hook program, not from a client.

The kit is the hook of every launch that turns on a token rule: holder rewards, a max wallet, a creator lock or an early-buyer lock. The mint's hook_program is HELIX's own id, and the kit runs inside HELIX, without a cross-program call. It can only be installed by create_launch: create_mint, set_hook and create_pool refuse it (KitOnlyThroughLaunch). Its hook accounts are just [launch], and a swap needs none. The rules are on Launches.

BytesTypeHolds
0..16u128Snapshot of the launch’s acc_per_share when the holding was last settled.
16..24u64Rewards owed, in lamports, not yet claimed.
24..32u64Tokens locked as an early buyer.
32..64Zero.
Little-endian. decodeKitHookData(holding.hookData) in the SDK reads it. A holding owed rewards can't be closed until they are claimed.