Uniswap v4 explicado: qué cambió respecto a v3, cómo funcionan los hooks y un tutorial completo de gestión de liquidez

Uniswap v4 se lanzó en enero de 2025, y no es una v3 con mejor gas. Es un rediseño de cómo se construye un AMM: todos los pools viven ahora dentro de un único contrato, los tokens solo se mueven una vez por transacción y los pools pasaron a ser programables mediante hooks. Para los traders, la diferencia es casi invisible. Para los proveedores de liquidez, y para cualquiera que escriba código que gestione liquidez, casi toda la mecánica del día a día cambió, empezando por el hecho de que ya no existe una función collect().

Este artículo tiene dos partes. Primero, un repaso estructurado de lo que realmente cambió respecto a v3 y de por qué importa. Después, un tutorial completo y práctico: gestionar una posición v4 real desde TypeScript, con cada operación del ciclo de vida (acuñar, aumentar la liquidez, reducir la liquidez, cobrar las comisiones y quemar) y las codificaciones de acciones exactas que espera el protocolo.

Parte 1: qué cambió respecto a v3

Un singleton en lugar de un contrato por pool

En v3, UniswapV3Factory.createPool() despliega un contrato completo para cada pool, y cada contrato de pool guarda sus propios saldos ERC-20. En v4, un único contrato PoolManager contiene todos los pools como estado interno: crear un pool es solo una escritura de estado, lo que lo hace aproximadamente un 99,99 % más barato. Un pool se identifica por su PoolKey, la estructura que usarás en cada operación del tutorial de abajo:

struct PoolKey { Currency currency0; // lower-sorted token; address(0) for native ETH Currency currency1; uint24 fee; // static fee in pips, or the dynamic-fee flag int24 tickSpacing; IHooks hooks; // hook contract, or address(0) for none }
La factory de Uniswap v3 despliega un contrato por pool, frente al PoolManager singleton de Uniswap v4, que contiene los pools como estado con hooks asociados
v3 despliega un contrato por pool; v4 guarda todos los pools dentro de un único PoolManager.

De aquí se derivan dos consecuencias. El mismo par de tokens puede existir ahora con cualquier comisión y cualquier tick spacing, porque los niveles de comisión ya no se limitan al conjunto clásico de 0,05 % / 0,30 % / 1 %: cualquier comisión estática de hasta el 100 % es válida, o una comisión dinámica que un hook puede actualizar. Y el ETH nativo vuelve a ser una moneda de primera clase: se acabó tener que envolverlo obligatoriamente en WETH, lo que ahorra gas y un paso en cada par con ETH.

Contabilidad flash

v4 liquida los saldos con contabilidad flash (flash accounting), construida sobre el almacenamiento transitorio de EIP-1153. Durante una transacción, las operaciones solo acumulan deltas (quién le debe qué al PoolManager); las transferencias ERC-20 reales ocurren una sola vez, en neto, al final del desbloqueo (unlock). Un swap multi-hop que en v3 habría movido tokens entre tres contratos de pool los mueve exactamente una vez en v4. La regla que hace que todo funcione: al final de la transacción, todos los saldos pendientes deben quedar en cero o la transacción entera revierte. Esta regla da forma a la API de gestión de posiciones que usarás más abajo: cada operación termina con una acción de liquidación (SETTLE_PAIR cuando debes tokens, TAKE_PAIR cuando te los deben a ti).

Hooks: los pools se vuelven programables

La funcionalidad estrella. Un hook es un contrato externo asociado a un pool en el momento de su creación (forma parte de la PoolKey, así que el mismo par con dos hooks distintos son dos pools distintos). El PoolManager lo llama en hasta diez puntos del ciclo de vida: antes y después de la inicialización, la adición de liquidez, la retirada de liquidez, el swap y la donación, además de variantes que pueden devolver deltas de saldo y quedarse con una parte de una operación o subvencionarla.

Un detalle de implementación ingenioso: los permisos de un hook están codificados en su propia dirección. Los 14 bits menos significativos de la dirección del contrato del hook declaran qué callbacks implementa, así que quienes lo despliegan minan un salt de CREATE2 hasta que la dirección lleva los flags correctos, y el PoolManager sabe a qué llamar sin necesidad de ningún registro.

Para los proveedores de liquidez, los hooks son a la vez la oportunidad y la letra pequeña:

