Skip to content

Fluid DEX V2: Liquidity Source Integration ​

How to route swaps through Fluid DEX V2: where the pools are, how a swap is executed and settled, how to read pool state, and what can make it revert.

D3 and D4 pools only. DexV2 serves two pool types: D3 (smart collateral, Uniswap-v3-style concentrated liquidity) and D4 (smart debt, the same engine on borrowed liquidity). Anyone can swap on both, through the same calls. Dex types 1 and 2 are not live.

Table of Contents ​

  1. Addresses
  2. Pools
  3. How a swap runs
  4. Swap calldata
  5. Settling tokens
  6. Adapter example
  7. Router
  8. Reading pool state
  9. Limits and reverts
  10. Fees and gas
  11. Events to watch

Addresses ​

EthereumPolygon
FluidDexV2 (proxy, call this)0xaBE4D5AA1Bb188f4B5d3a1aE3B8ec5e300A782610x17Eb78c5F7667C30ca241F56eCfE1e3b233b1E4F
FluidDexV2Resolver (read-only)0x143c3927EaF0e50a59b1651C2Eb1e37FB62Aa2a90xA206dd5502A09Ab106dfc95f9cd8C378CF497eAf
FluidDexV2Router0x34d73ec75C1283186bD5F09bc24a4b56afDdd00Cnot deployed
Fluid Liquidity0x52Aa899454998Be5b000Ad077a46Bbe360F4e4970x52Aa899454998Be5b000Ad077a46Bbe360F4e497

Aggregators integrate against the proxy through the callback flow below. Wallets and EOAs that cannot implement the callbacks swap through the router. Source code is verified on Etherscan: FluidDexV2, FluidDexV2Router; the D3 / D4 module addresses are on the contract addresses page.

Pools ​

A pool is identified by its DexKey and its dex type (3 = D3, 4 = D4):

solidity
struct DexKey {
    address token0; // token0 < token1 by address
    address token1;
    uint24 fee; // static LP fee, 1e6 = 100% (<= 65535), or 0xFFFFFF for a dynamic-fee pool managed by `controller`
    uint24 tickSpacing; // 1 to 500
    address controller; // address(0) unless the pool has a controller
}
// dexId = keccak256(abi.encode(dexKey))

The native token is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE. The same pair can have several D3 and D4 pools (one per fee / tick spacing / controller).

Discovery. The resolver returns the pools listed on the Fluid Money Market, which are the reviewed ones:

solidity
function getD3PermissionedDexes() external view returns (DexKey[] memory);
function getD4PermissionedDexes() external view returns (DexKey[] memory);

Every pool, listed or not, emits on creation (from the proxy):

solidity
event LogInitialize(uint256 indexed dexType, bytes32 indexed dexId, DexKey dexKey, uint256 sqrtPriceX96);

How a swap runs ​

DexV2 never transfers tokens inside a swap. The swap records what you owe and what you are owed; you then settle each token, all inside one operation opened by your contract:

your contract                          FluidDexV2 (proxy)
  │ startOperation(data) ─────────────► opens the operation
  │ ◄───────────────────────────────── startOperationCallback(data)
  │ operate(dexType, 1, swapCalldata) ─► runs the swap, records pending amounts
  │ settle(tokenIn, ...) ─────────────► pulls the token in (dexCallback or transferFrom)
  │ settle(tokenOut, ...) ────────────► pays the token out to `to`
  │ return ───────────────────────────► requires nothing pending, else reverts
  1. Your contract calls startOperation(bytes data); the dex calls back startOperationCallback(data) on it.
  2. Inside the callback, call operate(dexType, 1, swapCalldata) (implementation id 1 = swap module) once per swap.
  3. Settle each token touched with settle(...).
  4. When the callback returns, any unsettled amount reverts the whole operation with PendingTransfersNotCleared(bytes result). Otherwise startOperation returns what the callback returned.

An EOA cannot call startOperation directly: the caller must implement the callbacks.

Only one operation can be active on the proxy at a time. A nested startOperation, from your callback or from any contract it calls, reverts with empty data, so do not call anything inside the callback that opens its own operation. Pending amounts are tracked per caller: the contract that calls operate must also call settle.

solidity
interface IFluidDexV2 {
    function startOperation(bytes calldata data_) external returns (bytes memory result_);
    function operate(uint256 dexType_, uint256 implementationId_, bytes memory data_) external returns (bytes memory returnData_);
    function settle(address token_, int256 supplyAmount_, int256 borrowAmount_, int256 storeAmount_, address to_, bool isCallback_) external payable;
}

interface IDexV2Callbacks {
    function startOperationCallback(bytes calldata data_) external returns (bytes memory);
    function dexCallback(address token_, address to_, uint256 amount_) external;
}

Swap calldata ​

