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.
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#
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.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: Socket 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. Socket fails the transaction rather than clamping.Fit the compute you asked for
The pool's
hook_budgetis measured around your call, including its own overhead. Measure your worst case in simulation and set the budget above it, with room to spare.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.
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 }],
});{
"owner": "<creator>",
"nonce": "1",
"mintA": "<mint A>",
"mintB": "<mint B>",
"sqrtPrice": "18446744073709551616",
"tickSpacing": 10,
"baseFeePpm": 3000,
"maxFeePpm": 10000,
"maxDeltaBps": 0,
"permissions": 17,
"hookBudget": "200000",
"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, 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.