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.
| Callback | Runs | Discriminator |
|---|---|---|
before_transfer | before a transfer; may answer cuts and hook data | [23, 118, 189, 23, 179, 222, 94, 146] |
after_transfer | after it, with balances after | [153, 128, 220, 5, 209, 90, 39, 89] |
before_mint | before a mint; may answer the destination’s hook data | [67, 27, 57, 7, 28, 168, 109, 153] |
after_mint | after it | [134, 114, 182, 161, 82, 69, 14, 154] |
before_burn | before a burn; may answer the source’s hook data | [7, 177, 19, 160, 28, 229, 57, 73] |
after_burn | after 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.
| Index | Account | Access |
|---|---|---|
| 0 | Hook signer: HELIX’s PDA ["hook-authority", hook_program] | read-only, signer |
| 1 | Mint | read-only |
| 2 | Source holding (the mint, for a mint) | read-only |
| 3 | Destination holding (the mint, for a burn) | read-only |
| 4 | Authority | read-only |
| 5… | The hook’s extra accounts, in the order the client passed them | as passed (writable or not), never signers |
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.
| Field | Offset | Type | Meaning |
|---|---|---|---|
op | 0 | u8 | 0 transfer, 1 mint, 2 burn. |
phase | 1 | u8 | 0 before, 1 after. |
mint | 2 | Pubkey | The mint. |
source | 34 | Pubkey | The source holding. For a mint: the mint account. |
destination | 66 | Pubkey | The destination holding. For a burn: the mint account. |
source_owner | 98 | Pubkey | Owner of the source holding (the mint’s address for a mint). |
destination_owner | 130 | Pubkey | Owner of the destination holding (the mint’s address for a burn). |
authority | 162 | Pubkey | Who 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_delegate | 194 | bool | Whether the authority is the source’s delegate. |
amount | 195 | u64 | Tokens the operation moves, mints or burns. |
delta | 203 | u64 | What the cuts took. Zero in the before phase. |
source_balance | 211 | u64 | Before the operation in the before phase; after it in the after phase. Zero when absent. |
destination_balance | 219 | u64 | Same, for the destination. |
decimals | 227 | u8 | The mint’s decimals. |
supply | 228 | u64 | The mint’s supply. |
source_hook_data | 236 | [u8; 64] | The source holding’s 64 bytes (zeros when absent). |
destination_hook_data | 300 | [u8; 64] | The destination holding’s 64 bytes (zeros when absent). |
context | 364 | u8 | 0 Plain, 1 Buy, 2 Sell, 3 Liquidity, 4 Bridge. See below. |
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.
| Value | Context | Used for |
|---|---|---|
| 0 | Plain | The transfer, mint_to and burn instructions; the launch’s one mint of its supply into the pool; the burn of unused tokens at graduation. |
| 1 | Buy | A 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. |
| 2 | Sell | A sell: the transfer from the trader to the pool, and the burn rule’s burn from the pool’s holding. |
| 3 | Liquidity | create_pool and add_liquidity (tokens into the pool), remove_liquidity (tokens out of it). |
| 4 | Bridge | Tokens 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.
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.
| Bit | Value | Flag | Meaning |
|---|---|---|---|
| 0 | 1 | BEFORE_TRANSFER | Call before_transfer. |
| 1 | 2 | AFTER_TRANSFER | Call after_transfer. |
| 2 | 4 | BEFORE_MINT | Call before_mint. |
| 3 | 8 | AFTER_MINT | Call after_mint. |
| 4 | 16 | BEFORE_BURN | Call before_burn. |
| 5 | 32 | AFTER_BURN | Call after_burn. |
| 6 | 64 | TRANSFER_RETURNS_DELTA | before_transfer may answer up to three cuts. |
| 7 | 128 | WRITES_HOOK_DATA | before_* callbacks may answer hook data, and write_hook_data is allowed. Holdings must be all zeros to close. |
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).
// 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:
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:
- The return data decodes exactly: at most 3 cuts, no bytes left over. Otherwise
UnsupportedHookReturn. - 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. OtherwiseUnsupportedHookReturn. - Cuts are answered only by
before_transfer, and only if the mint hasTRANSFER_RETURNS_DELTA. OtherwiseUnsupportedHookReturn. - Every cut is more than zero (
UnsupportedHookReturn). - 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. - 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_DATAset, 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.
hook_selfis the PDA["hook-authority"]of the hook program, so only the hook can sign it, by CPI withinvoke_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.
| Bytes | Type | Holds |
|---|---|---|
| 0..16 | u128 | Snapshot of the launch’s acc_per_share when the holding was last settled. |
| 16..24 | u64 | Rewards owed, in lamports, not yet claimed. |
| 24..32 | u64 | Tokens locked as an early buyer. |
| 32..64 | Zero. |