solidity
struct SwapInParams {
    DexKey dexKey;
    bool swap0To1; // true: pay token0, receive token1
    uint256 amountIn;
    uint256 amountOutMin;
    bytes controllerData; // forwarded to the pool's controller; empty for pools without one
}

struct SwapOutParams {
    DexKey dexKey;
    bool swap0To1;
    uint256 amountOut;
    uint256 amountInMax;
    bytes controllerData;
}

function swapIn(SwapInParams calldata params_) external returns (uint256 amountOut_, uint256 protocolFeeAccrued_, uint256 lpFeeAccrued_);
function swapOut(SwapOutParams calldata params_) external returns (uint256 amountIn_, uint256 protocolFeeAccrued_, uint256 lpFeeAccrued_);

Encode with abi.encodeCall and pass as operate(dexType, 1, data). Selectors:

swapIn(((address,address,uint24,uint24,address),bool,uint256,uint256,bytes))   -> 0x8132635a
swapOut(((address,address,uint24,uint24,address),bool,uint256,uint256,bytes))  -> 0x4773f3df

operate returns the module's return data: decode (uint256, uint256, uint256). amountOut_ of swapIn is net of all fees; amountIn_ of swapOut includes them. The same calldata works on D3 and D4; only dexType differs.

Settling tokens ​

Settle each token once, with type(int128).max for both amounts ("my entire pending amount"):

solidity
// token in: you pay, pulled through your dexCallback
dex.settle(tokenIn, type(int128).max, type(int128).max, 0, address(0), true);
// token out: paid to `recipient`
dex.settle(tokenOut, type(int128).max, type(int128).max, 0, recipient, false);

This works for D3 and D4 alike: the net is the amount in on the token in and the amount out on the token out.

  • isCallback_ = true: the dex calls dexCallback(token, to, amount) on msg.sender and you transfer amount to to. to is either the dex proxy or the Liquidity contract, depending on where the dex serves the settle from; accept exactly those two.
  • isCallback_ = false: the dex pulls with transferFrom from msg.sender, so approve the dex proxy.
  • Native token: send the amount in as msg.value of that settle (the excess is refunded to msg.sender, so your contract needs receive()); isCallback_ is ignored. Native token out is sent to to.
  • to = address(0) means msg.sender.
  • A recipient that cannot receive the token (for example a contract without receive() for native) reverts the whole operation.
  • Standard ERC20 only: fee-on-transfer and rebasing tokens are not supported.

Adapter example ​

A complete exact-input adapter. payer is kept in transient storage so that dexCallback pulls from the right account without tx.origin. Tested on an Ethereum mainnet fork against the live D3 and D4 USDC / USDT pools.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IFluidDexV2 {
    function startOperation(bytes calldata data_) external returns (bytes memory result_);
    function operate(uint256 dexType_, uint256 implementationId_, bytes memory data_) external returns (bytes memory returnData_);
    function settle(address token_, int256 supplyAmount_, int256 borrowAmount_, int256 storeAmount_, address to_, bool isCallback_) external payable;
}

interface IDexV2Callbacks {
    function startOperationCallback(bytes calldata data_) external returns (bytes memory);
    function dexCallback(address token_, address to_, uint256 amount_) external;
}

struct DexKey { address token0; address token1; uint24 fee; uint24 tickSpacing; address controller; }
struct SwapInParams { DexKey dexKey; bool swap0To1; uint256 amountIn; uint256 amountOutMin; bytes controllerData; }

interface IDexV2SwapModule {
    function swapIn(SwapInParams calldata params_) external returns (uint256 amountOut_, uint256 protocolFeeAccrued_, uint256 lpFeeAccrued_);
}

contract FluidDexV2SwapAdapter is IDexV2Callbacks {
    using SafeERC20 for IERC20;

    IFluidDexV2 public immutable DEX_V2;
    address public immutable LIQUIDITY;
    uint256 internal constant SWAP_MODULE_ID = 1;

    address transient payer; // who pays the token in of the current operation

    struct Swap {
        uint256 dexType; // 3 or 4
        bytes swapData; // abi.encodeCall of swapIn
        address tokenIn;
        address tokenOut;
        address recipient;
    }

    constructor(address dexV2_, address liquidity_) {
        DEX_V2 = IFluidDexV2(dexV2_);
        LIQUIDITY = liquidity_;
    }

    /// @notice Exact-input swap. The caller has approved this contract for `params_.amountIn`.
    function swapIn(uint256 dexType_, SwapInParams calldata params_, address recipient_) external returns (uint256 amountOut_) {
        Swap memory s_ = Swap({
            dexType: dexType_,
            swapData: abi.encodeCall(IDexV2SwapModule.swapIn, (params_)),
            tokenIn: params_.swap0To1 ? params_.dexKey.token0 : params_.dexKey.token1,
            tokenOut: params_.swap0To1 ? params_.dexKey.token1 : params_.dexKey.token0,
            recipient: recipient_
        });
        payer = msg.sender;
        bytes memory result_ = DEX_V2.startOperation(abi.encode(s_));
        (amountOut_, , ) = abi.decode(result_, (uint256, uint256, uint256));
    }

    function startOperationCallback(bytes calldata data_) external returns (bytes memory result_) {
        require(msg.sender == address(DEX_V2), "not the dex");
        Swap memory s_ = abi.decode(data_, (Swap));

        result_ = DEX_V2.operate(s_.dexType, SWAP_MODULE_ID, s_.swapData);

        DEX_V2.settle(s_.tokenIn, type(int128).max, type(int128).max, 0, address(0), true);
        DEX_V2.settle(s_.tokenOut, type(int128).max, type(int128).max, 0, s_.recipient, false);
    }

    function dexCallback(address token_, address to_, uint256 amount_) external {
        require(msg.sender == address(DEX_V2), "not the dex");
        require(to_ == address(DEX_V2) || to_ == LIQUIDITY, "unexpected recipient");
        IERC20(token_).safeTransferFrom(payer, to_, amount_);
    }
}

