How it works
A pool is defined by two mints, a fee and a patch. This page covers the pool key, the eight permission bits, and the exact order in which the AMM runs the curve and calls modules.
The pool key#
Every pool is a PDA of the AMM program. Its seeds are the two mints, the static fee and a hash of the patch:
// AMM program: PbZkjLcF3Ghc6MGndLYZdXDwhbuv8YCuqZeQA8SbRgs
pool = PDA([
"pool",
mint_a, // mint_a < mint_b, raw 32-byte comparison
mint_b,
fee_bps.to_le_bytes(), // u16, 0..=1000
patch_hash, // sha256(module_0 || module_1 || ...)
]) // empty patch: sha256("")mint_a < mint_bby raw 32-byte comparison, so a pair has one order. The SDK sorts for you.patch_hashis the SHA-256 of the module account addresses, concatenated in patch order. An empty patch hashes the empty string. Order matters: Dynamic fee, then Limit order and Limit order, then Dynamic fee are two pools that run their modules in two different orders.fee_bpsis the static LP fee, from 0 to 1000 (0–10%). If any module has Dynamic fee, it is the default used when no module overrides it.- Same mints, fee and patch means the same pool. A patch is immutable: nobody can add, remove or reorder the modules of an existing pool.
Derive it yourself#
import { PublicKey } from '@solana/web3.js';
import { sha256 } from '@noble/hashes/sha2';
const PATCHBAY = new PublicKey('PbZkjLcF3Ghc6MGndLYZdXDwhbuv8YCuqZeQA8SbRgs');
/** Pool for two mints, a static fee and an ordered list of module accounts. */
export function poolAddress(
x: PublicKey,
y: PublicKey,
feeBps: number,
modules: PublicKey[],
) {
const [a, b] = Buffer.compare(x.toBuffer(), y.toBuffer()) < 0 ? [x, y] : [y, x];
const fee = Buffer.alloc(2);
fee.writeUInt16LE(feeBps);
const patchHash = sha256(Buffer.concat(modules.map((m) => m.toBuffer())));
const seeds = [Buffer.from('pool'), a.toBuffer(), b.toBuffer(), fee, patchHash];
return PublicKey.findProgramAddressSync(seeds, PATCHBAY)[0];
}Accounts#
| Account | Seeds | What it holds |
|---|---|---|
| Config | ["config"] | Admin, pending admin, treasury, and the protocol’s share of the LP fee (at most 2500 bps). |
| Pool | ["pool", mint_a, mint_b, fee_bps, patch_hash] | Mints and their token programs, vaults, LP mint, fee, the patch (modules, their programs and flags), reserves, protocol fees, LP supply, creator. |
| Vault | ["vault", pool, mint] | One token account per side. Its authority is the pool PDA. |
| LP mint | ["lp", pool] | 9 decimals. Its authority is the pool PDA. |
| Hook authority | ["hook_auth", pool, module] | A data-less PDA, one per module of the pool. The AMM signs with it when it calls that module. It owns nothing and has no authority over the vaults. |
| Listing | ["listing", module] | A registry entry with the module’s name, set by the admin. |
reserve_a and reserve_b are accounting reserves. They exclude protocol fees and anything sent to a vault directly, so a donation to a vault does not move the price.
The faceplate: permission flags#
A module’s permissions are not stored in any account. They are the last byte of the module account’s address: flags = address[31]. Each of the eight bits is a jack on its faceplate.
| Bit | Mask | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 | BEFORE_SWAP | Called before the curve runs. |
| 1 | 0x02 | AFTER_SWAP | Called after the curve, before the user is paid. |
| 2 | 0x04 | BEFORE_ADD | Called before liquidity is added. May revert to refuse. |
| 3 | 0x08 | AFTER_ADD | Called after liquidity is added. |
| 4 | 0x10 | BEFORE_REMOVE | Called before liquidity is removed. May revert to refuse (lockups). |
| 5 | 0x20 | AFTER_REMOVE | Called after liquidity is removed. |
| 6 | 0x40 | DYNAMIC_FEE | Its before_swap may return a fee override. |
| 7 | 0x80 | RETURNS_DELTA | Its before_swap may take part of the input or give output; its after_swap may return its own trades. |
Plug jacks in and out to see the byte, and what the AMM will call:
- swapbefore_swap · after_swap
- add_liquiditynot called
- remove_liquiditynot called
Its before_swap may override the fee.
Rules the AMM enforces#
flags == 0is invalid.DYNAMIC_FEEneedsBEFORE_SWAP.RETURNS_DELTAneedsBEFORE_SWAPorAFTER_SWAP.- The AMM never calls a hook point whose bit is not set, and ignores fee overrides and deltas from modules without the matching bit.
Mining an address#
Module programs derive module accounts as PDAs with a nonce, and grind the nonce until the last byte equals the wanted flags. That takes about 256 tries on average, a fraction of a second in a browser. The SDK ships mineModuleAddress(flags), and Create mines one live.
A swap, step by step#
swap(amount_in, min_amount_out, a_to_b, hook_account_counts) is exact input. Inside one instruction:
amount_inmoves from the user to the input vault.- Each module with Before swap runs, in patch order. With Dynamic fee it may return a fee override (at most 5000 bps). With Returns delta it may take part of the remaining input, or put output into the output vault itself.
- The fee is the highest override, or the pool’s fee_bps if no module returned one.
- The curve prices what is left of the input.
- Each module with After swap runs, in patch order. With Returns delta it may return up to four trades, which the AMM executes as ordinary curve trades at the same fee. This is how limit orders fill.
- The user receives the curve output plus any output given by modules. It must be at least
min_amount_out. - The AMM checks
vault ≥ reserve + protocol feeson both sides and emitsSwapEvent.
remaining = amount_in // already moved user → vault_in
for m in patch where m has BEFORE_SWAP:
r = m.before_swap(SwapParams)
if DYNAMIC_FEE and r.fee_override_bps != u16::MAX:
overrides.push(r.fee_override_bps) // each ≤ 5000
if RETURNS_DELTA and (r.take_in > 0 or r.give_out > 0):
remaining -= r.take_in // vault_in → slice[r.in_recipient]
give_out += r.give_out // already deposited into vault_out
fee = max(overrides) or pool.fee_bps
fee_amt = ceil(remaining * fee / 10000)
in_eff = remaining - fee_amt
out = floor(reserve_out * in_eff / (reserve_in + in_eff))
protocol = floor(fee_amt * protocol_fee_share_bps / 10000)
for m in patch where m has AFTER_SWAP:
r = m.after_swap(AfterSwapParams)
if RETURNS_DELTA:
run r.trades (≤ 4) as ordinary curve trades at the same fee
user_out = out + give_out // require user_out ≥ min_amount_outWorked example#
The example pool: 4,000 SOL and 600,000 USDC, a 0.30% fee, an empty patch and no protocol share. Someone swaps 10 SOL for USDC.
| Step | Value | How |
|---|---|---|
amount_in | 10.00 SOL | moved to the SOL vault |
fee | 30 bps | empty patch, so the pool’s fee_bps |
fee_amt | 0.03 SOL | ceil(10 × 30 / 10000), stays in the pool |
in_eff | 9.97 SOL | what the curve prices |
out | 1,491.78 USDC | floor(600,000 × 9.97 / 4,009.97) |
reserves after | 4,010.00 SOL · 598,508.22 USDC | price 149.25 USDC per SOL |
Load a Dynamic fee module and the same swap pays the module’s fee instead; load a Limit order module and resting orders can fill right after it, against the moved price. Built-in modules has the formulas.
Adding and removing liquidity#
add_liquidity(amount_a_max, amount_b_max, min_lp, hook_account_counts)
Order: Before add modules → transfer in → mint LP → After add modules.
remove_liquidity(lp_amount, min_a, min_b, hook_account_counts) pays floor(lp · r / S) of each side. Order: Before remove modules → burn → transfer out → After remove modules. A Before remove module may revert to refuse; that is how Lockup works.
How the AMM calls a module#
Every hook point is a cross-program call from the AMM into the module’s program. The accounts for every module called by an instruction ride in its remaining_accounts, one slice per module:
// one slice per module with a bit relevant to this instruction, in patch order
remaining_accounts = slice_0 || slice_1 || ...
hook_account_counts = [len(slice_0), len(slice_1), ...]
slice_i = [
module program, // 0 == pool.module_programs[i]
module account, // 1 == pool.modules[i]
hook authority, // 2 == PDA(["hook_auth", pool, module], AMM)
...extra accounts, // 3.. module-specific, in the order the module expects
]
// what the module's hook instruction receives (index ≥ 3 = same as the slice)
[hook_auth (signer), pool (read-only), module (read-only), ...slice_i[3..]]hook_account_countshas one entry per module with any bit relevant to the instruction, in patch order. Swap counts Before and After swap; add counts Before and After add; remove counts Before and After remove. A module’s before and after calls share one slice.- Before invoking, the AMM checks
slice[0],slice[1]andslice[2]against the program, module and hook authority stored in the pool. - It calls with
invoke_signed, signing only for that module’s hook authority. Every forwarded account hasis_signer = false: a module never receives the user’s, the payer’s or the pool’s signature. - Instruction data is the 8-byte Anchor discriminator
sha256("global:<name>")[..8]followed by the borsh-encoded params. The six names arebefore_swap,after_swap,before_add_liquidity,after_add_liquidity,before_remove_liquidityandafter_remove_liquidity. - The result comes back as return data. It counts only if the module program set it; empty return data means no change.
- Solana forbids A → B → A re-entrancy, so a module cannot call back into the AMM.
The param and result structs are on Write a module.
Supported tokens#
- SPL Token and Token-2022. Every transfer uses
transfer_checked. - Token-2022 mints are accepted only with these extensions: MetadataPointer, TokenMetadata, GroupPointer, TokenGroup, GroupMemberPointer, TokenGroupMember and MintCloseAuthority. Anything else, such as TransferFee, TransferHook, PermanentDelegate, DefaultAccountState, NonTransferable or ConfidentialTransfer, is rejected at
create_pool. - Native SOL trades as WSOL. The SDK wraps and unwraps it in the same transaction.