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:

pool addressPseudo-code
// 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_b by raw 32-byte comparison, so a pair has one order. The SDK sorts for you.
  • patch_hash is 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_bps is 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#

pool-address.tsTypeScript
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#

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

BitMaskNameMeaning
00x01BEFORE_SWAPCalled before the curve runs.
10x02AFTER_SWAPCalled after the curve, before the user is paid.
20x04BEFORE_ADDCalled before liquidity is added. May revert to refuse.
30x08AFTER_ADDCalled after liquidity is added.
40x10BEFORE_REMOVECalled before liquidity is removed. May revert to refuse (lockups).
50x20AFTER_REMOVECalled after liquidity is removed.
60x40DYNAMIC_FEEIts before_swap may return a fee override.
70x80RETURNS_DELTAIts 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:

0x430100 0011
  • swapbefore_swap · after_swap
  • add_liquiditynot called
  • remove_liquiditynot called

Its before_swap may override the fee.

Dynamic fee modules end in 0x43.Mine an address ending in 0x43

Rules the AMM enforces#

  • flags == 0 is invalid.
  • DYNAMIC_FEE needs BEFORE_SWAP. RETURNS_DELTA needs BEFORE_SWAP or AFTER_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:

  1. amount_in moves from the user to the input vault.
  2. 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.
  3. The fee is the highest override, or the pool’s fee_bps if no module returned one.
  4. The curve prices what is left of the input.
  5. 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.
  6. The user receives the curve output plus any output given by modules. It must be at least min_amount_out.
  7. The AMM checks vault ≥ reserve + protocol fees on both sides and emits SwapEvent.
swapPseudo-code
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_out

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

StepValueHow
amount_in10.00 SOLmoved to the SOL vault
fee30 bpsempty patch, so the pool’s fee_bps
fee_amt0.03 SOLceil(10 × 30 / 10000), stays in the pool
in_eff9.97 SOLwhat the curve prices
out1,491.78 USDCfloor(600,000 × 9.97 / 4,009.97)
reserves after4,010.00 SOL · 598,508.22 USDCprice 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)

first deposit lp = isqrt(a · b) − 1000
later lp = floor(min(a · S / ra, b · S / rb))
pulled = ceil(lp · r / S) per side, at most the max
S is the LP supply, ra and rb the reserves. The first deposit must give lp > 0, and lp_supply starts at lp + 1000: those 1000 units are counted but never minted, so they stay locked forever.

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:

module slicesPseudo-code
// 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_counts has 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] and slice[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 has is_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 are before_swap, after_swap, before_add_liquidity, after_add_liquidity, before_remove_liquidity and after_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.