  • Los hooks de comisión dinámica recalculan la comisión de swap por bloque o por swap, según la volatilidad o el inventario, lo que cambia el APR de comisiones que realmente ganas.
  • Los hooks de estrategia implementan sobre un pool órdenes límite on-chain, TWAMM (time-weighted average market making), reinversión automática de comisiones o vaults completos de rebalanceo.
  • La letra pequeña: un hook también puede quedarse con una parte de las comisiones de swap o de tu retiro, bloquear operaciones o reenviar tu hookData a lógica arbitraria. Leer el contrato del hook antes de aportar liquidez a un pool con hook no es opcional; forma parte de la diligencia debida. Cada operación de posición en v4 lleva un parámetro hookData precisamente porque los hooks pueden esperar datos de tu parte.

Las posiciones siguen existiendo, pero se gestionan con comandos

Como en v3, una posición v4 es un ERC-721 acuñado por un PositionManager periférico. A diferencia de v3, ese contrato ya no expone una función por operación. Tiene un único punto de entrada:

function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable;

unlockData es un par (actions, params) codificado en ABI: una lista empaquetada de bytes de acción y un blob de parámetros por cada acción. Agrupas lo que quieres hacer (por ejemplo, reducir la liquidez y después retirar ambos tokens) y el PositionManager ejecuta el lote de forma atómica dentro de un único unlock. Parece de más bajo nivel que v3, y lo es, pero también es estrictamente más potente: cualquier combinación de operaciones se liquida como una sola transacción con un único movimiento neto de tokens.

Las diferencias entre v3 y v4 que le importan a un LP, una al lado de la otra:

Uniswap v3Uniswap v4
Despliegue de poolsUn contrato por poolEstado dentro del singleton PoolManager
ETH nativoSolo WETHAdmite pares con ETH nativo
Niveles de comisiónConjunto fijo (0,01–1 %)Cualquier comisión estática, o dinámica mediante un hook
PersonalizaciónNingunaHooks en más de 10 puntos del ciclo de vida
API de posicionesUna función por operaciónUn único modifyLiquidities() con acciones codificadas
Cobro de comisionescollect()DECREASE_LIQUIDITY con liquidez cero
Liquidación de tokensTransferencias en cada operaciónContabilidad flash, liquidación neta (EIP-1153)
AprobacionesAprobar el position managerAprobar mediante Permit2

Parte 2: el tutorial de gestión de liquidez

Todo lo que sigue se ejecuta contra el pool canónico ETH/USDC 0,05 % en Base, desde TypeScript con viem. El mismo código funciona en cualquier cadena con v4 en cuanto sustituyes las direcciones por las de la página oficial de despliegues. Recorreremos todo el ciclo de vida en orden:

Las cinco etapas de una posición de Uniswap v4: acuñar, aumentar, cobrar, reducir y quemar, cada una como un lote de acciones a través de modifyLiquidities
Un punto de entrada, cinco operaciones. Cobrar es la que sorprende: es una reducción de cero.

Preparación: direcciones, clientes y la clave del pool

npm install viem @uniswap/v3-sdk @uniswap/sdk-core jsbi

Usamos @uniswap/v3-sdk solo por sus matemáticas (conversión tick ↔ precio y cálculo de liquidez); la matemática de ticks es idéntica en v3 y en v4.

config.ts
import { createPublicClient, createWalletClient, http, parseAbi } from 'viem'
import { base } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'

// Uniswap v4 on Base (docs.uniswap.org/contracts/v4/deployments)
export const POSITION_MANAGER = '0x7c5f5a4bbd8fd63184577525326123b519429bdc'
export const STATE_VIEW = '0xa3c0c9b65bad0b08107aa264b0f3db444b867a71'
export const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3'
export const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
export const NATIVE_ETH = '0x0000000000000000000000000000000000000000'

export const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
export const publicClient = createPublicClient({ chain: base, transport: http() })
export const walletClient = createWalletClient({ account, chain: base, transport: http() })

export const posmAbi = parseAbi([
  'function modifyLiquidities(bytes unlockData, uint256 deadline) payable',
  'function nextTokenId() view returns (uint256)',
  'function getPositionLiquidity(uint256 tokenId) view returns (uint128)',
])

export const stateViewAbi = parseAbi([
  'function getSlot0(bytes32 poolId) view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)',
])

// The ETH/USDC 0.05% pool. currency0 < currency1, and native ETH is address(0),
// so ETH is always currency0 in its pairs.
export const poolKey = {
  currency0: NATIVE_ETH,
  currency1: USDC,
  fee: 500,
  tickSpacing: 10,
  hooks: '0x0000000000000000000000000000000000000000',
} as const

La función auxiliar de codificación

Cada operación es un (actions, params) codificado en un único blob bytes. Los valores de los bytes de acción vienen de Actions.sol en v4-periphery:

encoding.ts
import { encodeAbiParameters, encodePacked, keccak256 } from 'viem'
import { poolKey } from './config'

export const Actions = {
  INCREASE_LIQUIDITY: 0x00,
  DECREASE_LIQUIDITY: 0x01,
  MINT_POSITION: 0x02,
  BURN_POSITION: 0x03,
  SETTLE_PAIR: 0x0d,
  TAKE_PAIR: 0x11,
  SWEEP: 0x14,
} as const

export const POOL_KEY_ABI = {
  type: 'tuple',
  components: [
    { name: 'currency0', type: 'address' },
    { name: 'currency1', type: 'address' },
    { name: 'fee', type: 'uint24' },
    { name: 'tickSpacing', type: 'int24' },
    { name: 'hooks', type: 'address' },
  ],
} as const

export function encodeUnlockData(actions: number[], params: `0x${string}`[]) {
  return encodeAbiParameters(
    [{ type: 'bytes' }, { type: 'bytes[]' }],
    [encodePacked(actions.map(() => 'uint8'), actions), params],
  )
}

// A pool's id is the hash of its key
export const poolId = keccak256(encodeAbiParameters([POOL_KEY_ABI], [poolKey]))

export const deadline = () => BigInt(Math.floor(Date.now() / 1000) + 600)

Aprobaciones únicas, a través de Permit2

El PositionManager de v4 no retira los tokens con un allowance ERC-20 directo; usa Permit2. Así que cada ERC-20 necesita dos aprobaciones, una sola vez: el token aprueba a Permit2 y Permit2 aprueba al PositionManager. El ETH nativo no necesita ninguna aprobación; viaja como msg.value.

approve.ts
import { maxUint160, maxUint256, parseAbi } from 'viem'
import { PERMIT2, POSITION_MANAGER, USDC, walletClient } from './config'

const erc20Abi = parseAbi(['function approve(address spender, uint256 amount) returns (bool)'])
const permit2Abi = parseAbi([
  'function approve(address token, address spender, uint160 amount, uint48 expiration)',
])

await walletClient.writeContract({
  address: USDC, abi: erc20Abi, functionName: 'approve',
  args: [PERMIT2, maxUint256],
})

await walletClient.writeContract({
  address: PERMIT2, abi: permit2Abi, functionName: 'approve',
  args: [USDC, POSITION_MANAGER, maxUint160, 2 ** 48 - 1],
})

Acuñar una posición

Acuñar son tres acciones: MINT_POSITION crea la posición, SETTLE_PAIR paga los dos tokens y, como uno de los lados es ETH nativo, SWEEP devuelve el ETH sobrante que hayamos enviado. Calculamos la cantidad de liquidez a partir de los tokens que estamos dispuestos a depositar, con las matemáticas del SDK de v3 (un paso de tick es un 0,01 %, así que ±500 ticks equivalen aproximadamente a un rango de ±5 %):

mint.ts
import { TickMath, maxLiquidityForAmounts, nearestUsableTick } from '@uniswap/v3-sdk'
import JSBI from 'jsbi'
import { encodeAbiParameters, parseEther } from 'viem'
import { account, poolKey, posmAbi, publicClient, walletClient,
         POSITION_MANAGER, STATE_VIEW, stateViewAbi, NATIVE_ETH } from './config'
import { Actions, POOL_KEY_ABI, deadline, encodeUnlockData, poolId } from './encoding'

// 1. Where is the pool trading right now?
const [sqrtPriceX96, tick] = await publicClient.readContract({
  address: STATE_VIEW, abi: stateViewAbi, functionName: 'getSlot0', args: [poolId],
})

// 2. A ±5% range around the current tick, aligned to the pool's tick spacing
const tickLower = nearestUsableTick(tick - 500, poolKey.tickSpacing)
const tickUpper = nearestUsableTick(tick + 500, poolKey.tickSpacing)

// 3. How much liquidity do 0.05 ETH + 120 USDC buy in that range?
const amount0 = parseEther('0.05')
const amount1 = 120_000_000n // USDC has 6 decimals
const liquidity = maxLiquidityForAmounts(
  JSBI.BigInt(sqrtPriceX96.toString()),
  TickMath.getSqrtRatioAtTick(tickLower),
  TickMath.getSqrtRatioAtTick(tickUpper),
  amount0.toString(), amount1.toString(), true,
)

// 4. Slippage caps: revert if the pool asks for more than +0.5%
const amount0Max = (amount0 * 1005n) / 1000n
const amount1Max = (amount1 * 1005n) / 1000n

// The next minted position will get this id
const tokenId = await publicClient.readContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'nextTokenId',
})

