Providing Liquidity on Uniswap V4 While Preserving Self-Custody

Building the infrastructure that allows a Gnosis Safe to delegate Uniswap V4 operations — minting, swapping, settling — to an operator without ever surrendering custody.

By bauti.eth · uniswap-v4 · zodiac · safe · non-custodial · solidity · security

DAMM manages assets without ever holding custody of them. Client funds sit in the client's own Safe; we operate through a Zodiac Roles v2 module that grants our operator exactly the permissions a strategy needs — this pool, these tokens, these function calls — and nothing else.

We don't rent this capability from a Fireblocks or a Fordefi. We built our own permission stack on top of Zodiac Roles v2: decentralized instead of routed through a custodial intermediary, open source instead of a black box, and — because the skill set lives in-house — ours to extend the moment a strategy needs something off-the-shelf tooling can't express. This article is about one of those moments.

That model makes every new protocol integration a permissions problem before it is a strategy problem. Someone has to express "the operator may do exactly this" in Roles' condition language. For Uniswap v3, Aave, Morpho and most of DeFi, that language is enough: you pin the target contract, the function selector, and constrain individual ABI-encoded parameters with a condition tree.

// Aave V3: every argument sits at a known offset in the calldata,
// so each one can carry its own condition
function supply(
    address asset,        // ← must be a whitelisted token
    uint256 amount,       // ← left free
    address onBehalfOf,   // ← must be the Safe
    uint16  referralCode
) external;

Uniswap V4 was the first protocol where that was not merely hard. It was impossible — the Zodiac JS SDK, and the condition system underneath it, cannot express V4 calldata at all. So we built the missing layer in-house.

Why V4 breaks static permissions

Zodiac Roles authorizes transactions by walking a static condition tree over calldata. Each node declares a parameter type (static, dynamic, tuple, array…) and an operator (EqualTo, LessThan, WithinAllowance…). Crucially, the tree's shape is fixed when the permission is configured. That works when calldata structure is knowable from the ABI alone.

V4's periphery abandoned that world for an interpreter pattern. A position change goes through:

PositionManager.modifyLiquidities(bytes unlockData, uint256 deadline)
// where unlockData = abi.encode(bytes actions, bytes[] params)

and a swap goes through the Universal Router's V4_SWAP command, whose input has the exact same shape: abi.encode(bytes actions, bytes[] params).

Think of that payload as a tiny program. actions is the instruction list, one byte per step: 0x02 means mint a position, 0x06 swap exact-in, 0x0c settle, 0x0f take. params is the argument list: params[i] holds the arguments for step i, packed as raw bytes. Which struct those bytes decode to is decided by the opcode at actions[i] — mint arguments if it's a mint, swap arguments if it's a swap. Nothing in the calldata's shape tells you which is which; you have to read the values.

Concretely, here is the payload of a real USDC → USDT swap on Arbitrum — three actions: do the swap, pay in the USDC, collect the USDT:

actions = 0x 06 0c 0f
             │  │  │
             │  │  └── 0x0f TAKE_ALL
             │  └───── 0x0c SETTLE_ALL
             └──────── 0x06 SWAP_EXACT_IN_SINGLE

params[0] = 0xaf88d065…  ← 352+ bytes. Decodes as ExactInputSingleParams
                           { poolKey: {USDC, USDT, fee, tickSpacing, hooks},
                             zeroForOne, amountIn, amountOutMinimum, hookData }
                           …but ONLY because actions[0] == 0x06
params[1] = 0xaf88d065…  ← 96 bytes. Decodes as (currency: USDC, maxAmount)
                           …but ONLY because actions[1] == 0x0c
params[2] = 0xfd086bc7…  ← 96 bytes. Decodes as (currency: USDT, minAmount)
                           …but ONLY because actions[2] == 0x0f

