Docs

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.

src/lib.rsRust
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#

  1. 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.

  2. Reply with the call's phase

    Set return data on every successful call, with version: 1 and the same phase. 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.

  3. Stay inside the pool's bounds

    Only set fee_ppm in the before-swap phase, and never above call.max_fee_ppm. Only set input_delta after a swap, and never above call.amount_in × max_delta_bps / 10,000. The call doesn't carry max_delta_bps: read it from the pool account, which is passed read-only. Latch fails the transaction rather than clamping.

  4. 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_budget is reserved for compatibility and does not enforce a per-call cap; use zero for new pools.

  5. 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.

create-pool.tsTypeScript
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 }],
});

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.