const params = [
  encodeAbiParameters(
    [POOL_KEY_ABI, { type: 'int24' }, { type: 'int24' }, { type: 'uint256' },
     { type: 'uint128' }, { type: 'uint128' }, { type: 'address' }, { type: 'bytes' }],
    [poolKey, tickLower, tickUpper, BigInt(liquidity.toString()),
     amount0Max, amount1Max, account.address, '0x'],
  ),
  encodeAbiParameters(
    [{ type: 'address' }, { type: 'address' }],
    [poolKey.currency0, poolKey.currency1],
  ),
  encodeAbiParameters([{ type: 'address' }, { type: 'address' }], [NATIVE_ETH, account.address]),
]

const hash = await walletClient.writeContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
  args: [
    encodeUnlockData([Actions.MINT_POSITION, Actions.SETTLE_PAIR, Actions.SWEEP], params),
    deadline(),
  ],
  value: amount0Max, // currency0 is native ETH; SWEEP refunds the unused part
})

console.log(`Position ${tokenId} minted in tx ${hash}`)

Dos detalles que merecen una pausa. hookData es '0x' porque este pool no tiene hook; en un pool con hook, aquí es donde van los datos que espera el hook. Y leemos nextTokenId antes de acuñar: modifyLiquidities no devuelve el id, así que esta es la forma estándar de saber qué ERC-721 acabas de recibir.

