Write a module
A module is your own Solana program plus a module account whose address ends in the permission byte it needs. This page builds one in Anchor: a fee that rises for large trades.
What you build#
- A program with one instruction per hook point you use, named
before_swap,after_swap,before_add_liquidity,after_add_liquidity,before_remove_liquidityorafter_remove_liquidity. The AMM only calls hook points whose bit is set, so you implement only those. - Module accounts owned by that program, at addresses whose last byte is your flags. Each module account is one configuration; a program can have many.
- Optionally, per-pool state, created by a permissionless instruction of your own.
The example below, Size fee, charges a higher fee when a trade is large compared with the pool. It needs no per-pool state.
1. Pick the jacks#
Size fee changes the fee before the curve runs, so it needs Before swap and Dynamic fee: 0x41. Ask for as few bits as you need. Every hook-point bit is one more cross-program call on every matching instruction, and up to four modules share one transaction’s compute budget.
Try combinations on How it works.
2. Set up the crate#
[package]
name = "size-fee"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
idl-build = ["anchor-lang/idl-build", "patchbay-interface/idl-build"]
[dependencies]
anchor-lang = "1.2.1"
borsh = "1.5.7"
# crates/patchbay-interface from the Patchbay repository
[dependencies.patchbay-interface]
path = "../patchbay/crates/patchbay-interface"
features = ["anchor"]patchbay-interface gives you the flag constants, the param and result structs, the six discriminators, authenticate_hook_call, and the same curve math the AMM uses (swap_out, fee_amount, lp_for_deposit and friends), so your numbers match the pool’s exactly. The anchor feature derives the structs through Anchor so they work as instruction arguments.
3. The program#
use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::set_return_data;
use patchbay_interface::{
authenticate_hook_call, flags_of, BeforeSwapResult, SwapParams, BEFORE_SWAP,
DYNAMIC_FEE, MAX_FEE_BPS, NO_FEE_OVERRIDE,
};
// `anchor keys sync` writes your program id here.
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
/// Every Size fee module address ends in this byte: 0x41.
pub const REQUIRED_FLAGS: u8 = BEFORE_SWAP | DYNAMIC_FEE;
#[program]
pub mod size_fee {
use super::*;
/// Permissionless. The caller mines `nonce` so the PDA ends in REQUIRED_FLAGS.
pub fn create_module(
ctx: Context<CreateModule>,
nonce: u64,
threshold_bps: u16,
large_fee_bps: u16,
) -> Result<()> {
let module = &mut ctx.accounts.module;
require!(flags_of(&module.key()) == REQUIRED_FLAGS, SizeFeeError::WrongFlags);
require!(large_fee_bps <= MAX_FEE_BPS, SizeFeeError::FeeTooHigh);
let bump = ctx.bumps.module;
module.set_inner(SizeFee { threshold_bps, large_fee_bps, nonce, bump });
Ok(())
}
/// Called by the Patchbay AMM before the curve runs.
/// Trades of at least `threshold_bps` of the input reserve pay `large_fee_bps`.
pub fn before_swap(ctx: Context<Hook>, params: SwapParams) -> Result<()> {
let pool = ctx.accounts.pool.to_account_info();
authenticate_hook_call(
ctx.accounts.hook_auth.key,
ctx.accounts.hook_auth.is_signer,
pool.owner,
&pool.try_borrow_data()?,
&ctx.accounts.module.key(),
)
.map_err(|_| error!(SizeFeeError::NotPatchbay))?;
let m = &ctx.accounts.module;
let reserve_in = match params.a_to_b {
true => params.reserve_a,
false => params.reserve_b,
};
let size_bps = params.amount_in as u128 * 10_000 / reserve_in.max(1) as u128;
let fee_override_bps = if size_bps >= m.threshold_bps as u128 {
m.large_fee_bps
} else {
NO_FEE_OVERRIDE // keep the pool's fee
};
let result = BeforeSwapResult { fee_override_bps, ..Default::default() };
set_return_data(&borsh::to_vec(&result)?);
Ok(())
}
}
#[derive(Accounts)]
#[instruction(nonce: u64)]
pub struct CreateModule<'info> {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
init,
payer = payer,
space = 8 + SizeFee::INIT_SPACE,
seeds = [b"module", nonce.to_le_bytes().as_ref()],
bump,
)]
pub module: Account<'info, SizeFee>,
pub system_program: Program<'info, System>,
}
/// What every hook point receives: [hook_auth (signer), pool, module, ...extra].
#[derive(Accounts)]
pub struct Hook<'info> {
pub hook_auth: Signer<'info>,
/// CHECK: verified by authenticate_hook_call (AMM-owned, module in its patch)
pub pool: UncheckedAccount<'info>,
pub module: Account<'info, SizeFee>,
}
#[account]
#[derive(InitSpace)]
pub struct SizeFee {
pub threshold_bps: u16,
pub large_fee_bps: u16,
pub nonce: u64,
pub bump: u8,
}
#[error_code]
pub enum SizeFeeError {
#[msg("Module address does not end in the required flags byte")]
WrongFlags,
#[msg("Fee override above MAX_FEE_BPS")]
FeeTooHigh,
#[msg("Caller is not the Patchbay AMM for this pool and module")]
NotPatchbay,
}4. Authenticate every call#
A hook instruction is a public instruction of your program. Anyone can call it directly, so it first proves the caller is the AMM calling this module for this pool. authenticate_hook_call checks that:
- the pool account is owned by the Patchbay AMM and parses as a pool;
- your module account is in the pool’s patch;
hook_authis that module’s hook authority,PDA(["hook_auth", pool, module], AMM), as stored in the pool;hook_authsigned. Only the AMM can sign for it.
It returns the parsed pool and your module’s index in the patch. The AMM saves the pool account before every call, so what you read matches the params you were sent. If you keep per-pool state, also check that its PDA matches the pool. Because each module has its own hook authority, one module cannot use its signature to pose as the AMM toward another module of the same pool.
5. Return data#
Hooks answer through return data, borsh-encoded. These are the structs, from the interface crate:
pub const NO_FEE_OVERRIDE: u16 = u16::MAX;
pub const MAX_FEE_BPS: u16 = 5000;
pub struct SwapParams {
pub sender: Pubkey,
pub a_to_b: bool,
pub amount_in: u64, // input still going to the curve
pub reserve_a: u64,
pub reserve_b: u64,
pub fee_bps: u16, // the pool's static fee
pub protocol_fee_share_bps: u16,
pub timestamp: i64,
}
pub struct BeforeSwapResult {
pub fee_override_bps: u16, // NO_FEE_OVERRIDE or ≤ MAX_FEE_BPS
pub take_in: u64, // needs RETURNS_DELTA
pub give_out: u64, // needs RETURNS_DELTA
pub in_recipient: u8, // slice index (≥ 3) receiving take_in
}
pub struct AfterSwapParams {
pub sender: Pubkey,
pub a_to_b: bool,
pub amount_in: u64, // the user's curve input, fee included
pub amount_out: u64, // the user's curve output
pub fee_bps: u16, // effective fee of this swap
pub protocol_fee_share_bps: u16,
pub reserve_a_before: u64,
pub reserve_b_before: u64,
pub reserve_a: u64,
pub reserve_b: u64,
pub timestamp: i64,
}
pub struct HookTrade {
pub amount_in: u64, // deposited during the call
pub a_to_b: bool,
pub min_out: u64,
pub out_recipient: u8, // slice index (≥ 3) receiving the output
}
pub struct AfterSwapResult {
pub trades: Vec<HookTrade>, // at most 4 (needs RETURNS_DELTA)
}
// before_*: state before the change; after_*: state after it
pub struct LiquidityParams {
pub owner: Pubkey,
pub amount_a: u64,
pub amount_b: u64,
pub lp_amount: u64,
pub reserve_a: u64,
pub reserve_b: u64,
pub lp_supply: u64,
pub timestamp: i64,
}- Set nothing and the call is a no-op: no fee override, no delta, no trades.
- Return data counts only if your program set it. Call
set_return_datalast, after any cross-program call your hook makes. - The AMM decodes return data only where it can use it:
before_swapof a module with Dynamic fee or Returns delta, andafter_swapof a module with Returns delta. There, anything other than exactly one borsh-encoded result reverts the swap; everywhere else return data is ignored. - A fee override above 5000 bps reverts the swap.
6. Extra accounts#
- Your hook instruction receives
[hook_auth, pool, module, ...extra]. The extras come from your slice of the AMM’sremaining_accountsat the same positions: slice index 3 is instruction account 3. - Whoever builds the transaction passes them. The SDK’s
moduleSlice(module, pool, ctx)knows the built-ins; for your module, publish its slice layout so wallets and routers can build it. - Extras keep the writable flag they were given but never arrive as signers. Don’t design a hook that needs a signature.
in_recipientandout_recipientare slice indices and must be 3 or higher.
7. Moving tokens with Returns delta#
A module with Returns delta can trade next to the user. It never pulls from the vaults: it deposits first and reports after, and the AMM checks the balances.
- before_swap: transfer
give_outof the output mint into the pool’s output vault during the call, then return it. The AMM checks that the vault grew by at least that much.take_inof the input goes from the input vault toslice[in_recipient]and may not exceed the input still remaining. - after_swap: deposit each trade’s
amount_ininto that trade’s input vault during the call (checked by balance difference, summed per side) and return up to fourHookTrades. The AMM runs each as a normal curve trade at the swap’s fee, requiresout ≥ min_out, and sends the output toslice[out_recipient]. - A vault balance must never go down during your call. If it does, the swap fails.
// before_swap with RETURNS_DELTA: deposit first, then report it.
// 1. move give_out of the OUTPUT mint into the pool's output vault,
// signed by your own PDA
// 2. return how much input you take and where it goes
let result = BeforeSwapResult {
fee_override_bps: NO_FEE_OVERRIDE,
take_in: 0,
give_out: deposited,
in_recipient: 3, // first extra account of your slice
};
set_return_data(&borsh::to_vec(&result)?);Move tokens from accounts your own program controls, such as an escrow PDA, with transfer_checked.
8. Mine the address and create the module#
Find a nonce whose PDA ends in your flags byte, then create the module account at it:
import { PublicKey } from '@solana/web3.js';
/** Grind nonces until the PDA's last byte equals `flags` (~256 tries on average). */
export function mineModule(programId: PublicKey, flags: number, from = 0n) {
const seed = Buffer.alloc(8);
for (let nonce = from; ; nonce++) {
seed.writeBigUInt64LE(nonce);
const seeds = [Buffer.from('module'), seed];
const [address] = PublicKey.findProgramAddressSync(seeds, programId);
if (address.toBytes()[31] === flags) return { address, nonce };
}
}
const { address, nonce } = mineModule(SIZE_FEE_PROGRAM_ID, 0x41);import { BN } from '@coral-xyz/anchor';
// threshold 1% of the input reserve, large trades pay 1.00%
await sizeFee.methods
.createModule(new BN(nonce.toString()), 100, 100)
.accountsPartial({ payer: wallet.publicKey, module: address })
.rpc();create_module checks the last byte again, so a wrong nonce fails before anything is created.
9. Put it in a pool#
import { buildCreatePoolTx } from '@patchbay/sdk';
// your module goes in the patch like any built-in one
const tx = await buildCreatePoolTx({ mintA, mintB, feeBps: 30, modules: [address] });create_pool checks that the module account is owned by your program, that the program is executable, that the module account is not itself a program, and that its flags are valid. If your module needs per-pool state, put your init instruction in the same transaction, right after create_pool, as the SDK does for the built-ins.
10. Test it locally#
AMM=PbZkjLcF3Ghc6MGndLYZdXDwhbuv8YCuqZeQA8SbRgs
MODULES=Pm4xyzcCmLhbx7CuqbqTgkCz6WYq6L7A6kVKfH8FzUN
# fetch the deployed programs once
solana program dump -u devnet $AMM patchbay.so
solana program dump -u devnet $MODULES patchbay_modules.so
# the AMM must be upgradeable with your key as upgrade authority:
# only that key can sign initialize_config
solana-test-validator --reset \
--upgradeable-program $AMM patchbay.so ~/.config/solana/id.json \
--bpf-program $MODULES patchbay_modules.so \
--bpf-program <YOUR_PROGRAM_ID> target/deploy/size_fee.soThen call initialize_config once, signed by that upgrade-authority key, which becomes the local admin. Create two test mints, a pool with your module and some liquidity, and swap through it. Test the failure paths as well:
- calling your hook instruction directly must fail;
- passing a different module’s hook authority must fail;
- a swap must still respect the user’s minimum output with your module in the patch.
11. Get listed#
The AMM accepts any module whose address carries valid flags; a listing is not needed to use one. The registry entry, ["listing", module], is set by the admin with set_listing and decides which modules the app’s module list shows by name. For now the registry is curated by the Patchbay admin.
Checklist#
- Every hook instruction authenticates the caller before doing anything else.
- Only the bits you need. No hook point you don’t use.
- Return data set last, borsh-encoded, within the limits above.
- Params fixed at creation. LPs and traders choose a pool by its modules; keep their rules stable.
- No hook that needs a signer.
- Light on compute: the SDK requests 400,000 compute units for a whole swap, shared by up to four modules.
- Your slice layout published next to your program ID.
Reference#
The built-in modules in programs/patchbay-modules and the interface in crates/patchbay-interface are the reference implementation of everything on this page.