Gully Core

Build

Hook pools

A Gully Core hook is a contract attached to a pool. This guide takes you from a new project to a deployed hook, listed in the Gully Core Marketplace and backing a live pool on the shared core.

The idea

A hook contract is attached to a pool. The pool's key names one hook, and the pool manager calls it at the points in the pool's life the hook asked for: before or after initialise, adding or removing liquidity, swapping or donating. A hook can refuse an operation, set the fee of a dynamic-fee pool, or take a share of a swap through a hook delta.

The hook a pool names is part of the pool's identity. It cannot be changed later. A different hook is a different pool. Gully Core's own hooks include the launch guards (anti-snipe fee, creator tax, trade fee in the quote currency), the dynamic-fee hooks, the oracle hooks and RevShareHook. The contract names stay as the ABI has them: IHooks, getHooksRegistrationBitmap(), hookDelta.

Every token in every Gully Core pool is held by one Vault. Pools do not hold balances of their own. A trade happens inside a lock: a router asks the Vault to lock, performs any number of pool operations, and at the end every balance it owes or is owed must net to zero.

A hook runs on the shared core. You do not deploy the core. You bring your hook and, if you want, your own site. On pools you create, Gully takes a flat 0.30% of every trade and nothing else. See Fees.

CL poolBin pool
LiquidityConcentrated in a price range between two ticksPlaced in discrete price bins, each a fixed step apart
Pool managerCLPoolManagerBinPoolManager
PositionsCLPositionManagerBinPositionManager

1. Set up

A hook compiles against Gully Core's own core: the hardened Vault, the pool managers and the two base contracts BaseCLHook and BaseBinHook. All of them are in the public contracts repository, the same source that is deployed. Clone it, write your contract inside packages/hooks, where the imports below already resolve, and build it with Foundry. You need Foundry and Git. Contracts compile with Solidity 0.8.26.

shell
git clone --recurse-submodules https://github.com/gully-labs/gully-core
cd contracts/packages/hooks
# save the listing in step 2 as src/MyGullyCore.sol, then:
forge build src/MyGullyCore.sol

2. Write the hook

Extend BaseCLHook (or BaseBinHook for a Bin pool), return the callbacks you want from getHooksRegistrationBitmap(), and override only those. Every callback is callable only by the pool manager. One you declare but do not implement reverts HookNotImplemented, so a forgotten callback fails loudly.

solidity
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity 0.8.26;

// packages/hooks/src/MyGullyCore.sol in github.com/gully-labs/gully-core
import {PoolKey} from "infinity-core/src/types/PoolKey.sol";
import {PoolId} from "infinity-core/src/types/PoolId.sol";
import {BalanceDelta} from "infinity-core/src/types/BalanceDelta.sol";
import {ICLPoolManager} from "infinity-core/src/pool-cl/interfaces/ICLPoolManager.sol";
import {ICLHooks} from "infinity-core/src/pool-cl/interfaces/ICLHooks.sol";
import {BaseCLHook} from "gullycore-hooks/src/base/BaseCLHook.sol";

contract MyGullyCore is BaseCLHook {
    mapping(PoolId poolId => uint256 count) public swapCount;
    event SwapCounted(PoolId indexed poolId, uint256 count);

    constructor(ICLPoolManager _poolManager) BaseCLHook(_poolManager) {}

    function getHooksRegistrationBitmap() public pure override returns (uint16) {
        return AFTER_SWAP;
    }

    function _afterSwap(address, PoolKey calldata key, ICLPoolManager.SwapParams calldata, BalanceDelta, bytes calldata)
        internal
        override
        returns (bytes4, int128)
    {
        PoolId poolId = key.toId();
        uint256 count = ++swapCount[poolId];
        emit SwapCounted(poolId, count);
        return (ICLHooks.afterSwap.selector, int128(0));
    }
}

Dynamic fees

Pools with a dynamic fee quote the fee from the hook. A GullyCoreDexHook pool gives a dynamic fee and the price oracle in one hook.

3. Encode and deploy

The hook's bitmap and the pool key's bitmap must be the same number. The pool key carries it in the low 16 bits of parameters, beside the tick spacing (CL) or bin step (Bin) from bit 16. Core compares the two when the pool is initialised. Permissions live in the key, not in the hook's address, so there is no salt to mine and the same hook works from any address.

The swap counter above is afterSwap alone, bitmap 0x0080. With a tick spacing of 60 its pool key's parameters word is 0x3c0080.

shell
# from contracts/packages/hooks; CL_POOL_MANAGER is the chain's CL pool manager
forge create src/MyGullyCore.sol:MyGullyCore --constructor-args $CL_POOL_MANAGER \
  --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast

The pool managers' addresses on every chain are on Contracts. Deploy only where Gully Core is deployed. See Chains. Then initialise a pool whose key names your hook and that parameters word.

4. Register the hook

The Gully Core Marketplace is a view over one contract per chain, GullyCoreRegistry. Registration is permissionless, free beyond gas, and has no allowlist. The permissions recorded are read from the hook itself. The name, description and links you supply are descriptive only. Every listing enters as Unverified, Active. Only a curator moves it up, and a steward edit of the metadata drops it back to Unverified.