Aumentar la liquidez

Añadir a una posición existente reutiliza el mismo patrón con INCREASE_LIQUIDITY, que recibe el tokenId en lugar de una clave de pool y un rango. Una sutileza de v4: los ingresos por comisiones se acreditan automáticamente cuando aumentas, así que si la posición ha acumulado comisiones, estas compensan parte de lo que debes y la liquidación solo cobra la diferencia.

increase.ts
const params = [
  encodeAbiParameters(
    [{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint128' },
     { type: 'uint128' }, { type: 'bytes' }],
    [tokenId, BigInt(addedLiquidity.toString()), amount0Max, amount1Max, '0x'],
  ),
  encodeAbiParameters(
    [{ type: 'address' }, { type: 'address' }],
    [poolKey.currency0, poolKey.currency1],
  ),
  encodeAbiParameters([{ type: 'address' }, { type: 'address' }], [NATIVE_ETH, account.address]),
]

await walletClient.writeContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
  args: [
    encodeUnlockData([Actions.INCREASE_LIQUIDITY, Actions.SETTLE_PAIR, Actions.SWEEP], params),
    deadline(),
  ],
  value: amount0Max,
})

Si quieres que sean las propias comisiones acumuladas las que financien el aumento, v4 tiene acciones de liquidación dedicadas: CLOSE_CURRENCY decide por cada moneda si liquidar o retirar el saldo restante, y CLEAR_OR_TAKE renuncia a los restos (dust) por debajo de un umbral en lugar de pagar más en gas de lo que valen. Esas dos existen precisamente porque «reinvertir mis comisiones en la posición» es el movimiento de gestión de liquidez más habitual que hay.

Cobrar las comisiones

Esta es la operación que sorprende a todo desarrollador que viene de v3: v4 no tiene función de cobro. Las comisiones se cobran reduciendo la liquidez en cero. La acción DECREASE_LIQUIDITY acredita las comisiones acumuladas como efecto secundario, así que una reducción de cero acredita solo las comisiones, y TAKE_PAIR las envía a tu dirección:

collect-fees.ts
const params = [
  // liquidity = 0, amount mins = 0: a pure fee collection.
  // Zero mins are safe here because fee collection cannot be front-run.
  encodeAbiParameters(
    [{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint128' },
     { type: 'uint128' }, { type: 'bytes' }],
    [tokenId, 0n, 0n, 0n, '0x'],
  ),
  encodeAbiParameters(
    [{ type: 'address' }, { type: 'address' }, { type: 'address' }],
    [poolKey.currency0, poolKey.currency1, account.address],
  ),
]

await walletClient.writeContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
  args: [
    encodeUnlockData([Actions.DECREASE_LIQUIDITY, Actions.TAKE_PAIR], params),
    deadline(),
  ],
})

No hay ningún evento ni valor de retorno que te indique directamente las cantidades cobradas: lee tus saldos de tokens antes y después de la llamada. Si ejecutas esto de forma programada (y deberías: las comisiones paradas no rinden nada), esa diferencia de saldos es también tu contabilidad de ingresos por comisiones.

Reducir la liquidez

Una reducción real es el mismo lote con una cantidad de liquidez distinta de cero, y aquí los parámetros de cantidad mínima dejan de ser opcionales: son tu protección contra el slippage si el pool se mueve (o alguien lo mueve) entre la cotización y la ejecución. Calcula off-chain las cantidades esperadas para la liquidez que retiras y pasa como suelo esas cantidades con un pequeño recorte:

