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.

module accountRust
// 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 permissionless init_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 after create_pool; the caller pays the rent.
  • Every hook instruction checks that hook_auth signed 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#

ModuleJacksHook points called
Dynamic fee 0x43before_swap, after_swap
TWAP oracle 0x01before_swap
Limit order 0x82after_swap
Lockup 0x10before_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
ParamMeaning
base_fee_bpsThe fee when the average move is zero.
fee_per_moveBasis 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_bpsThe cap on the returned fee. Overrides are also capped by the AMM at 5000 bps.
half_life_secsEvery full half-life halves the average move; in between it falls linearly.
before_swap a = avg / 2^⌊Δt / h⌋
avg = a − a · (Δt mod h) / (2h)
fee = min(max_fee_bps, base_fee_bps + avg · fee_per_move / 100)
after_swap avg = avg + |p1 − p0| / p0; last_ts = now
Δt = now − last_ts and h = half_life_secs: the average halves for every full half-life and falls linearly in between. One swap’s move is capped at 100× the price. avg_move is stored in units of 1e-6 bps (MOVE_SCALE), so small moves still count; the fee formula reads it in bps and rounds down. p0 is the price from the reserves before the user’s curve trade, p1 from the reserves when the module runs, after any hook trades of modules patched before it. The fee goes back to the AMM as a fee override: the pool’s own fee_bps applies only if no module overrides it, and with several Dynamic fee modules the highest fee wins.
0.62%62 bps fee on the next swap

Example params: base 30 bps, fee_per_move 50, max 100 bps, half-life 300 s

a = 80 / 2^0 = 80.0 · avg = a − a × 120 / (2 × 300) = 64.0 bps
fee = min(100, 30 + 64.0 × 50 / 100) = 62 bps

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
tick = floor(log_1.0001(reserve_b / reserve_a)) // before the swap
each swap with now > last_ts:
tick_cumulative += tick · (now − last_ts) // ring of 64
mean_tick = Δ tick_cumulative / Δ ts // over the window
price ≈ 1.0001 ^ mean_tick // B atoms per A atom
  • 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).
read itPseudo-code
// TWAP oracle module: any program can call it via CPI
consult(window_secs) -> { mean_tick: i64, observations: u16 }  // return data

Limit 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_order reverts with BookFull.
  • In after_swap only 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 least MIN_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 = 10000 and B = D − fee, (rb/ra) · D/B < L for buy-A and (rb/ra) · B/D > L for 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 simulated min_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_state stores unlock_at = now + lock_secs. Until then, before_remove_liquidity reverts with Locked.
  • 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_liquidity calls Lockup; add_liquidity calls nothing.
  • With several fee overrides the highest wins.
  • If any module reverts, the whole transaction reverts.

Want something else? Write a module.