In the app, Deploy probes the hook, checks it is not registered, reads its bitmap the way the registry will, simulates register at the current block and only then asks for a signature. From a script it is one call. Only name is required.

shell
# register(hook, (name, description, sourceURI, auditURI, chainIds)): irreversible
cast send $REGISTRY "register(address,(string,string,string,string,uint256[]))" $HOOK \
  '("MyGullyCore","Counts swaps per pool","https://github.com/you/my-gullycore","",[$CHAIN_ID])' \
  --rpc-url $RPC_URL --private-key $PRIVATE_KEY

Every address also has a public page, /verify/<address>, with no wallet and no app chrome: its listing status, verification level, capability class and bitmap, and, clearly marked as the submitter's, its description and links. Link it from your own site.

Why register reverts

ErrorWhat it means
ZeroAddress()The hook argument is address(0).
GullyCoreAlreadyRegistered(address)That address is registered already. Registration happens once. The steward edits metadata with updateMetadata.
GullyCoreHasNoCode(address)No code at that address on this chain: a wallet, a typo, or a contract on another chain.
EmptyName()metadata.name is required. Everything else may be empty.
StringTooLong(uint256,uint256)name over 64 bytes, description over 2,048, sourceURI or auditURI over 512.
TooManyChains(uint256,uint256)More than 32 entries in metadata.chainIds.
InsufficientGasForProbe(uint256,uint256)Too little gas left to probe the bitmap. Raise the transaction's gas limit.
PermissionsUnreadable(address)getHooksRegistrationBitmap() reverted, returned something other than one word, a value above uint16, or ran past 100,000 gas. Core would refuse such a hook at initialize too.
ReservedBitsSet(uint16)The bitmap sets bit 14 or 15.
PermissionDependencyMissing(uint16)A *ReturnsDelta bit without its callback.

Permissions bitmap

Fourteen bits, one per point in a pool's life. On a Bin pool bits 2 to 5 and 12 to 13 are named mint and burn instead of add and remove liquidity. The positions are the same.

CallbackBitValueWhat it doesOverrideReturns
beforeInitialize00x0001Can refuse a pool before it exists._beforeInitializebytes4
afterInitialize10x0002Runs once, after the pool is created._afterInitializebytes4
beforeAddLiquidity20x0004Can refuse a deposit._beforeAddLiquiditybytes4
afterAddLiquidity30x0008Runs after a deposit settles._afterAddLiquiditybytes4, BalanceDelta
beforeRemoveLiquidity40x0010Can refuse a withdrawal._beforeRemoveLiquiditybytes4
afterRemoveLiquidity50x0020Runs after a withdrawal settles._afterRemoveLiquiditybytes4, BalanceDelta
beforeSwap60x0040Can block a swap, or set the fee of a dynamic-fee pool._beforeSwapbytes4, BeforeSwapDelta, uint24
afterSwap70x0080Runs once the swap has executed._afterSwapbytes4, int128
beforeDonate80x0100Can refuse a donation._beforeDonatebytes4
afterDonate90x0200Runs after a donation settles._afterDonatebytes4
beforeSwapReturnsDelta100x0400Lets beforeSwap resize the swap.nonenone
afterSwapReturnsDelta110x0800Lets afterSwap take part of the unspecified side.nonenone
afterAddLiquidityReturnsDelta120x1000Lets it take part of a deposit.nonenone
afterRemoveLiquidityReturnsDelta130x2000Lets it take part of a withdrawal.nonenone

Each *ReturnsDelta bit needs its callback: bit 10 needs bit 6, bit 11 needs bit 7, bit 12 needs bit 3 and bit 13 needs bit 5. Without it the base contract's constructor reverts PermissionDependencyMissing and the hook does not deploy.

Every callback receives a sender: the Vault locker, which is the router, not the end user. Per-wallet logic keyed on it is broken by construction.

Execution order

  1. A router locks the Vault. Only the lock holder may swap. It becomes the callbacks' sender.
  2. beforeSwap runs, if bit 6 is set. Reverting here blocks the swap.
  3. The fee override is read: only on a dynamic-fee pool and only with OVERRIDE_FEE_FLAG set.
  4. The swap executes. With bit 10, beforeSwap's delta resizes the amount before the pool sees it.
  5. afterSwap runs, if bit 7 is set. With bit 11 its delta is taken from the unspecified side. Balances settle when the lock is released.

A pool key names one hook. A callback that reverts reverts the whole swap, so be deliberate about which conditions revert.

Hook deltas

A hook with a *ReturnsDelta bit can take (or give) tokens as part of an operation. The launch guards take their fee and the creator tax this way, and RevShareHook its revenue share. The delta is settled against the Vault inside the same lock, so the router's minimum output already accounts for it.

  • beforeSwap may return a delta on the specified side and the unspecified side. Core reverts HookDeltaExceedsSwapAmount if it is larger than the swap.
  • afterSwap may return a delta on the unspecified side only.
  • Hold what you take as Vault claims (ERC-6909) and let recipients pull, rather than pushing tokens inside the callback.

Common errors

