Skip to content

Tokenize Uniswap v4 positions

From /tokenize or a script: previewPositions, an approval or a signed permit, then tokenizePositions with a term.

On this page

Uniswap v4 positions go into a door the tokenizer makes for them, locked until a date or for good, and the revenue token's income is their LP fees.

In the app

  1. Open /tokenize. Your wallet lists the wallet's v4 positions with their pools, and its Hookr's leg column shows each one's leg. Tick them, press Tokenize, set the terms and sign: this tab approves the door factory once with setApprovalForAll, then sends tokenizeStreams.
  2. Or open By id and its Uniswap v4 position NFTs card. Pick the positions, and the card previews Hookr's leg and the token's assets. Sign and tokenize asks for one signature: a permit for one position, or a permit for all of them for several. The tokenizer applies it before it moves them.

A wallet whose address has code, or that cannot sign typed data, gets Approve and tokenize instead. This card sends tokenizePositions.

The calls

  1. previewPositions(tokenIds, extraAssets) on the tokenizer gives what the mint will fix, read from the PositionManager: legBps, under Fee below, the token's assets (ETH, each position's pool currencies and extraAssets, at most four), and for each position whether its pool is on the Hookr root and whether its hook runs on liquidity removals and so could make the door's collection of it fail.
  2. setApprovalForAll(doorFactory, true) on the PositionManager, both named by the tokenizer (doorFactory(), positionManager()), or a permit in the call itself.
  3. tokenizePositions({ tokenIds, unlockAt, permits }, t, l, m): 1 to 64 positions, each with liquidity, and unlockAt at least 7 days from now or the largest uint64 for good. The tokenizer emits PositionReported for each position, makes the door, moves the positions in and mints. l.unlockBlock is read only when a market opens, and the value sent is the launch fee then and nothing otherwise.
  4. Once unlockAt has passed, anyone calls release() on the door, and each position goes back to its depositor after a last collection: Terms and endings.

In code

With client.ts and abis.ts:

import { createWalletClient, http, keccak256, parseUnits, toHex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { robinhood } from "viem/chains";
import { client, TOKENIZER } from "./client";
import { positionManagerAbi, tokenizerAbi } from "./abis";

const creator = privateKeyToAccount(process.env.CREATOR_KEY as `0x${string}`);
const wallet = createWalletClient({ account: creator, chain: robinhood, transport: http() });
const POSITIONS = [111n, 222n]; // your Uniswap v4 position NFTs

// 1. What the mint will fix: Hookr's leg and the token's assets, read from the PositionManager.
const [legBps, assets, hookrPool] = await client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "previewPositions", args: [POSITIONS, []] });
console.log({ legBps, assets, hookrPool });

// 2. Let the tokenizer's door factory move the positions.
const [positionManager, doorFactory] = await Promise.all([
  client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "positionManager" }),
  client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "doorFactory" }),
]);
if (!(await client.readContract({ address: positionManager, abi: positionManagerAbi, functionName: "isApprovedForAll", args: [creator.address, doorFactory] }))) {
  const hash = await wallet.writeContract({ address: positionManager, abi: positionManagerAbi, functionName: "setApprovalForAll", args: [doorFactory, true] });
  await client.waitForTransactionReceipt({ hash });
}

// 3. The terms, with no market: with `open` false the tokenizer reads no other field of MarketTerms.
const supply = parseUnits("1000000000", 18);
const leg = (supply * BigInt(legBps)) / 10_000n;
const terms = {
  name: "Example LP Revenue",
  symbol: "rLP",
  supply,
  streamSeconds: 86_400,
  tipBps: 100,
  extraAssets: [],
  recipients: [creator.address],
  amounts: [supply - leg],
  termsHash: keccak256(toHex("rLP: two v4 positions, 30 days")),
};
const noMarket = { ...(await client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "defaultMarket", args: [2n ** 96n, 1n] })), open: false };
const unlockAt = BigInt(Math.floor(Date.now() / 1000)) + 30n * 86_400n; // 30 days; at least 7, or FOREVER

// 4. Lock the positions in a door and mint.
const { request } = await client.simulateContract({
  account: creator,
  address: TOKENIZER,
  abi: tokenizerAbi,
  functionName: "tokenizePositions",
  args: [{ tokenIds: POSITIONS, unlockAt, permits: [] }, terms, { unlockBlock: 0n }, noMarket],
});
await client.waitForTransactionReceipt({ hash: await wallet.writeContract(request) });

To sign instead of sending the approval:

// Instead of step 2: one signature the tokenizer applies before it moves the positions.
// The PositionManager's nonces are unordered, a word and a bit: a fresh random word, bit 0.
const nonce = BigInt(toHex(crypto.getRandomValues(new Uint8Array(31)))) << 8n;
const deadline = BigInt(Math.floor(Date.now() / 1000)) + 3_600n;
const signature = await creator.signTypedData({
  domain: { name: "Uniswap v4 Positions NFT", chainId: robinhood.id, verifyingContract: positionManager },
  types: {
    PermitForAll: [
      { name: "operator", type: "address" },
      { name: "approved", type: "bool" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
    ],
  },
  primaryType: "PermitForAll",
  message: { operator: doorFactory, approved: true, nonce, deadline },
});
const permits = [{ forAll: true, tokenId: 0n, deadline, nonce, signature }];

A permit for all of a wallet's positions stays in force after the mint; setApprovalForAll(doorFactory, false) ends it. For one position, sign a Permit(spender, tokenId, nonce, deadline) to the door factory and send it with forAll: false; the move uses it up. The PositionManager's domain has its name, the chain and its address, and no version.

Fee

When every position's pool is on the Hookr hook, the positions already pay Hookr through it and the mint gives Hookr no leg. When any pool is off it, Hookr's leg is 5% of the supply, minted to the leg collector. previewPositions gives the figure before the mint, and the code above takes it out of the recipients' amount.