Several swaps (multi-hop, any pools) can run in one operation: call operate for each, then settle each token once at the end. Pending amounts of a token net across the swaps, so an intermediate token needs no settle when the next hop's amountIn equals the previous hop's amountOut exactly. Pass the returned amountOut_ on unchanged: any difference, even 1 wei, stays pending and must be settled or the operation reverts. Settling a token that nets to zero is harmless.

Router ​

FluidDexV2Router runs a whole swap for the caller: it opens the operation, swaps along a path of D3 / D4 pools, settles every token and refunds unused native value. It holds no funds between transactions. Approve the router (not the proxy) for ERC20 inputs; it pulls them from msg.sender during settle. Send native inputs as msg.value (the native token is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE in the path); the excess is refunded to msg.sender. The output is paid by the dex straight to to.

solidity
struct Hop {
    uint256 dexType; // 3 = D3, 4 = D4
    uint24 fee; // the pool's DexKey.fee
    uint24 tickSpacing; // the pool's DexKey.tickSpacing
    address controller; // the pool's DexKey.controller
    bytes controllerData; // forwarded to the controller's fee fetch, empty if none
}

interface IFluidDexV2Router {
    // exact input: amountIn of path[0] for at least amountOutMin of path[path.length - 1]
    function swapIn(
        address[] calldata path,
        Hop[] calldata hops,
        uint256 amountIn,
        uint256 amountOutMin,
        address to,
        uint256 deadline
    ) external payable returns (uint256 amountOut);

    // exact output: amountOut of the last token for at most amountInMax of path[0]; unused native value is refunded
    function swapOut(
        address[] calldata path,
        Hop[] calldata hops,
        uint256 amountOut,
        uint256 amountInMax,
        address to,
        uint256 deadline
    ) external payable returns (uint256 amountIn);

    // quotes: call through eth_call only (they run the swap and revert it)
    function quoteSwapIn(address[] calldata path, Hop[] calldata hops, uint256 amountIn) external returns (uint256 amountOut);
    function quoteSwapOut(address[] calldata path, Hop[] calldata hops, uint256 amountOut) external returns (uint256 amountIn);
}
  • path[i] swaps into path[i + 1], with one Hop per pair (hops.length == path.length - 1). The router sorts the two tokens into the DexKey, so the direction follows from the path.
  • The quotes use the real swap engine (dynamic and controller fees, every limit) and match the swap at the same state. A quote is not a swap guarantee: it does not settle, so it does not test the Liquidity conditions a settle can revert on (Limits and reverts), and the result moves between the quote and execution (other swaps, and Liquidity exchange prices that accrue every second).
  • Always set amountOutMin / amountInMax, derived from the quote with a tolerance. The exact quoted amount as the bound can revert even when no other swap happened in between.
  • Send the quote eth_call with a non-zero from. With the zero address, the default of many clients, it reverts with 220007.
  • to cannot be address(0) or the router. A path that ends in its input token (a loop) can only pay msg.sender.
  • to must accept native tokens when the output is native.
  • Router errors are FluidDexV2RouterError(uint256 id) with ids 220001–220011 (220003 deadline passed, 220004 path and hops do not match, 220005 dex type other than 3 / 4, 220006 invalid recipient, 220009msg.value below the native input). Swap and limit errors from the dex bubble up unchanged.

The router costs about 13k gas more per single-hop swap than calling the proxy from your own contract.

Reading pool state ​

The resolver returns what an off-chain router needs to price a swap. For an exact on-chain quote, eth_call the router's quoteSwapIn / quoteSwapOut.