A hook's own revert reaches the trader inside WrappedError.

ErrorWhat it means
HookConfigValidationError()poolKey.parameters and getHooksRegistrationBitmap() disagree at initialize, or a pool with no hook asks for a bitmap or a dynamic fee. Make both sides the same number.
HookPermissionsValidationError()A *ReturnsDelta bit declared without the callback that returns it. The base contracts catch the same mistake at deploy time.
InvalidHookResponse()The callback returned the wrong selector or the wrong number of bytes. Return ICLHooks.<callback>.selector, or use the passthrough helpers.
HookDeltaExceedsSwapAmount()beforeSwap returned a delta larger than the swap itself. A hook may resize a swap, never invert it.
HookCallFailed()Your hook reverted. Core wraps the hook's own error in WrappedError(target, selector, reason, details). Decode reason for the real cause.
HookNotImplemented()From the base contract: a permission is declared but its internal callback is not overridden. Implement it or drop the bit.
PermissionDependencyMissing(uint16)From the base constructor: a *ReturnsDelta bit without its callback. The contract does not deploy.
ReservedBitsSet(uint16)Bits 14 or 15 are set. No callback uses them.
NotPoolManager()Something other than the pool manager called a callback.
LPFeeTooLarge(uint24)A fee override above 100% (1,000,000 pips). On a Bin pool the cap is 10%.

Revenue-share hook

RevShareHook indexes everything by pool id, a hash nothing can invert, while every write takes the full PoolKey. These views close the gap with one call each:

FunctionWhat it returns
keyOf(PoolId) → PoolKeyThe full key the hook stored when the pool was first configured. A zeroed struct for a pool it never governed. Ask hasKey first.
hasKey(PoolId) → boolWhether the hook governs that pool.
totalTaken(PoolId, Currency) → uint256Lifetime revenue taken by the pool in that currency. Only ever increases.
pendingBeneficiary(PoolId, Currency) → uint256A balance: the roster's pot not yet split. Drops when settleBeneficiaries runs.
pendingDistributorShare(PoolId, Currency) → uint256A balance: what the epoch distributor can pull.
shell
# All you have is a pool id. One eth_call returns the key every write needs.
cast call $HOOK "hasKey(bytes32)(bool)" $POOL_ID --rpc-url $RPC_URL
cast call $HOOK "keyOf(bytes32)((address,address,address,address,uint24,bytes32))" $POOL_ID --rpc-url $RPC_URL
# Lifetime revenue in one currency, next to the balance still waiting to be split.
cast call $HOOK "totalTaken(bytes32,address)(uint256)" $POOL_ID $CURRENCY --rpc-url $RPC_URL
cast call $HOOK "pendingBeneficiary(bytes32,address)(uint256)" $POOL_ID $CURRENCY --rpc-url $RPC_URL

Oracle hooks

A pool born on GullyCoreOracleHook or GullyCoreDexHook records a time-weighted average price that any contract can read. A pool gets the oracle only if it is created on one of these hooks. See Price oracle.

Stock (RWA) hooks

A stock pool's market-hours hook asks a price-band oracle for a reference price from inside the swap and stops trading on a revert, a zero or a stale timestamp. That makes referencePrice a view, and any oracle that must be updated has its update split into a separate, permissionless call. The adapters (Chainlink, Chainlink cross, a bounded manual publisher, Pyth) and how to run a stock market are on Stock market.

Maintenance calls

Several calls keep revenue moving. Every one is permissionless: any address may make it, it can only pay the party the contract names, and the worst a caller can do is make a call that was due anyway and pay the gas. Most guards are reverts, so simulating a call first tells you for free whether it is due.

CallContractIf nobody makes it
flushProtocolFees()LaunchpadKitV2, GullyCorePadFactory, the utilitiesFees wait in the contract.
collectFees(…)GullyCoreLPLocker, GullyCoreBinLPLockerA locked position's fees are not split to creator, Launchpad and protocol.
settleFee / settleTaxThe launch guardsTrade fees and taxes stay in the guard's pot instead of the parties' credit.
sweep(poolManager, currency)The protocol fee controllerCore protocol fees stay in the pool manager.
settleBeneficiaries(key, currency)RevShareHookThe roster's pot is never split. Returns quietly when empty: read pendingBeneficiary first.
applyPendingConfig(key)RevShareHookA config change that has waited out its delay never takes effect.
closeEpoch() / rollover(id)Epoch distributorsHolders cannot claim. Expired epochs' funds do not roll forward.

None of these calls needs a privileged key, so anyone can automate them.

What it costs

A pool's protocol fee composes with its LP fee: protocolFee + lpFee − protocolFee × lpFee / 1,000,000 (in pips), with the protocol fee capped at 0.4%. On pools builders create, Gully takes a flat 0.30% of every trade. See Fees.

Licence

A contract that imports Gully Core's core or base contracts is GPL-2.0-or-later, as they are. The SDK and the CLI are MIT, so front ends and scripts that only talk to your hook through its ABI may use any licence. Fee-on-transfer and rebasing tokens are not supported in any Gully Core contract.

Wrong or missing? Tell us at @Gully_Labs.