All skills
uniswap avatar

/permissioned-pools-deployer

@b058c31
by Uniswap Labsuniswap/uniswap-ai233 stars
39

Guided walkthrough for executing the on-chain setup sequence for a Uniswap v4 Permissioned Pool from a permissioned-pools-configurator JSON config - allowlist checker deployment, createPermissionsAdapter, allowlisting and funding the adapter, verification, wrapper and hook registration, pool initialization, swap enablement, liquidity seeding, and the routing-allowlist request. Use when the user says "deploy a permissioned pool", "run the permissioned pool setup", "execute the permissioned pools journey", "createPermissionsAdapter", "verify my permissions adapter", "walk me through the permissioned pool deployment", or asks to execute the on-chain steps after generating a permissioned-pools-configurator config. Covers contract mechanics only - does not constitute legal, financial, investment, tax, or compliance advice, and is not a compliance review - and never emits a broadcastable command before an explicit acknowledgment.

Use this Skill: https://skilld.dev/gh/uniswap/uniswap-ai/permissioned-pools-deployer

This session only. Nothing lands on disk.

referencesstep-walkthrough.md

≈5.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Step Walkthrough: Steps 2 Through 7

Ordered command sequences for the on-chain setup journey, continuing from Preflight and Validation's Step 1 (the allowlist checker). Every step below states its precondition and, where the contracts enforce one, the exact revert selector raised if that precondition is missing — matching permissioned-pools-issuer's Enforced Ordering and Reverts selector-for-selector. Where a step is not enforced against ordering, this file says so rather than inventing a revert for it.

Do not run any command in this file before the acknowledgment gate in the skill's main file has been explicitly satisfied, and before every address this step needs has passed Input Validation Rules and, if it started as "RESOLVE", address resolution.

Every command below assumes validated values are already in shell variables ($FACTORY_ADDRESS, $PERMISSIONED_TOKEN, $ADAPTER_OWNER, $ALLOWLIST_CHECKER, $RPC_URL, $DEPLOYER_ADDRESS, and so on) — never re-read a raw config value directly into a command. Signing uses --account <name> --sender $DEPLOYER_ADDRESS (encrypted keystore) or --ledger (hardware wallet) throughout; see Key Handling for both.


Step 2: createPermissionsAdapter

Precondition: the allowlist checker from Step 1 passes its ERC-165 probe. This is not one of the five ordering edges — it is a standalone precondition that fires on the very first Uniswap call in the journey.

Revert if missing: InvalidAllowListChecker(checker).

cast send "$FACTORY_ADDRESS" \
  "createPermissionsAdapter(address,address,address)" \
  "$PERMISSIONED_TOKEN" "$ADAPTER_OWNER" "$ALLOWLIST_CHECKER" \
  --rpc-url "$RPC_URL" \
  --account deployer --sender "$DEPLOYER_ADDRESS"

createPermissionsAdapter is callable by anyone — the resulting adapter's owner is whichever address you pass as $ADAPTER_OWNER, not whoever sends this transaction. There is no existence check on the factory either, so read the PermissionsAdapterCreated event from this transaction's receipt to get the new adapter's address, and keep your own record of it — nothing else marks this adapter as "the canonical one" for your token:

cast receipt "$TX_HASH" --rpc-url "$RPC_URL"
# Read the adapter address out of the PermissionsAdapterCreated log.

Check afterwards:

cast call "$FACTORY_ADDRESS" "permissionsAdapterOf(address)(address)" "$ADAPTER_ADDRESS" --rpc-url "$RPC_URL"
# expect: $PERMISSIONED_TOKEN

cast call "$ADAPTER_ADDRESS" "owner()(address)" --rpc-url "$RPC_URL"
# expect: $ADAPTER_OWNER

Step 3: Allowlist and Fund the Adapter

Three sub-steps, none of them a Uniswap call. This is the only step in the whole journey where you — not "anyone" — must be the one to act, and everything downstream waits on it.

Step 3a: Allowlist the adapter on your token

Add $ADAPTER_ADDRESS to your permissioned token's own allowlist, through whatever admin path your token exposes. No forge/cast command is prescribed here because this call is on your token's own contract, not on any permissioned-pools contract.

Precondition: none of its own. Downstream effect: this is half of the first enforced edge — without it, the adapter can never hold a balance, so Step 4 will revert regardless of Step 3c.