solidity
// current tick, sqrt price (Q64.96), active liquidity and fee configuration of a pool
function getDexPoolState(uint256 dexType_, DexKey memory dexKey_) external view returns (DexPoolState memory);
// the liquidity along the ticks from startTick_ to endTick_, starting from startLiquidity_ (the active liquidity)
function getLiquidityAmounts(
    uint256 dexType_,
    DexKey memory dexKey_,
    int24 startTick_,
    int24 endTick_,
    uint256 startLiquidity_
) external view returns (TickLiquidity[] memory);
// what the dex can pay out of a token in one settle
function getTokenBaseState(address token_) external view returns (TokenBaseState memory);

Pool math runs in a normalised space: amounts are scaled to 9 decimals and divided by the token's Liquidity exchange price (supply price for D3, borrow price for D4). The stored price therefore stays put between trades while the token-unit price it implies moves with the two exchange prices; DexPoolState.dexPriceParsed gives the price with the exchange prices taken out.

Payout limit. The amount out must not exceed getTokenBaseState(tokenOut).availableForSwapOutWithdraw (D3) or .availableForSwapOutBorrow (D4). For the token out of a D4 swapIn, keep a margin of the swap's fees below availableForSwapOutBorrow, since the dex borrows the payout plus the fees.

Limits and reverts ​

Swap errors are FluidDexV2D3D4Error(uint256 id), base errors FluidDexV2Error(uint256 id).

ConditionError id
Pool not initialized211001
amountOut < amountOutMin / amountIn > amountInMax211002 / 211003
Swap needs liquidity beyond the last initialized tick (no partial fills)211004
Amount in / out outside [1e4, 2^128 - 1] token units212004
Normalised amount outside [1e4, 2^86 - 1]212005
Price move of one swap outside [5e-10, 20 %] of the sqrt price (about +44 % / -36 % in price; a minimum trade size on deep pools)212006
Dynamic-fee pool: price impact at the fee ceiling211008
controllerData longer than 10,000 bytes on a pool that asks its controller for the fee211012
Native msg.value short of the amount in201005 / 202003 / 202004
msg.value sent with a non-native token201004
dexCallback transferred less than owed (isCallback_ = true)201006
Unsettled amount when the callback returnsPendingTransfersNotCleared(bytes)

A swap that starts with zero active liquidity measures the 20 % limit from the price where it first finds liquidity. Re-entering the same pool during a swap reverts with empty data.

Settles can revert at Liquidity. The dex serves a settle either from its own token balance or from Fluid Liquidity, decided at execution. When it goes to Liquidity, Liquidity's limits and pauses apply and their error (FluidLiquidityError) reverts the whole operation; there is no fallback. Check the payout headroom above before routing large amounts.

A settle served from the dex's own balance can still call Liquidity. D4 swaps settle borrow legs, and when the dex's running total of borrow legs not yet registered at Liquidity passes its cap (maxCap and unaccountedBorrow in getTokenBaseState), the dex syncs with Liquidity inside the settle. That emits LogRebalance, costs more gas, and reverts with Liquidity's error if Liquidity rejects the sync. D3 swaps never do this.

Fees and gas ​

Fees are included in the swap amounts: amountOut_ of swapIn is net, amountIn_ of swapOut is gross. The LP fee is DexKey.fee on static pools; on dynamic-fee pools (fee = 0xFFFFFF) it is set by the pool's controller and can depend on price impact. A per-direction protocol fee (at most 0.4095 %) applies on top. getDexPoolState returns the current values.

Controller fee fetch. On a pool that fetches its fee from the controller, every swap makes a call to it with 200,000 gas forwarded, unless the swapper is the controller itself. If that call fails or returns an unusable fee, the swap silently uses the pool's own fee. A quote from stored state can therefore differ from execution, and the 200k gas needs to be in your budget.

Gas. A whole D3 swap (operate plus both settles, cold) costs about 104k gas when both tokens are served from the dex's own balance and 176k–188k when both settles go through Liquidity. Which path a settle takes is decided at execution, not at quote time, so budget for the Liquidity path. Settling with isCallback_ = true saves about 2k–3k gas per pulled token against an approval plus transferFrom.

Events to watch ​

Pool state changes on:

  • LogSwapIn / LogSwapOut / LogOperate emitted by the proxy: LogSwapIn(uint256 dexType, bytes32 dexId, address user, bool is0to1, uint256 amountIn, uint256 amountOut, uint256 protocolFee, uint256 lpFee), LogSwapOut with the same fields;
  • LogSettle (every settle) and LogRebalance (a sync of the unregistered borrow with Liquidity) emitted by the proxy: both move the payout headroom;
  • LogOperate at Liquidity with the dex proxy as user (it moves the payout headroom);
  • time: the token-unit price drifts with the two tokens' Liquidity exchange prices without a trade, so refresh pool state after a few minutes without events.

Resources ​