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
- 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 sendstokenizeStreams. - 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
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 andextraAssets, 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.setApprovalForAll(doorFactory, true)on the PositionManager, both named by the tokenizer (doorFactory(),positionManager()), or a permit in the call itself.tokenizePositions({ tokenIds, unlockAt, permits }, t, l, m): 1 to 64 positions, each with liquidity, andunlockAtat least 7 days from now or the largestuint64for good. The tokenizer emitsPositionReportedfor each position, makes the door, moves the positions in and mints.l.unlockBlockis read only when a market opens, and the value sent is the launch fee then and nothing otherwise.- Once
unlockAthas passed, anyone callsrelease()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.