Hooks
Write a hook
Build a program that answers Latch's callback, create a pool that plugs it in, and get it routed.
A hook is an ordinary Solana program with one instruction that matters: Callback(HookCall). Latch calls it, your program checks that the call is genuine, does its work, and sets a HookReply as return data. The types live in the socket-hook-interface crate.
A minimal hook#
This hook charges the pool's max fee on swaps above a size and the base fee otherwise. It needs before swap (1) and fee override (16): mask 17.
use borsh::BorshDeserialize;
use socket_hook_interface::{
hook_authority_address, HookInstruction, HookReply, BEFORE_SWAP, SOCKET_PROGRAM_ID,
};
use solana_program::{
account_info::AccountInfo, entrypoint, entrypoint::ProgramResult,
program::set_return_data, program_error::ProgramError, pubkey::Pubkey,
};
entrypoint!(process);
const LARGE: u64 = 1_000_000_000;
fn process(_program: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let call = match HookInstruction::try_from_slice(data) {
Ok(HookInstruction::Callback(call)) => call,
_ => return Err(ProgramError::InvalidInstructionData),
};
let [pool, authority, ..] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// Only Latch can sign as this pool's hook authority.
let (expected, _) = hook_authority_address(pool.key);
if pool.owner != &SOCKET_PROGRAM_ID || authority.key != &expected || !authority.is_signer {
return Err(ProgramError::IllegalOwner);
}
let mut reply = HookReply::unchanged(call.phase);
if call.phase == BEFORE_SWAP {
let fee = if call.amount >= LARGE { call.max_fee_ppm } else { call.base_fee_ppm };
reply.fee_ppm = Some(fee);
}
let bytes = borsh::to_vec(&reply).map_err(|_| ProgramError::InvalidAccountData)?;
set_return_data(&bytes);
Ok(())
}Rules your program must follow#
Authenticate every call
Check that the pool is owned by the Latch program and that the second account is the pool's hook authority,
["hook-authority", pool]under Latch, and signed. Without this, anyone can call your hook directly with made-up data and write to its state.Reply with the call's phase
Set return data on every successful call, with
version: 1and the samephase.HookReply::unchanged(phase)is the empty answer. If your hook calls other programs, set your reply last: Latch rejects return data from any program but the pool's hook.Stay inside the pool's bounds
Only set
fee_ppmin the before-swap phase, and never abovecall.max_fee_ppm. Only setinput_deltaafter a swap, and never abovecall.amount_in × max_delta_bps / 10,000. The call doesn't carrymax_delta_bps: read it from the pool account, which is passed read-only. Latch fails the transaction rather than clamping.Simulate transaction compute
Your hook shares the transaction's compute limit with Latch and every other instruction. Simulate complete operations, including your hook's worst case, and set the transaction's limit with headroom. The pool's serialized
hook_budgetis reserved for compatibility and does not enforce a per-call cap; use zero for new pools.Refuse with an error
To reject a swap or a liquidity change, return an error. The whole transaction reverts. That is the only way to say no.
State and accounts#
Your hook gets the accounts you list when the pool is created, at most eight, in that order, with the write access you chose. Each entry records the address, the program that must own it, and whether it is writable; Latch checks all three on every call. Addresses are literal: there is no seed resolution at run time.
Latch passes an initialization call only to the built-in hooks program. Create and initialize your hook's accounts before you create the pool, with your program's own instruction, and list them as the pool's extras. One state account per pool, derived from the pool's address, is the usual shape.
The pool is locked while your hook runs, so your hook can't call back into Latch for this pool.
Create a pool with your hook#
The Latch API prepares pools only for hook programs on its allowlist. Before yours is on it, build the instruction with the SDK and send it yourself.
import { initializeInstruction } from "@socket/backend/sdk";
const ix = initializeInstruction({
payer: creator,
nonce: 1n,
mintA, mintB, // sorted by public-key bytes
sqrtPrice: 1n << 64n, // Q64.64: a price of 1 in atomic units
tickSpacing: 10,
baseFeePpm: 3000,
maxFeePpm: 10000,
maxDeltaBps: 0,
permissions: 1 | 16, // before swap + fee override
hookBudget: 0n, // reserved for wire compatibility
hookProgram: myHook,
extras: [{ address: myState, owner: myHook, writable: true }],
});{
"owner": "<creator>",
"nonce": "1",
"mintA": "<mint A>",
"mintB": "<mint B>",
"sqrtPrice": "18446744073709551616",
"tickSpacing": 10,
"baseFeePpm": 3000,
"maxFeePpm": 10000,
"maxDeltaBps": 0,
"permissions": 17,
"hookBudget": "0",
"hookProgram": "<your program>",
"extras": [{ "address": "<state account>", "owner": "<your program>", "writable": true }]
}The instruction checks the pool parameters before it is built: sorted mints, a max fee of at most 100,000 ppm, a surcharge cap of at most 1,000 bps, and no power without its phase.
Getting routed#
Latch enforces the bounds; it doesn't vouch for what a hook does inside them. The Latch API and the app only resolve, quote and prepare pools whose hook program is on the deployment's allowlist; pools with other hooks are indexed but marked unsupported. Other routers keep their own lists.
To be considered, a hook should be:
- Deployed with a verifiable build, so the bytes on chain can be matched to source.
- Immutable or under a clear upgrade authority. A pool fixes the hook's address, not its code; an upgradeable program can change behind the pool.
- Documented: which phases it uses, what it reads and writes, what it can refuse, and its worst-case compute.
- Tested against Latch's bounds, including malformed calls and calls that don't come from Latch.