Built-in modules
Four modules ship with Patchbay, all in one program. Anyone can create a module from them with their own parameters, then plug it into a pool.
Module accounts#
A built-in module is an account of the patchbay-modules program. Its program ID is on Addresses.
// PDA(["module", nonce.to_le_bytes()], patchbay-modules) nonce: u64
Module {
kind: u8, // 0 dynamic fee · 1 twap · 2 limit order · 3 lockup
flags: u8, // == last byte of this account's address
authority: Pubkey,
params: [u8; 64], // kind-specific, immutable
nonce: u64,
bump: u8,
created_at: i64,
}create_module(nonce, kind, params)is permissionless. It requires the PDA’s last byte to equal the kind’s flags exactly, so the nonce is mined first. Create does this for you.- Params are immutable, like the constructor arguments of a deployed contract. Different params mean a different module.
- Per-pool state lives in
["state", module, pool], created by the permissionlessinit_pool_state(). It checks that the pool is owned by the Patchbay AMM and that the module is in its patch. The SDK adds it to the pool-creation transaction right aftercreate_pool; the caller pays the rent. - Every hook instruction checks that
hook_authsigned and is the AMM’s hook authority for this pool and module, that the pool is owned by the AMM, that the module is in the pool’s patch, and that the state PDA matches.
Jacks used by the built-ins#
| Module | Jacks | Hook points called |
|---|---|---|
| Dynamic fee | 0x43 | before_swap, after_swap |
| TWAP oracle | 0x01 | before_swap |
| Limit order | 0x82 | after_swap |
| Lockup | 0x10 | before_remove_liquidity |
Dynamic fee#
Raises the fee after the price moves, then lets it fall back as the market calms down.
- Kind
- 0
- Flags
- 0x43 · Before swap · After swap · Dynamic fee
- Params
- base_fee_bps u16 · fee_per_move u16 · max_fee_bps u16 · half_life_secs u32
| Param | Meaning |
|---|---|
base_fee_bps | The fee when the average move is zero. |
fee_per_move | Basis points of fee added per 1 bps of average move, ×1/100. A value of 50 adds 0.5 bps of fee per bps of move. |
max_fee_bps | The cap on the returned fee. Overrides are also capped by the AMM at 5000 bps. |
half_life_secs | Every full half-life halves the average move; in between it falls linearly. |
Example params: base 30 bps, fee_per_move 50, max 100 bps, half-life 300 s
TWAP oracle#
Keeps a time-weighted average price for the pool that any program can read. It never changes a swap.
- Kind
- 1
- Flags
- 0x01 · Before swap
- Params
- none
- Observations are
{ ts: i64, tick_cumulative: i128 }in a ring of 64 per pool. At most one is written per second, and it records the price that held until this swap, before the swap moves it. - Any program can call
consult(window_secs); the answer comes back as return data. The longest window it can answer is the time covered by the last 64 observations, so it depends on how often the pool trades. - The price is in atoms. For a UI price multiply by
10^(decimals_a − decimals_b).
// TWAP oracle module: any program can call it via CPI
consult(window_secs) -> { mean_tick: i64, observations: u16 } // return dataLimit order#
A resting order book inside the pool. Orders fill right after a swap moves the price across them, inside the same transaction, with no keeper.
- Kind
- 2
- Flags
- 0x82 · After swap · Returns delta
- Params
- none
- Each pool gets an order book with 64 slots (zero-copy). Prices are B atoms per A atom as Q64.64.
place_order(side, price_q64, amount)escrows tokens in hook vault PDAs["hook_vault", state, mint].cancel_order(id)returns what is unfilled plus the proceeds;claim(id)takes the proceeds so far.- Orders start at
MIN_ORDER_ATOMS(1,000,000 atoms), counted both in the input mint and in output atoms at the limit. The capital stays locked and is fully refundable, so filling the book costs a squatter real money. When all 64 slots are used,place_orderreverts withBookFull. - In
after_swaponly the side the swap moved towards is considered: buy-A orders after an A → B swap, sell-A orders after a B → A swap. An order is a candidate if its remaining escrow, and that escrow’s value at its limit, are both at leastMIN_FILL_ATOMS(10,000). - Candidates are taken best price first (ties: oldest). An order is marketable while the marginal price including the fee is strictly on its side of the limit: with
D = 10000andB = D − fee,(rb/ra) · D/B < Lfor buy-A and(rb/ra) · B/D > Lfor sell-A. - Its fill is the input that brings that marginal price back to the limit, capped by the escrow. The module verifies it exactly with the shared curve math: the price has not crossed the limit (else it shrinks the fill by binary search), the average price is at the limit or better, and the input is at least
MIN_FILL_ATOMS. Proceeds are credited from the simulatedmin_out. - The scan stops at the first candidate that cannot fill, because worse-priced orders cannot be more marketable. At most four fills per swap; each one chains on the reserves the previous one left.
- If either hook vault is frozen by its mint’s freeze authority, the module fills nothing for that swap instead of reverting it.
- Fills are ordinary curve trades at the swap’s fee, so LPs earn the fee on them too.
Lockup#
Refuses liquidity removal until an unlock time. Adding liquidity and swapping are never blocked.
- Kind
- 3
- Flags
- 0x10 · Before remove liquidity
- Params
- lock_secs u32
init_pool_statestoresunlock_at = now + lock_secs. Until then,before_remove_liquidityreverts withLocked.- There is one unlock time per pool. It starts when the module’s state for that pool is created, normally in the pool-creation transaction, and applies to every LP position in the pool, including ones added later.
Combining modules#
- The patch order is the call order, and it is part of the pool key.
- All four fit in one pool. With the patch Dynamic fee, TWAP oracle, Limit order, Lockup, a swap calls Dynamic fee and TWAP before the curve, then Dynamic fee and Limit order after it;
remove_liquiditycalls Lockup;add_liquiditycalls nothing. - With several fee overrides the highest wins.
- If any module reverts, the whole transaction reverts.
Want something else? Write a module.