Three blobs, three different structs — and the only thing that says which struct lives in which blob is the opcode byte next door. Reorder the opcodes and every blob silently changes meaning. To a static condition tree, all three of these look identical: opaque byte strings it has no vocabulary to open. (This exact transaction shape is replayed byte-for-byte in the repo's test suite.)

Composed together, the transaction the Safe actually signs nests all of this two bytes-encodings deep:

UniversalRouter.execute(
  commands = 0x10,                    // one command: V4_SWAP
  inputs   = [
    abi.encode(
      actions = 0x060c0f,             // the 3-step program above
      params  = [ swap blob, settle blob, take blob ]
    )
  ],
  deadline
)

In the real transaction, the swap struct a permission system needs to inspect starts at byte 516 of the calldata — buried inside a bytes element of an array, inside another bytes argument, its type determined by one byte sitting somewhere else entirely.

That defeats a static condition tree on two axes:

  1. Value-dependent typing. A tree node's type is schema, fixed upfront. It cannot say "if byte i of actions is 0x02, decode params[i] as a MintPosition struct and constrain its fields." In V4, the type of each parameter is data.
  2. Nested re-encoding. Each params[i] carries a second layer of ABI encoding inside a bytes element. The condition language has no way to descend into it.

The result: with stock tooling, a Safe can either grant an operator all of Uniswap V4 or none of it. For a non-custodial manager, both answers are wrong.

The escape hatch: custom condition contracts

Roles v2 ships one loophole — a condition node can delegate its judgment to an external contract implementing ICustomCondition:

interface ICustomCondition {
    function check(
        address to, uint256 value, bytes calldata data, uint8 operation,
        uint256 location, uint256 size,   // the slice this condition governs
        bytes12 extra                      // per-permission metadata
    ) external view returns (bool success, bytes32 reason);
}

Roles hands the verifier the full transaction calldata plus (location, size) bounds of the specific params[i] element, and 12 bytes of metadata packed into the permission itself. The division of labor is clean: the ordinary condition tree still pins the target address, the selector, and which action opcodes may appear; a purpose-built verifier then validates the struct inside each blob.

To see how simple a custom condition can be, here is the reference implementation Zodiac itself ships — it answers one question: is the Safe the owner of the NFT named in the calldata?

contract AvatarIsOwnerOfERC721 is ICustomCondition {
    function check(
        address to, uint256, bytes calldata data, Operation,
        uint256 location, uint256 size, bytes12
    ) public view returns (bool success, bytes32 reason) {
        address avatar = IModifier(msg.sender).avatar();
        uint256 tokenId = uint256(bytes32(data[location:location + size]));
        return (IERC721(to).ownerOf(tokenId) == avatar, 0);
    }
}

Read the slice, ask the chain a question, return a verdict. Our verifiers are the same species — they just have Uniswap V4's action structs living inside the slice.

We wrote nine of them — one per V4 action our strategies need:

Verifier V4 action What it validates
Mint MINT_POSITION both currencies, fee tier ≤ cap, position owner = the Safe
Decrease Liquidity DECREASE_LIQUIDITY NFT ownership: ownerOf(tokenId) = the Safe (live on-chain read)
Swap Exact-In Single SWAP_EXACT_IN_SINGLE both currencies, fee ≤ cap, native-ETH msg.value = amountIn
Swap Exact-Out Single SWAP_EXACT_OUT_SINGLE both currencies, fee ≤ cap, native-ETH msg.value = amountInMaximum
Settle All / Settle Pair SETTLE_ALL / SETTLE_PAIR currency(-ies) ∈ the whitelisted pair
Take All / Take Pair TAKE_ALL / TAKE_PAIR currencies ∈ the pair; recipient = the Safe
Sweep SWEEP currency ∈ the pair; sweep destination = the Safe

Read the table column-wise and the design invariant falls out:

Every argument that names a token must match the whitelisted pair. Every argument that names a destination for funds must be the Safe itself. Amounts, ticks, and slippage are left to the operator.

The permission layer decides what can be touched and where value can flow — not how well the operator trades.

Two details worth noticing. The verifiers decode calldata using Uniswap's own CalldataDecoder library, the same assembly the router executes — so the verifier's view of a struct can never diverge from the chain's view. And the fee-tier cap (1%) is not pedantry: V4 fee tiers are permissionless up to 100%, so an unbounded fee field would let a rogue operator route the Safe's tokens through a self-created 100%-fee pool of the same pair — a value-exfiltration channel closed with one uint24 comparison.

These conditions also compose with the rest of Roles' machinery. In our deployments each permission is additionally wrapped in a Zodiac allowance — an on-chain rate limit that throttles how often the operator can invoke each action (say, ten calls per hour). A compromised operator key can't be prevented from trading badly, but it can be prevented from trading fast: throttling mint/swap cycles caps how quickly anyone could wash-trade value out of a position through fees, and buys the guardians time to respond.

The dirty-bytes war story

The verifiers initially failed against every real Zodiac call, for a reason documented nowhere: when Roles hands a bytes[] element to a custom condition, the slice includes the element's 32-byte length word. Expected: abi.encode(struct). Reality: abi.encodePacked(length, abi.encode(struct)).

No revert reason points at this. No documentation mentions it. Just verifiers rejecting perfectly valid transactions, decoding garbage exactly one word away from where the struct actually was. The repo's git history preserves — verbatim, and forever — the moment the offset finally clicked:

"it works, praise be to god."commit bda0ef6

The fix is a one-word offset (skip 0x20 bytes before decoding), but the consequence runs through the whole codebase: the test suite names the two encodings "dirty" (with length word) and "clean" (without), and every fuzz test writes garbage into the length slot to prove the verifiers never read it. If you are building custom Zodiac conditions, this paragraph may save you a week.

Twelve bytes to name a pair

A permission needs to say which token pair a verifier enforces — but the custom-condition metadata field is only bytes12 (Roles packs the 20-byte verifier address plus 12 spare bytes into one 32-byte slot). Two token addresses are 40 bytes. They don't fit.

The scheme: identify each token by the first 6 bytes of the hash of its address, and concatenate —

extra = bytes6(keccak256(token0)) || bytes6(keccak256(token1))

Truncating to 48 bits per token admits collisions in principle: a random collision needs on the order of 2²⁴ tokens (birthday bound), and a targeted forgery costs ~2⁴⁸ keccak evaluations. Those odds are accepted because the verifier is one layer in a stack: the Safe only ever approves its whitelisted tokens to Permit2, the router, and the PositionManager, so a hypothetical collided token has nothing to move anyway.

The other half: wiring it up in TypeScript

The contracts alone don't make a permission — someone has to author the Roles condition tree that invokes them. That lives in our internal fund-deployment toolkit, built on the zodiac-roles-sdk. Showing it end to end makes the whole design click.

First, the token key — the same math as the Solidity tokenHeader, and the 32-byte compValue a Custom condition node carries: 20 bytes of verifier address plus our 12 bytes of extra data.

const tokenHashHeader = (token: Address) =>
    keccak256(encodePacked(['address'], [token])).slice(2, 14) // first 6 bytes

const encodeCustomCompValue = (verifier: Address, extra: BytesLike = '0x') =>
    getAddress(verifier).toLowerCase() + hexlify(extra).slice(2).padEnd(24, '0')

Each verifier gets a small condition factory that plugs into the SDK's authoring DSL. Here is the mint one — Operator.Custom is the delegation point:

export const structIsV4PositionMint =
    (chainId: ChainId, token0: Address, token1: Address) =>
    (abiType: ParamType) => {
        if (abiType.type !== 'bytes') throw new Error('bytes params only')
        return {
            paramType: ParameterType.Dynamic,
            operator: Operator.Custom, // ← delegate this node to our verifier
            compValue: encodeCustomCompValue(
                positionMintVerifierAddress(chainId),
                `0x${tokenHashHeader(token0)}${tokenHashHeader(token1)}`
            ),
        }
    }

And the composition — the full permission for minting a position into one pool (a mint runs four actions: mint, settle the pair, sweep both leftovers):

allow.arbitrumOne.uniswapV4.positionManager.modifyLiquidities(
    c.abiEncodedMatches(
        [
            // pin the EXACT action program — no reordering, no extra opcodes
            `0x${MINT_POSITION}${SETTLE_PAIR}${SWEEP}${SWEEP}`,
            // and every params blob must satisfy one of our verifiers
            c.every(
                c.or(
                    structIsV4PositionMint(chainId, pool.token0, pool.token1),
                    structIsV4SettlePair(chainId, pool.token0, pool.token1),
                    structIsV4Sweep(chainId, pool.token0, pool.token1)
                )
            ),
        ],
        ['bytes', 'bytes[]']
    )
)

Read it back against the problem statement and the division of labor is exactly the one the condition system wanted all along. c.abiEncodedMatches teaches Roles that unlockData decodes as (bytes, bytes[]). The first element is pinned to the literal opcode sequence, so the operator cannot reorder, drop, or append actions. c.every(c.or(…)) requires each params blob to satisfy at least one verifier — and because each verifier hard-fails on any struct that isn't its own (wrong size, wrong shape), a mint blob can only ever pass the mint verifier. The tree pins the program; the verifiers vet each instruction's arguments; and both sides are anchored to the same token pair — the tree through the verifier addresses it embeds, the verifiers through the 12-byte key.

The audit

We didn't want this code trusted on our word — and thanks to the Uniswap Foundation, which sponsored the audit, it doesn't have to be. Certora performed a manual security review of all nine verifiers (July 28 – August 4, 2025) and found zero critical, high, or medium severity issues — four lows and two informationals, five of six fixed before release. The full report ships in the repo.

The one finding we chose not to fix says the most about the design: swaps and mints don't constrain slippage. Our response, preserved verbatim in the report: "This is an intended footgun for the operator." Slippage tolerance is strategy, and strategy accountability lives in the management agreement — not in the permission layer.

Deployment and reuse

These contracts are public infrastructure — anyone can use them, no permission from us required. They are stateless, hold no funds, take no fees, and have no owner or admin: one deployment serves any number of Safes, roles, and token pairs, because the pair lives entirely in the 12 bytes of each permission's metadata. If you run a Safe and want scoped Uniswap V4 access for an operator, you can point your Roles module at them today.

Deployment is deterministic via the EIP-2470 singleton factory, so the nine verifiers land at identical addresses on any EVM-compatible chain. That matters here more than usual: Roles permission payloads embed the verifier address, so permissions stay portable across chains byte-for-byte. The verifiers are live on Arbitrum at the addresses below — and if you need them elsewhere, anyone can permissionlessly replay the deployment through the singleton factory and land on the same addresses:

Verifier Address
Position Mint 0xA0B62aCD2fD69720744d03d5E11b021D715BEc04
Decrease Liquidity 0x34fA7F29A69989cdA0822f3F65F2aA47e3C80f3A
Swap Exact-In Single 0xB21d73547b40438bA661Dd4B6EA99cF938F67Af8
Swap Exact-Out Single 0xc3f0bC7CfFec898EC5aD9d40e4f7eA80d9641B7B
Settle All 0x66ABaaC16293a9d2a291f3C7A88d8B1b65953752
Settle Pair 0x83a502479931508f977321615BA7e6F2AA933Cf1
Take All 0x16e75e91aa5C9c0976d48bd36035A71f2E54A18c
Take Pair 0x78a8bBa010bb4E0EC0A3A50049C77cFd3abEA495
Sweep 0xc6af34fE57e4293D58eA2ad3D68b3f8BaA43A5D4

The whole thing is open source and free to useMIT-licensed on GitHub, audit report included. Fork it, deploy it, wire it into your own Safe. One kind ask: if this ends up in your product, your protocol, your course material — wherever it lands — please credit DAMM Capital as the source. The license doesn't require it, but it would be cool.


Made it this far? Two doors. If this is the kind of problem you want to spend your days on, we hire people who obsess over exactly this — see our careers page. And if you're a potential client who'd like to talk about non-custodial asset management, reach out at team@dammcap.finance or through our contact page.

Explore our fundsDAMMstable →DAMMeth →