decrease.ts
// Current liquidity, straight from the position manager
const currentLiquidity = await publicClient.readContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'getPositionLiquidity',
  args: [tokenId],
})

const liquidityToRemove = currentLiquidity / 4n // trim 25%

// expected0/expected1: quote them off-chain from sqrtPriceX96 and your range
// (the SDK's Position class does this), then floor them at -0.5%:
const amount0Min = (expected0 * 995n) / 1000n
const amount1Min = (expected1 * 995n) / 1000n

const params = [
  encodeAbiParameters(
    [{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint128' },
     { type: 'uint128' }, { type: 'bytes' }],
    [tokenId, liquidityToRemove, amount0Min, amount1Min, '0x'],
  ),
  encodeAbiParameters(
    [{ type: 'address' }, { type: 'address' }, { type: 'address' }],
    [poolKey.currency0, poolKey.currency1, account.address],
  ),
]

await walletClient.writeContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
  args: [
    encodeUnlockData([Actions.DECREASE_LIQUIDITY, Actions.TAKE_PAIR], params),
    deadline(),
  ],
})

Como los ingresos por comisiones se acreditan automáticamente en cada reducción, los tokens que recibes son el principal más todas las comisiones acumuladas. Una reducción es siempre, implícitamente, también un cobro.

Quemar la posición

Salir por completo no requiere reducir antes. BURN_POSITION retira toda la liquidez restante (y las comisiones), elimina el ERC-721, y TAKE_PAIR lo paga todo:

burn.ts
const params = [
  encodeAbiParameters(
    [{ type: 'uint256' }, { type: 'uint128' }, { type: 'uint128' }, { type: 'bytes' }],
    [tokenId, amount0Min, amount1Min, '0x'],
  ),
  encodeAbiParameters(
    [{ type: 'address' }, { type: 'address' }, { type: 'address' }],
    [poolKey.currency0, poolKey.currency1, account.address],
  ),
]

await walletClient.writeContract({
  address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
  args: [
    encodeUnlockData([Actions.BURN_POSITION, Actions.TAKE_PAIR], params),
    deadline(),
  ],
})

Referencia rápida

OperaciónAccionesSentido de la liquidación
AcuñarMINT_POSITION + SETTLE_PAIR (+ SWEEP para ETH)Pagas tú
AumentarINCREASE_LIQUIDITY + SETTLE_PAIR (+ SWEEP para ETH)Pagas tú (menos las comisiones acumuladas)
Cobrar comisionesDECREASE_LIQUIDITY (liquidez = 0) + TAKE_PAIRRecibes tú
ReducirDECREASE_LIQUIDITY + TAKE_PAIRRecibes tú (principal + comisiones)
QuemarBURN_POSITION + TAKE_PAIRLo recibes todo

Y los hábitos que se trasladan desde v3, con un matiz: las aprobaciones pasan por Permit2, no por el position manager; los límites de slippage son parámetros de cada acción (amountMax al entrar, amountMin al salir, cero solo para un cobro puro de comisiones); ETH nativo significa msg.value más un SWEEP; y en los pools con hook, comprueba siempre qué hace el hook antes de que tu capital lo toque.

De un script a una estrategia

Todo lo anterior gestiona una posición, una vez. Una estrategia de liquidez real es este bucle funcionando sin parar: vigilar el tick del pool frente a tu rango, cobrar y reinvertir las comisiones cuando cubren el gas, rebalancear cuando el precio sale del rango, retirar liquidez cuando la volatilidad o el comportamiento de un hook se vuelven en tu contra, y registrar cada operación. Entre v4 en las cadenas EVM, Slipstream en Aerodrome, los forks de Algebra y Raydium u Orca en Solana, cada plataforma habla un dialecto distinto del mismo ciclo de vida que acabas de aprender.

Ese bucle es lo que construimos en Etherwave Labs: automatización sin custodia que ejecuta acuñaciones, aumentos, reducciones, cobros y rebalanceos sin interrupción, con simulación antes de cada transacción y supervisión después, en Uniswap v3/v4, Slipstream, Algebra V1, Raydium y Orca. Explicamos cómo lo abordamos en nuestra página del servicio de automatización de la gestión de liquidez y, si prefieres que tus posiciones se gestionen solas, contáctanos.

Más artículos

¿Quieres llevar tu proyecto al siguiente nivel?

Contáctanos hoy para ver cómo podemos ayudarte a alcanzar tus objetivos en el sector blockchain.