Docs

Hooks

Write a hook

Build a program that answers Socket'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). Socket 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 Socket 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 Socket program and that the second account is the pool's hook authority, ["hook-authority", pool] under Socket, 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: Socket 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. Socket fails the transaction rather than clamping.

  4. Fit the compute you asked for

    The pool's hook_budget is measured around your call, including its own overhead. Measure your worst case in simulation and set the budget above it, with room to spare.

  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; Socket checks all three on every call. Addresses are literal: there is no seed resolution at run time.

Socket 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 Socket for this pool.

Create a pool with your hook#

The Socket 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: 200_000n,
  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, a positive hook budget, and no power without its phase.

Getting routed#

Socket enforces the bounds; it doesn't vouch for what a hook does inside them. The Socket 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 Socket's bounds, including malformed calls and calls that don't come from Socket.