Check afterwards: this step has no check of its own that confirms anything. balanceOf(adapter) is necessarily still 0 at this point — the adapter has not been funded yet, that is Step 3c — so reading it here would only ever show 0 regardless of whether the allowlisting worked. Confirmation that 3a actually landed comes from Step 3c instead: if your token enforces the allowlist on incoming transfers, a successful depositForVerification (or the approve before it, depending on how your token's allowlist gate is wired) is the first evidence the adapter was really added. If your token exposes its own allowlist-membership getter, check that directly here instead — this skill does not know your token's interface and cannot give a generic command for it.

Step 3b: Approve the adapter to pull the deposit

depositForVerification pulls the tokens with safeTransferFrom, so the depositor must approve first:

cast send "$PERMISSIONED_TOKEN" \
  "approve(address,uint256)" \
  "$ADAPTER_ADDRESS" "$VERIFICATION_DEPOSIT_AMOUNT" \
  --rpc-url "$RPC_URL" \
  --account deployer --sender "$DEPLOYER_ADDRESS"

Precondition: must precede Step 3c. Revert if skipped: the deposit call reverts inside your token's own allowance check, not with a permissioned-pools selector — decode it as an ERC-20 allowance failure, not a bug in this walkthrough.

Step 3c: depositForVerification

Use depositForVerification, not a raw ERC-20 transfer, even though the verification gate itself is a bare balanceOf read that a plain transfer would also satisfy. The reason is the event: depositForVerification emits VerificationDeposit(address indexed depositor, uint256 amount), which is filterable, while a plain transfer emits only a generic Transfer with no permissioned-pools-specific signal.

cast send "$ADAPTER_ADDRESS" \
  "depositForVerification(uint256)" \
  "$VERIFICATION_DEPOSIT_AMOUNT" \
  --rpc-url "$RPC_URL" \
  --account deployer --sender "$DEPLOYER_ADDRESS"

$VERIFICATION_DEPOSIT_AMOUNT comes from the config's verificationDepositAmount field — 1 wei is sufficient and is not consumed; the deposited balance becomes the adapter's mintable headroom and there is no way to withdraw it later, so do not deposit more than the config specifies "to be safe."

Precondition: must precede Step 4. Callable by: anyone — the security property comes from the balance itself, arranged in Step 3a, not from who sends this transaction.

Check afterwards:

cast call "$PERMISSIONED_TOKEN" "balanceOf(address)(uint256)" "$ADAPTER_ADDRESS" --rpc-url "$RPC_URL"
# expect: non-zero

Step 4: verifyPermissionsAdapter

Precondition (the first enforced edge): the adapter is allowlisted on your token and holds a non-zero balance of it.

Revert if missing: PermissionsAdapterNotVerified(adapter).

cast send "$FACTORY_ADDRESS" \
  "verifyPermissionsAdapter(address)" \
  "$ADAPTER_ADDRESS" \
  --rpc-url "$RPC_URL" \
  --account deployer --sender "$DEPLOYER_ADDRESS"

Callable by anyone, and irreversible — a second call on an already-verified adapter reverts PermissionsAdapterAlreadyVerified(adapter), and an address the factory never created reverts PermissionsAdapterNotFound(adapter). There is no undo: the factory has no owner and no setters at all.

Check afterwards:

cast call "$FACTORY_ADDRESS" "verifiedPermissionsAdapterOf(address)(address)" "$ADAPTER_ADDRESS" --rpc-url "$RPC_URL"
# expect: $PERMISSIONED_TOKEN

This is the hinge of the whole journey — three of the five enforced edges (Steps 5b, 6a, and 6c below) depend directly on this call having landed.


Step 5: Register Wrappers and the Hook

Two independent operations, neither enforced against the other. Register wrappers before or after pool creation — nothing depends on the position — but each one must be registered before anything is routed through it.

Step 5a: The four updateAllowedWrapper registrations

Precondition (the fifth enforced edge): not enforced against setup ordering — this is an onlyOwner call on the adapter with no dependency on verification or on the pool existing. It is enforced against use: nothing can be routed through a contract that is not registered.

Revert if a contract is used unregistered: a bare Unauthorized() from the hook, then UnauthorizedWrapper(wrapper) on settlement.

Register exactly these four, and only these four (plus any issuer-added custom wrapper from the config's allowedWrappers object) — never the factory, and never the hook. Both are in the deploy guide's six-row Step 5 table and neither is a wrapper; registering either is a configuration error. Preflight and Validation already stops on a config key literally named factory or hook sitting inside the allowedWrappers object — but that is a key-name check, not a value check: it cannot catch the factory's or the hook's address being written as the value of one of the four legitimate allowedWrappers keys instead, nor the same mistake made here, by hand, when generating this step's commands directly. So check the address you are about to register against the resolved factory and hook addresses before sending, regardless of which key it came from.

# 1. PermissionedPositionManager — required before the first mint
cast send "$ADAPTER_ADDRESS" "updateAllowedWrapper(address,bool)" "$PERMISSIONED_POSITION_MANAGER" true \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

# 2. Universal Router, the "UniversalRouter#v2.2" deployment (2.2.0 or higher) — required before the first swap through it
cast send "$ADAPTER_ADDRESS" "updateAllowedWrapper(address,bool)" "$UNIVERSAL_ROUTER_V2_2" true \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

# 3. V4Quoter — required before the first quote simulation
cast send "$ADAPTER_ADDRESS" "updateAllowedWrapper(address,bool)" "$V4_QUOTER" true \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

# 4. MixedRouteQuoterV2 — same requirement, for mixed-route quoting
cast send "$ADAPTER_ADDRESS" "updateAllowedWrapper(address,bool)" "$MIXED_ROUTE_QUOTER_V2" true \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

The plain UniversalRouter deployments key is a different, non-permissioned router on the same chain — only the #v2.2 deployment (2.2.0 or higher) takes the permissions-adapter factory in its constructor. Resolving the wrong key registers a contract that looks right and produces a swap path that cannot work.

Check afterwards, for each of the four:

cast call "$ADAPTER_ADDRESS" "allowedWrappers(address)(bool)" "$PERMISSIONED_POSITION_MANAGER" --rpc-url "$RPC_URL"
# repeat for the router, both quoters, and any custom wrapper — expect true

cast call "$ADAPTER_ADDRESS" "allowedWrappers(address)(bool)" "$FACTORY_ADDRESS" --rpc-url "$RPC_URL"
cast call "$ADAPTER_ADDRESS" "allowedWrappers(address)(bool)" "$PERMISSIONED_HOOKS" --rpc-url "$RPC_URL"
# both expect false

Step 5b: setAllowedHook on the position manager

Precondition (the second enforced edge): the adapter must already be verified (Step 4). _getOwner resolves to address(0) for an unverified adapter's currency, so even the genuine owner is rejected before verification.

Revert if early: NotPermissionsAdapterAdmin().

cast send "$PERMISSIONED_POSITION_MANAGER" \
  "setAllowedHook(address,address,bool)" \
  "$ADAPTER_ADDRESS" "$PERMISSIONED_HOOKS" true \
  --rpc-url "$RPC_URL" \
  --account deployer --sender "$DEPLOYER_ADDRESS"

This call is setAllowedHook (singular), on the position manager — not on the adapter, and not through updateAllowedWrapper. It gates the LP path only (mints and increases; never swaps, decreases, or burns), and it is per position manager — approve it again for another deployment or another chain.

Check afterwards:

cast call "$PERMISSIONED_POSITION_MANAGER" \
  "isAllowedHooks(address,address)(bool)" "$ADAPTER_ADDRESS" "$PERMISSIONED_HOOKS" \
  --rpc-url "$RPC_URL"
# expect: true

Step 6: Create the Pool, Enable Swapping, Seed Liquidity

Step 6a: Initialize the pool

Precondition (the third enforced edge): the currency must have been created by the factory and verified.

Revert if missing: NoVerifiedAdapter() if neither currency was created by the factory at all (a wrong-address mistake, not a verification problem despite the name), or UnverifiedAdapter() if a factory-created currency exists but Step 4 has not landed yet.

The PoolKey's permissioned side is always $ADAPTER_ADDRESS — never the underlying $PERMISSIONED_TOKEN. The paired side is $PAIRED_CURRENCY, resolved from the config's pool.pairedCurrency: the literal "native" resolves to address(0) here and only here; any other value is already a validated ERC-20 address. Because struct arguments and the ratio-to-sqrtPriceX96 conversion below are awkward to build with cast alone, use a forge script:

// script/InitializePool.s.sol
pragma solidity ^0.8.24;

import "forge-std/Script.sol";

interface IPoolManager {
    struct PoolKey {
        address currency0;
        address currency1;
        uint24 fee;
        int24 tickSpacing;
        address hooks;
    }

    function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24);
}

contract InitializePool is Script {
    function run() external {
        address adapter = vm.envAddress("ADAPTER_ADDRESS");
        address pairedCurrency = vm.envAddress("PAIRED_CURRENCY");
        address poolManager = vm.envAddress("POOL_MANAGER_ADDRESS");
        address hooks = vm.envAddress("PERMISSIONED_HOOKS");
        uint24 fee = uint24(vm.envUint("POOL_FEE_TIER"));
        int24 tickSpacing = int24(vm.envInt("POOL_TICK_SPACING"));
        uint160 sqrtPriceX96 = uint160(vm.envUint("POOL_SQRT_PRICE_X96"));

        // currency0 must sort strictly below currency1 as an address — the PoolManager
        // rejects an out-of-order or equal pair before the hook ever runs.
        (address currency0, address currency1) =
            adapter < pairedCurrency ? (adapter, pairedCurrency) : (pairedCurrency, adapter);

        vm.startBroadcast();
        IPoolManager(poolManager).initialize(
            IPoolManager.PoolKey({
                currency0: currency0,
                currency1: currency1,
                fee: fee,
                tickSpacing: tickSpacing,
                hooks: hooks
            }),
            sqrtPriceX96
        );
        vm.stopBroadcast();
    }
}
forge script script/InitializePool.s.sol:InitializePool \
  --rpc-url "$RPC_URL" \
  --broadcast \
  --account deployer --sender "$DEPLOYER_ADDRESS"

$POOL_SQRT_PRICE_X96 is derived, not configured directly: the configurator stores pool.startingPriceRatio as a human-readable paired-currency-per-permissioned-token ratio, because the sqrtPriceX96 conversion depends on both currencies' decimals, which the configurator does not resolve. Read both decimals first —

cast call "$PERMISSIONED_TOKEN" "decimals()(uint8)" --rpc-url "$RPC_URL"
cast call "$PAIRED_CURRENCY" "decimals()(uint8)" --rpc-url "$RPC_URL"   # skip for native — 18 decimals

— then compute sqrtPriceX96 = sqrt(ratio * 10^(decimals1 - decimals0)) * 2^96, adjusted for which currency ended up as currency0 versus currency1 above (the ratio is paired-per-permissioned; invert it if the permissioned adapter is currency1).

Check afterwards: the pool's slot0 reads back the intended starting price, and the PoolKey's currency is the adapter — never the underlying token.

Step 6b: updateSwappingEnabled

Precondition: none — not one of the five enforced edges. This is an onlyOwner call on the adapter with no dependency on the pool existing; the guide places it after pool creation as a convention, not a requirement.

cast send "$ADAPTER_ADDRESS" "updateSwappingEnabled(bool)" true \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

swappingEnabled defaults to false, so every swap reverts SwappingDisabled() — checked by the hook before the wrapper check, and again by the router on settlement — until this call lands. There is no equivalent flag for liquidity; LPs can act while swapping is paused.

Check afterwards:

cast call "$ADAPTER_ADDRESS" "swappingEnabled()(bool)" --rpc-url "$RPC_URL"
# expect: true

Step 6c: Allowlist the seeding wallet, approve Permit2, then seed liquidity

Precondition (the fourth enforced edge): a verified adapter on at least one side, the hook approved on this position manager, and LIQUIDITY_ALLOWED on both the caller and the recipient of the mint.

Revert if missing: NoVerifiedAdapter() (position manager's own declaration — a different raise site than Step 6a's), InvalidHook(), or a bare Unauthorized(). The bare Unauthorized() can come from four independent checks that share the same selector — see Enforced Ordering and Reverts for how to disambiguate by call site rather than by name.

The caller-and-recipient LIQUIDITY_ALLOWED requirement, stated plainly: being the adapter owner grants nothing on this path. Before the first mint, both the wallet that sends it and the wallet that will own the resulting position must hold LIQUIDITY_ALLOWED on your own checker's allowlist. If seeding.intent is "seed-now" in the config, the issuer must allowlist seeding.wallet on their own token's allowlist for LIQUIDITY_ALLOWED before attempting this mint — this skill does not do that allowlisting for you, because it is a call on your token's own contract, not a permissioned-pools contract.

Its signature per permissioned-pools-issuer's Contract Architecture is isAllowed(address,PermissionFlag). PermissionFlag is a user-defined value type, and the reference does not state its underlying integer width — uint16 below is inferred from the flag constants spanning 0x0000-0xFFFF, not a value read from source. Confirm PermissionFlag's declaration in PermissionFlags.sol at the pinned commit before relying on the exact ABI type; the flag value 2 for LIQUIDITY_ALLOWED is confirmed correct regardless of that width:

cast call "$ADAPTER_ADDRESS" \
  "isAllowed(address,uint16)(bool)" "$SEEDING_WALLET" 2 \
  --rpc-url "$RPC_URL"
# 2 = LIQUIDITY_ALLOWED. Confirm true for both the sending wallet and the position recipient
# before sending the mint below — this is a read, not part of the mint transaction. If this
# reverts on ABI-decoding rather than returning false, re-check PermissionFlag's underlying
# type before concluding the wallet lacks the permission.

The two Permit2 approvals, both required before the first mint, and both on the underlying token, never on the adapter:

# 1. Approve Permit2 to move the underlying permissioned token.
cast send "$PERMISSIONED_TOKEN" "approve(address,uint256)" "$PERMIT2_ADDRESS" \
  115792089237316195423570985008687907853269984665640564039457584007913129639935 \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

# 2. Approve the PermissionedPositionManager as a Permit2 spender.
cast send "$PERMIT2_ADDRESS" \
  "approve(address,address,uint160,uint48)" \
  "$PERMISSIONED_TOKEN" "$PERMISSIONED_POSITION_MANAGER" \
  1461501637330902918203684832716283019655932542975 281474976710655 \
  --rpc-url "$RPC_URL" --account deployer --sender "$DEPLOYER_ADDRESS"

Both approvals above grant the maximum allowance with no expiry, matching the published issuer skill's own example exactly — if your operational risk tolerance calls for it, both can instead be scoped to the seeding amount with a short expiry, and revoked (approve(spender, 0) on the token, Permit2.approve(..., 0, 0) on the position-manager spend) once seeding is complete.

Permit2 itself is never registered as an allowed wrapper. It never holds the token and never calls the PoolManager on this pool's behalf — the position manager pulls through it with permit2.transferFrom(payer, adapter, amount, permissionedToken), which is a different relationship than the allowedWrappers check. Missing either approval above fails inside Permit2 with an allowance error, not a permissioned-pools selector.

Mint through $PERMISSIONED_POSITION_MANAGER — never the standard PositionManager — using whatever minting interface your Foundry scripting setup wraps it with; the position manager also needs to be a registered allowed wrapper on the adapter (Step 5a) or the hook rejects the mint with the same bare Unauthorized() regardless of the allowlist and Permit2 state being correct.

Check afterwards: the position NFT exists, and the pool shows liquidity at the intended range.


Step 7: Request Routing Allowlisting

The on-chain pool is complete after Step 6. This step has no forge/cast command — it is a form submission to Uniswap Labs, not a transaction.

Once the adapter is verified, submit the routing-allowlist request through the published dev-portal form, once per network (including Sepolia — testnet routing is not self-serve). The form needs, at minimum, the permissioned token address, the verified adapter address, and a KYC URL; see permissioned-pools-issuer's Coordination Boundary for the complete field list, including two fields the published guide never mentions.

Timelines, eligibility, and outcomes are not defined by the contracts and are not something this skill can commit to on Uniswap Labs' behalf.


Related Reading

  • Preflight and Validation — Step 1, config validation, address resolution, input validation in full, key handling, and testnet-first sequencing.
  • permissioned-pools-issuer's Issuer Journey — the same sequence presented as contract mechanics rather than command sequences.
  • permissioned-pools-issuer's Enforced Ordering and Reverts — the full revert catalogue and worked out-of-order scenarios this file's precondition callouts are drawn from.

Source: SKILL.md on GitHub

1 warning1mo3 checks · Risk SAFE
  • Gen Agent Trust Hub1mo

    The permissioned-pools-deployer skill is a secure deployment guide for Uniswap v4 pools, featuring rigorous input validation, safety gates, and secure key-handling protocols.

  • Socket1mo

    1 alert: gptAnomaly

  • Snyk1mo

    Risk: LOW · No issues

Signed by skilld at b058c31. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated 2 months ago
What it can do
Reads files Runs commands
Modelopus
model
opus
metadata
{
  "author": "uniswap",
  "version": "0.1.0"
}
All 6 allowed tools
ReadGlobGrepBash(forge:*)Bash(cast:*)AskUserQuestion

README badge

README badge for uniswap/uniswap-ai/permissioned-pools-deployer