Loading the docs
01 Overview02 Programs03 Hooks04 Pools05 Fees
Carpenter, in one page
What the programs do, and what this site reads from them.
The first Solana AMM with Uniswap v4-style hooks
01 — What Carpenter is
Carpenter is one program, carpenter_amm, holding pools, positions and vaults. A pool may name a hook program; the AMM calls it at the same phases Uniswap v4 does, and a hook must be approved before any pool can use it.
Every pool is initialize_pool(fee, tick_spacing, hook, sqrt_price). A pool with hook set is a hooked pool; the flags, rounds and callback slice are copied from the approved-hook record into the Pool at that moment and never re-read.
The flag bits sit at Uniswap v4's Hooks.sol positions, so a v4 author reads the interface without a translation table. One bit is Solana-only: REQUESTS_ACTIONS, which lets a hook hand actions back to the AMM after a swap.
The approve_hook instruction writes an ApprovedHook account per hook program — Anchor init, so it can run once — and only AmmConfig.authority may sign it. Today that authority is the deployer key.
Devnet: carpenter_amm at HGmPMk36hRTg7H2gKKSszKKdx5Ry7JyrEmU8KEA2iVXs; the FLOOR hook at AA9gZsej…LnF9 (the §49 fresh program set of 2026-10-05, which re-keyed the AMM too). One address lookup table per deployment — devnet's is CYU9DQCL…SML, 34 addresses, and it reads on chain as still writable: its authority is the deployer, and no freeze was run. A single deployer key holds every authority; the hand-over to the upgrade timelock has not been run.
02 — Pools and positions
A pool has a fee, a tick spacing and a current sqrt_price; a position is a range of ticks holding liquidity. What the pages read is the pool account, the position accounts, and the approved-hook records.
A hooked pool needs at least one flag or a dynamic fee (DYNAMIC_FEE_FLAG, 0x800000), else FeeInvalid. A hook whose header carries zero flags is legal and usable only with dynamic-fee pools.
A hook's own positions are PDAs of ["position", pool, hook_authority, salt]. They must exist before an action names them; a missing account fails the action closed.
Per-pool set_pool_paused stops swaps and deposits into that pool. It is a different switch from the hook-level pause in §04, which touches new pools only.
The hook directory is one getProgramAccounts over the APPROVED_HOOK discriminator. A pool page compares the pool's hook field with the deployment's FLOOR hook id — never a name, never a URL.
03 — Hooks: the interface
A hook program publishes its terms in a header the AMM reads at approval and re-checks per pool. Everything the AMM will ever ask of the hook follows from those eight bytes.
A PDA of ["hook_config"] under the hook program, owned by it, any size ≥ 16 bytes. Bytes 0..8 are an Anchor discriminator the AMM never checks; bytes 8..16 are the header. FLOOR's account is 240 bytes — the discriminator over a 232-byte layout — and the mock's 136, space = 8 + 128.
// carpenter-types · layout.rs:388-412 — #[repr(C)] Pod, exactly 8 bytes, read at offset 8version u8 // must be HOOK_INTERFACE_VERSION = 1bump u8 // the PDA bump; informationalmax_rounds u8 // ≤ MAX_ROUNDS_CAP = 4; ≥ 2 if AFTER_SWAP is setcallback_accounts u8 // length of the account slice handed to every callbackflags u16 // little-endian; bit 15 must be 0authority_bump u8 // bump of ["hook_authority"]; the AMM re-derives itpad0 u8 // unchecked — the AMM never reads it (approve_hook.rs:101-114)
Fourteen at v4's positions, one Solana-only. ALL_HOOK_FLAGS = 0x7FFF.
| bit | flag | bit | flag |
|---|---|---|---|
| 14 | REQUESTS_ACTIONS (Solana-only) | 6 | AFTER_SWAP |
| 13 | BEFORE_INITIALIZE | 5 | BEFORE_DONATE |
| 12 | AFTER_INITIALIZE | 4 | AFTER_DONATE |
| 11 | BEFORE_ADD_LIQUIDITY | 3 | BEFORE_SWAP_RETURNS_DELTA |
| 10 | AFTER_ADD_LIQUIDITY | 2 | AFTER_SWAP_RETURNS_DELTA |
| 9 | BEFORE_REMOVE_LIQUIDITY | 1 | AFTER_ADD_LIQUIDITY_RETURNS_DELTA (refused) |
| 8 | AFTER_REMOVE_LIQUIDITY | 0 | AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA (refused) |
| 7 | BEFORE_SWAP | 15 | must be 0 |
Scroll for more →
| rule | error | where |
|---|---|---|
any bit outside 0x7FFF | HookFlagsInvalid | fees.rs:58 |
BEFORE_SWAP_RETURNS_DELTA without BEFORE_SWAP | HookFlagsInvalid | fees.rs:61 |
AFTER_SWAP_RETURNS_DELTA without AFTER_SWAP | HookFlagsInvalid | fees.rs:64 |
AFTER_ADD_LIQUIDITY_RETURNS_DELTA without AFTER_ADD_LIQUIDITY | HookFlagsInvalid | fees.rs:67 |
AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA without AFTER_REMOVE_LIQUIDITY | HookFlagsInvalid | fees.rs:70 |
REQUESTS_ACTIONS without AFTER_SWAP — actions only come back from after_swap / after_actions | HookFlagsInvalid | fees.rs:74 |
either liquidity-phase RETURNS_DELTA bit set at all — the record refuses liquidity-phase deltas, so those bits would never pay | HookFlagsInvalid | fees.rs:78 · hook.rs:522-531 |
allow_public_pools == false without BEFORE_INITIALIZE — the AMM never enforces private pools itself; only the hook's before_initialize can refuse a stranger | HookFlagsInvalid | approve_hook.rs:106, :11-13 |
max_rounds above 4 | RoundsTooHigh | approve_hook.rs:113 |
AFTER_SWAP with max_rounds below 2 | HookFlagsInvalid | approve_hook.rs:114 |
header version, max_rounds or callback_accounts not equal to the terms passed to approve_hook | HookHeaderMismatch | approve_hook.rs:107-112 |
Scroll for more →
A swap runs before_swap as round 0, after_swap as round 1, and after_actions as rounds 2 and 3 — only when the hook asked for them. max_rounds caps the loop; a want_callback that would pass it is HookRoundsExceeded.
Every callback is a CPI from the AMM: program = pool.hook, accounts = the pool PDA (read-only, a signer) followed by the first callback_accounts of the client's hook slice, with the client's writability and never as signers. Any other slice length is HookSliceLengthMismatch; a slice that aliases the pool, a vault, the bitmap, a mint, a tick array, the trader, either of the trader's two token accounts or the event authority is HookSliceAliasesAmmAccount. The hook must require the pool signed.
Instruction data is sha256("global:<name>")[..8] followed by the Borsh-encoded args; the eleven names and their bytes are listed in one file. Every args struct starts version, phase, round; phases run 0..10; hook_data is at most 64 bytes.
The hook answers with set_return_data(borsh(HookRecordV1)): at most 1,024 bytes, strict decode, no trailing bytes, read back as the CPI returns and only from pool.hook, non-empty — otherwise HookBadReturn. It echoes version, phase and round. What it may carry depends on the phase: a fee override only in before_swap (≤ 1,000,000 pips, applied only on a dynamic-fee pool with BEFORE_SWAP); a specified-side delta only in before_swap under BEFORE_SWAP_RETURNS_DELTA; an unspecified-side delta in before_swap under that flag or in after_swap under AFTER_SWAP_RETURNS_DELTA; nothing in after_actions. Its four fees [skim, creator, holder, platform] must explain that one delta — skim + creator + holder ≤ |delta|, platform ≤ skim — or the record is HookFeesInconsistent. Actions (≤ 8, ModifyPosition or Swap) and want_callback come only from after_swap / after_actions and only with REQUESTS_ACTIONS. A delta may not flip the specified side; v1 accepts only non-negative hook deltas (HookDeltaNegative); the trader's side stays in ≤ 0, out ≥ 0.
A hook acts on the AMM through ["hook_authority"], a PDA it derives and the AMM re-derives at approval. modify_liquidity, swap, hook_deposit and hook_withdraw take that signer and settle against claims; register_mint_exclusivity and update_dynamic_lp_fee take it too, with no claims settlement. revoke_mint_exclusivity is not the hook's at all: it is signed by the CONFIG authority alone (authority == config.authority, else Unauthorized) and closes the entry to that authority. A hook-authority swap skips every callback. hook_withdraw's destination is the hook's own responsibility to check. Exclusivity needs allow_exclusivity == 1 on the approval.
The AMM records it and the site reads and prints it, but nothing acts on it: the AMM never refuses a stranger's pool, and /pool/new offers no hook at all — a Carpenter pool is hookless by construction, and hooklessChoice drops every offerable hook unasked. If the hook wants private pools it must set BEFORE_INITIALIZE and refuse in that callback — which is why the rule above insists on the bit.
04 — The lifecycle
Deploy under loader-v3 (upgradeable, or final with no authority) → the hook's own init writes the header at ["hook_config"] → apply → the config authority signs approve_hook(max_rounds, callback_accounts, allow_public_pools, allow_exclusivity, name) → pools. Anchor's account validation runs first: init on ["approved_hook", program] refuses an already-approved program before any handler check (devnet smoke: System custom program error: 0x0). Then the handler checks, in order: executable and its ProgramData pointer (HookNotExecutable); the ProgramData account and its slot (HookNotExecutable); the header decodes (HookHeaderMismatch); hook_flags_valid; public-pools-or-BEFORE_INITIALIZE; version and the two terms equal the header (HookHeaderMismatch); rounds ≤ 4 (RoundsTooHigh); AFTER_SWAP ⇒ rounds ≥ 2. Then it derives hook_authority, writes the record and emits HookApproved.
The record must exist and match (HookNotApproved), the hook must not be paused for new pools (HookPausedForNewPools), and the ProgramData slot must still equal the approved one (HookUpgradedReapprove); then the fee rule and mint exclusivity (MintExclusive).
Upgrading moves ProgramData.slot, so new pools are refused until the authority signs reapprove_hook. The header must still equal the stored flags, rounds, callback count and version — a changed interface is a different hook, and there is no instruction to re-term. Only the two slots move; paused is untouched. Live pools keep trading on the upgraded code from the upgrade's own slot; approval never gates a swap on an existing pool.
set_hook_paused(bool) blocks new pools on the hook and nothing else. There is no revoke.
It vouches that the program is a loader-v3 executable, that its header is well-formed and agrees with the stated terms, that the flags are a legal combination, and that the code at one ProgramData slot was blessed. It does not hash or review the program bytes; it does not gate reads or swaps on live pools after an upgrade; it does not enforce allow_public_pools; and it does not bound what a hook returns beyond the record rules — a hook may take up to the whole swap amount as its delta. A trust boundary, not an audit. name is cosmetic, for frontends.
05 — Building a hook
tests/programs/mock_hook implements all eleven callbacks against the real types. About 350 of its 788 lines — some 45%, the four ranges row 02 names, measured — are test scripting; the rest is the hook you would write anyway.
The account shapes; the 128-byte HookConfig at ["hook_config"] and initialize_hook_config(flags, max_rounds, callback_accounts) writing the header fields, with authority_bump from find_program_address(["hook_authority"], ID); the pool_signer.is_signer check in every callback; forward, a hook-initiated CPI signed by ["hook_authority"]; and the From<TypesRecord> twin — note fees must be set in place.
MockState, MockScript, set_reported_fees and the return-mode scripting are test rigging. The pass-through callbacks echo the three-byte prefix via HookRecordV1::pass_through using a non-strict Anchor decode; the three swap callbacks decode the full args and call set_return_data themselves — replace their bodies with your logic and keep the return path.
The carpenter-types crate (args, records, the header, the seeds — borsh and bytemuck only), the callback discriminators, hook_flags_valid's rules, the mock as reference, and the carpenter_amm IDL from anchor build. Today the crate is publish = false and UNLICENSED (Apache-2.0 is ruled for the freeze), and the IDL is not shipped — decision 01 below is about the template that would carry them.
Anchor, at the version the workspace's Anchor.toml pins — anchor_version 1.2.0, solana_version 3.1.14 today; the docs print it from that file, not from prose. Deploy under loader-v3, and not with the pinned CLI: Agave 3.1.14 cannot deploy these SBPF v3 artefacts (its v3 feature gate names a pubkey that exists on no cluster) — deploy with a 4.3.0 CLI, and never --use-rpc on a public endpoint (0.5 tx/min, measured). Run the AMM and your hook on Surfpool, the local runtime the repo's own end-to-end run uses; create a pool on it and swap: the record rules cannot be checked from outside the program, so a simulated initialize_pool and swap against your hook are the only proof the callbacks answer correctly.
06 — Getting approved
The application page reads your program and runs the same checks approve_hook runs, in the order the chain refuses them: (1) ["approved_hook", program] is absent — Anchor's init runs in account validation before the handler, so a present one fails first (devnet smoke: System custom program error: 0x0); (2) the program account is executable, owned by BPFLoaderUpgradeab1e, tagged 2 with the ProgramData pointer; (3) the ProgramData account is owned by the loader, ≥ 45 bytes, tagged 3, with its slot at bytes 4..12; (4) ["hook_config"] exists, is owned by your program, is ≥ 16 bytes and decodes at offset 8; (5) the flags pass hook_flags_valid and the two approval-only rules against the terms you state — four reads, one per rule. It also derives ["hook_authority"] to show you.
The program id; a name of at most 32 bytes (the on-chain field is [u8; 32]); the terms — max_rounds and callback_accounts as your header states them, whether you allow public pools, whether you want exclusivity; a contact; the source repository; an audit report if one exists; two lines on what the hook does and which pools it is for.
The config authority. In v1 that is a single deployer key with no multisig and no timelock, on every cluster — mainnet included (owner ruling 2026-09-17); a two-multisig design (Squads A custody, Squads B operations) is recorded as a later option, not an intent. types.ts:85's “a Squads multisig in production” predates that ruling and is stale. Approval is once per program; an upgrade is a reapproval, not a new application.
The reviewer sees what the checks see plus what you send. The approval does not hash the program, and it binds to whatever ProgramData slot is live when the instruction runs — a decision on an expected_programdata_slot argument is pending. The stated worst case is approving a malicious hook for new pools; existing pools on other hooks are untouched by it.