| name | deploy-uni-hook |
| description | Generate, simulate, audit, and deploy a Uniswap v4 hook + test pool from a brief, on any Uniswap v4 chain (every testnet and mainnet) - pre-audited templates or a from-scratch freeform hook (flags auto-derived; static audit + dangerous-pattern scan + a behavioral forge test + fork sim gate the deploy). Dry-run by default; explicit arm: to broadcast; testnet default, mainnet behind a double opt-in; records the deploy to main. Every deployed hook inherits a mandatory 10 bps AeonFee protocol fee. |
| metadata | {"title":"Deploy Uni Hook","category":"crypto","var":"arm: to broadcast (default is a dry-run), template:dynamic|noop|skim to force a mode, chain:<name> to pick a chain (default base-sepolia), then the hook brief. Empty prints the grammar.","tags":["crypto","dev","onchain"],"requires":["HOOK_DEPLOYER_PRIVATE_KEY?","ALCHEMY_API_KEY?","ETHERSCAN_API_KEY?"],"capabilities":["onchain_writes","writes_external_host","sends_notifications"]} |
${var} — the hook brief. Grammar: [arm:][template:<name>] [chain:<name>] <brief>
- `` (empty) → print help and exit
DEPLOY_HOOK_EMPTY.
<brief> → dry-run: generate, compile, mine, and simulate. Never broadcasts. [default — no prefix]
arm:<brief> → broadcast: do the full dry-run first, then deploy for real if the simulation passes.
template:<name> → force a mode: dynamic | noop | skim (pre-audited templates) or freeform (build a whole hook from the prompt). Omit to auto-pick: a brief that matches a template uses it; anything else → freeform.
chain:<name> → any Uniswap v4 chain in chains.tsv (run ./hook-deploy.sh chains to list). Default base-sepolia. Testnets: base-sepolia, unichain-sepolia, arbitrum-sepolia. Mainnets (testnet: false, e.g. base, ethereum, unichain, arbitrum, optimism, polygon, bnb, avalanche, ...) require BOTH arm: and an explicit chain: — the skill never targets mainnet by default. base-mainnet is accepted as an alias for base.
Today is ${today}. This skill turns a one-line brief into a live Uniswap v4 hook. It is built to be safe: it simulates every deploy before it broadcasts, it defaults to a dry-run on testnet, and it needs an explicit arm: to move on-chain.
Why this design
A hook binding is immutable and a bad hook can brick a pool or steal funds. So the gates sit BEFORE the deploy: two of them (dry-run then arm:), a mandatory simulation, and idempotent state. Everything after the broadcast is just recording what already happened — appended to memory/state/hook-deploys.json on main, no PR (there is nothing left to review). The Foundry flow is the proven one — mine a CREATE2 salt so the address carries the right hook-flag bits, deploy, initialize the pool, add liquidity, run one swap.
Safety contract (do not skip)
- Mainnet needs a triple lock. Never target a
testnet: false chain unless ${var} has BOTH arm: AND an explicit chain:<mainnet-name> — AND the instance has HOOK_MAINNET_OK=1 set as a repo variable (a third, operator-level lock enforced inside hook-deploy.sh, exit 7; store it as a variable, not a secret - a secret value of 1 masks every 1 in the run log, so tx hashes and links print as ***). An instance that never authorized mainnet cannot broadcast there even if an armed message asks it to. This skill must only run on an instance whose inbound path is owner-gated (TELEGRAM_ALLOWED_USER_ID / the multi-channel allowlist) — a mainnet deploy spends real gas, so an untrusted sender must never be able to dispatch it. On a mainnet chain, first read the deployer balance with cast balance and abort (DEPLOY_HOOK_UNDERFUNDED) if it cannot cover the simulation's Estimated amount required; hook-deploy.sh independently enforces a funding floor (exit 8), an optional MAX_GAS_GWEI gas-price ceiling (exit 9), and warns if the deployer holds more than HOOK_MAX_FLOAT_ETH (default 0.25) — a deploy key must hold gas float only, never LP or treasury capital. Log a clear MAINNET warning in the output.
- Simulate before every broadcast. If the simulation reverts, do not broadcast. Report the revert and exit
DEPLOY_HOOK_SIM_FAILED.
- Dry-run is the default. Broadcast only when
${var} starts with arm:.
- Key hygiene. The deployer key is a burner. Never print it. Never put it on a shell command line — always go through
./hook-deploy.sh, which reads it from the env inside the script.
- Idempotency. Before broadcasting, read
memory/state/hook-deploys.json. If an identical brief already deployed within the last hour, do not re-deploy. The deploy script is also idempotent at the address level: it deploys to the canonical address (the first flag-matching CREATE2 salt for this exact (creationCode, flags, PoolManager)). If that address already holds code, an identical hook is already live, so the script logs ALREADY_DEPLOYED <addr> and does nothing — the runner reports the existing address instead of deploying a duplicate. (HookMiner itself skips occupied addresses, so without this check a re-run would silently deploy another copy at a new address.)
Inputs and config
- Templates:
skills/deploy-uni-hook/templates/ - AeonFee.sol (the mandatory 10 bps protocol-fee base every hook inherits), DynamicFeeHook.sol, NoOpHook.sol, HookFeeHook.sol (pre-audited), Hook.sol + Hook.t.sol + hook.env.example (freeform scaffold, behavioral-test gate, manifest), plus DeployHook.s.sol, MockERC20.sol, foundry.toml, chains.tsv.
- Chain config:
skills/deploy-uni-hook/templates/chains.tsv is the single source of truth — TAB-separated name chainId testnet poolManager stateView rpc explorer alchemy, one row per Uniswap v4 chain (staged next to hook-deploy.sh, which reads it). memory/uni-deployments.md mirrors it for humans. To add a chain, append a row to chains.tsv.
- Authenticated RPC: the
rpc column is a public endpoint. When ALCHEMY_API_KEY is set and the row has an alchemy slug, hook-deploy.sh uses https://<slug>.g.alchemy.com/v2/$ALCHEMY_API_KEY instead — a trusted RPC matters for the mainnet sim + broadcast (a lying public RPC can fake a clean sim). Precedence: RPC_URL (override, for testing) > Alchemy key + slug > public rpc. The RPC path (where the key lives) is never printed — logs show host only.
- Deploy helper:
skills/deploy-uni-hook/hook-deploy.sh — the only sanctioned broadcast path (hides the key).
- State:
memory/state/hook-deploys.json — idempotency + the deploy ledger.
Template picker (when template: is not given)
| Brief mentions | Mode |
|---|
| fee, volatility, dynamic, surge | dynamic |
| skim, hook fee, take a cut, revenue | skim |
| "minimal" / "starter" / "empty" | noop |
| game, leaderboard, points, crown, loyalty | freeform (game rules in Labs routing) |
| anything else (novel logic the templates don't cover) | freeform |
Mandatory AeonFee (every hook)
Every hook this skill deploys inherits AeonFee (templates/AeonFee.sol): a MANDATORY 10 bps (0.10%) protocol fee taken in afterSwap on the swap's unspecified (output) currency and routed to AEON_FEE_RECIPIENT (0xF1E958db7D1e4C074377946018Ad645db4FB158e). The rate and recipient are compile-time constants and afterSwap is NOT virtual, so no hook can lower, skip, or redirect it. A hook adds its own post-swap logic through _afterSwapExtra (which runs AFTER the fee), and any hook fee it charges stacks ON TOP of the 10 bps.
Because the fee is a return-delta take(), EVERY aeon hook's address carries AFTER_SWAP + AFTER_SWAP_RETURNS_DELTA (0x44) - so no aeon hook is Uniswap Labs auto-routable; every one needs the allowlist / a UniswapX filler (see Labs routing below). This is the deliberate trade for a fee that can never be bypassed.
Labs routing
Uniswap Labs auto-routes a hooked pool unless the address starts with 0x91, or the hook uses beforeSwapReturnsDelta, afterSwapReturnsDelta, or dynamicFees. Anything in that set needs the allowlist form or a UniswapX filler. Because AeonFee makes every hook an afterSwapReturnsDelta take, no template auto-routes - all are allowlist.
| Template | Flags | Labs classic router |
|---|
noop | 0xC4 (beforeSwap + AeonFee 0x44) | allowlist (afterSwapReturnsDelta) |
freeform default (_afterSwapExtra) | 0x44 (AeonFee) | allowlist (afterSwapReturnsDelta) |
dynamic | 0x10C4 (0x10C0 + AeonFee 0x04) + DYNAMIC_FEE_FLAG | allowlist (dynamicFees + afterSwapReturnsDelta) |
skim | 0x44 (AeonFee + own skim, same bits) | allowlist (afterSwapReturnsDelta) |
Game on a hook (freeform): the 10 bps fee already runs in the base afterSwap via take() - a freeform body must NOT implement afterSwap (put extra logic in _afterSwapExtra).
- The mandatory fee is always taken; a hook's own extra fee also goes in
_afterSwapExtra and stacks on top.
- Game runs only when
hookData names a player. Empty hookData (Labs Universal Router) = paid swap, no game, no revert.
- Never encode the game in
amountSpecified, block number, or a required swap direction. Those revert the router and collect nothing.
sender is the router, not the user. Do not key game state off sender.
Do not generate amount-suffix / block-echo / exact-out-only / direction-gate hooks unless the brief explicitly asks for a revert-gate. The miner skips 0x91... addresses.
Fleet audit rules (from aeon.fun hook audits)
These are standing defects measured on the live fleet. Freeform MUST NOT recreate them. The skim template is already patched.
Fee / take():
- Charge the MAGNITUDE of the unspecified delta. Exact-out makes that delta negative.
if (unspecifiedAmount <= 0) return silently skips the fee on every exact-output swap (shared-base F1).
- Widen to
int256 before negating. -type(int128).min panics and bricks that swap.
poolManager.take(..., feeRecipient, ...) to an immutable recipient. NEVER address(this). No withdraw(). Custody was the CrownClash/LegacyLedger HIGH.
- An extra skim helper must not copy a
<= 0 early return (second copy of the sign guard).
Gates (only if the brief demands a revert-gate):
- 1a. Value that moves on its own (
block.number): a view helper answered at head N is wrong at execution N+1. Target the execution block.
- 1b. Value that moves when someone swaps (price low byte): exact match + zero tolerance is a contention DoS. Need a band, or do not gate.
- 1c. Shared counter an attacker can park, not advanced on failure: griefing primitive.
-
- A contract in the
unlock frame can satisfy the predicate; a signed tx cannot. That binds the wrong party.
-
- Never compare raw
amountSpecified to a token-denominated constant. The caller picks the specified currency via exact-in vs exact-out. Use a dimensionless bound (tick move / liquidity fraction).
-
- A "balance" / "skew" / "heavier-side" gate on the two virtual reserves is a raw-PRICE gate in disguise.
StateLibrary gives amount0 = L*2^96/sqrtP and amount1 = L*sqrtP/2^96, so amount0/amount1 = 1/price and the liquidity L cancels exactly. Any test on the two reserves (b0 >= b1, a skew band, "which side is heavier") therefore reduces to comparing the pool's RAW price to an implicit 1.0, and raw price is token1/token0 in smallest units, so it is ~1.0 only for a same-decimals pair near parity. A USDC(6d)/WETH(18d) pool sits ~8 orders of magnitude off; two 18-dec tokens at price 2.0 are already outside a 10% band. Such a gate is permanently one-directional on every real pair: one whole leg reverts forever, and "every trade rebalances toward 50/50" is false (a full-range position is already 50/50 by value at any price). Fix: snapshot the pool's OWN reference (its sqrtPriceX96 at afterInitialize, or an explicit target ratio from the brief) and gate the current price against THAT, never a hard-coded 1.0. This needs the afterInitialize callback (adds flag bit 0x1000), so a price/balance/skew hook must include it in the callback set or it cannot know its own starting price.
- Never
balanceOf(poolManager): that is the v4 singleton's global inventory, not this pool. Use StateLibrary.
sender is the router. Do not treat it as the trader.
Tests:
- A fee hook must assert the take on exact-in AND exact-out.
- A gate needs a hookless negative control (
hooks = address(0)).
- Do not cache
block.number across vm.roll (via-ir folds it). Use vm.getBlockNumber().
- A price / balance / skew gate MUST be asserted at a price away from 1:1. The scaffold's
setUp() pool starts at 1:1 (sqrtPriceX96 = 2^96), the single price where a raw-price-vs-1.0 gate looks correct no matter how it is written. Call _freshPoolAt(<non-1:1 sqrtPriceX96>) (helper in Hook.t.sol) and assert BOTH legs there: the leg that must stay open is not rejected, the leg that must close reverts. A gate proven only at 1:1 is a false pass.
Steps
-
Parse ${var}. Extract the arm: flag, the optional template:, the optional chain:, and the free-text brief. Empty brief → exit DEPLOY_HOOK_EMPTY with the grammar.
-
Resolve the chain. The chain name resolves in chains.tsv (default base-sepolia); hook-deploy.sh maps it to the official PoolManager + RPC, so you pass the NAME, not the address. Run ./hook-deploy.sh chains to see the list, or read chains.tsv. If the name is not in the registry, exit DEPLOY_HOOK_BAD_CHAIN. Look up the row's testnet column: if it is false (mainnet), enforce the double opt-in — require BOTH arm: and an explicit chain: in ${var}, else exit DEPLOY_HOOK_BAD_CHAIN. Every Uniswap v4 chain is supported (Base, Ethereum, Unichain, Arbitrum, Optimism, Polygon, BNB, Avalanche, Robinhood, Worldchain, Ink, Soneium, Celo, X Layer + their testnets).
-
Confirm the staged toolchain + project. The workflow pre-stages everything before this run (scripts/stage-deploy-uni-hook.sh): Foundry on $PATH, a pre-built v4 project at $HOOKBUILD_DIR (default $HOME/hookbuild) holding all three templates + MockERC20.sol + DeployHook.s.sol + the v4 libraries, and ./hook-deploy.sh copied to the repo root. Do not install Foundry or clone the libs in-run — the sandbox blocks that. Check command -v forge and that $HOOKBUILD_DIR exists; if either is missing, degrade to DEPLOY_HOOK_NO_TOOLCHAIN (emit the generated source + plan).
-
Build the hook (brief-driven).
- Template mode (
dynamic / noop / skim): in $HOOKBUILD_DIR/src/<Hook>.sol, edit ONLY the region between // --- AEON:LOGIC START --- and // --- AEON:LOGIC END ---. Keep the callback signatures and flag set unchanged. If the default already fits the brief, leave it.
- Freeform mode (anything else): write the whole hook into
$HOOKBUILD_DIR/src/Hook.sol, replacing the // --- AEON:BODY ... --- region. Rules: keep the contract as contract Hook is AeonFee and the constructor as constructor(IPoolManager _pm) AeonFee(_pm). Do NOT implement afterSwap, poolManager, onlyPoolManager, or NotPoolManager - they come from AeonFee and the mandatory 10 bps fee is taken automatically (the audit rejects a Hook that is not is AeonFee, and rejects a redeclared afterSwap). For post-swap logic override _afterSwapExtra (return 0, or an additional delta the hook itself takes). Implement any OTHER v4 callbacks the prompt needs, each with the EXACT IHooks signature, onlyPoolManager, and the right selector return. Do NOT hand-set flags - they are auto-derived from your callbacks (plus the always-on AeonFee afterSwap/afterSwapReturnsDelta bits). If another callback returns a non-zero delta, set HOOK_RETURNS_DELTA in $HOOKBUILD_DIR/hook.env; for a fee-override hook set HOOK_POOL_FEE=dynamic there. Follow Labs routing and Fleet audit rules: empty hookData must succeed; a game must not revert a vanilla exact-in swap; an extra take() (in _afterSwapExtra) must charge magnitude (exact-in and exact-out) and never custody; a price/balance/skew gate must add the afterInitialize callback and anchor to the pool's own start price (Gates rule 6), never an implicit 1.0.
- Also write the behavioral test. In
$HOOKBUILD_DIR/test/Hook.t.sol, replace the // --- AEON:ASSERT ... --- region with test_* functions that assert the hook's SPECIFIC intended behavior — not just "does not revert". For every rule in the brief write at least one positive and one negative case: a swap the hook must REJECT as _expectSwapRevert(zeroForOne, amount, Hook.SomeError.selector) (this helper unwraps v4's WrappedError for you — do NOT use bare vm.expectRevert, it won't match the wrapper); a swap it must ALLOW as a plain _swap(...); any getter/accounting as assertEq(hook.someGetter(...), expected). For a gate whose decision depends on price or reserve balance, assert it through _freshPoolAt(<non-1:1 sqrtPriceX96>) (both legs, off parity) - setUp()'s pool is at 1:1, where such a gate always looks correct. Do NOT edit setUp() or the helpers - only the AEON:ASSERT region. If the brief has no rejectable behavior, still assert the observable state the hook changes.
-
Simulate + audit (always). Pass mode, kind, and chain (chain omitted = base-sepolia):
./hook-deploy.sh simulate <kind> <chain>
For freeform this runs, in order, three gates before any deploy: