Liquidity Pools

What is a Liquidity Pool?

A liquidity pool is a core component of JAMM DEX, consisting of a smart contract that holds reserves of two tokens. Each liquidity pool is an instance of the JAMMPair contract, responsible for managing the trading and liquidity of a specific token pair.

Pool Creation

Creation via Factory

All liquidity pools are created through the JAMMFactory contract:

// from JAMMFactory.sol
function createPair(
    address tokenA,
    address tokenB,
    uint24 fee
) external override returns (address pair) {
    require(tokenA != tokenB, "JAMMFactory: IDENTICAL_ADDRESSES");
    (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
    require(token0 != address(0), "JAMMFactory: ZERO_ADDRESS");
    require(
        fee == FEE_0_5_PERCENT ||
        fee == FEE_1_PERCENT ||
        fee == FEE_2_PERCENT ||
        fee == FEE_3_PERCENT,
        "JAMMFactory: INVALID_FEE"
    );
    require(getPair[token0][token1][fee] == address(0), "JAMMFactory: PAIR_EXISTS");
    
    // deploy the pair using CREATE2
    bytes memory bytecode = type(JAMMPair).creationCode;
    bytes32 salt = keccak256(abi.encodePacked(token0, token1, fee));
    assembly {
        pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
    }
    
    // initialize the pair
    JAMMPair(pair).initialize(token0, token1, fee);
    getPair[token0][token1][fee] = pair;
    getPair[token1][token0][fee] = pair;
    allPairs.push(pair);
    emit PairCreated(token0, token1, fee, pair, allPairs.length);
}

Deterministic Address Generation

JAMM DEX uses CREATE2 to generate deterministic pool addresses. This means that for the same token pair and fee, the pool address will always be the same. This calculation is performed in JAMMLibrary.sol:

// from JAMMLibrary.sol
function pairFor(
    address factory,
    address tokenA,
    address tokenB,
    uint24 fee
) internal pure returns (address pair) {
    (address token0, address token1) = sortTokens(tokenA, tokenB);
    pair = address(uint160(uint256(keccak256(abi.encodePacked(
        hex"ff",
        factory,
        keccak256(abi.encodePacked(token0, token1, fee)),
        hex"2e599419feff8382bdfc47abf847537b06e96b0525db3802b2a9fb0bfe068ed8" // init code hash
    )))));
}

Pool State

Reserve Management

Each pool maintains reserves of two tokens:

// from JAMMPair.sol
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;

function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
    _reserve0 = reserve0;
    _reserve1 = reserve1;
    _blockTimestampLast = blockTimestampLast;
}

Reserve Updates

Reserves are updated after every trade or liquidity change:

// from JAMMPair.sol
function _update(
    uint balance0,
    uint balance1,
    uint112 _reserve0,
    uint112 _reserve1
) private {
    require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "JAMM: OVERFLOW");
    uint32 blockTimestamp = uint32(block.timestamp % 2**32);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast;
    if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
        // update cumulative prices
        price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
        price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
    }
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    blockTimestampLast = blockTimestamp;
    emit Sync(reserve0, reserve1);
}

Liquidity Tokens (LP Tokens)

The Role of LP Tokens

When a user provides liquidity to a pool, they receive LP tokens as a receipt:

  • LP tokens represent the user's share in the pool.

  • They can be redeemed at any time for the corresponding token pair.

  • LP tokens are themselves ERC-20 tokens and can be transferred.

Minting LP Tokens

// from JAMMPair.sol
function mint(address to) external lock returns (uint liquidity) {
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));
    uint amount0 = balance0 - _reserve0;
    uint amount1 = balance1 - _reserve1;

    bool feeOn = _mintFee(_reserve0, _reserve1);
    uint _totalSupply = totalSupply;
    if (_totalSupply == 0) {
        // first liquidity provider
        liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
        _mint(address(0), MINIMUM_LIQUIDITY);
    } else {
        // subsequent liquidity providers
        liquidity = Math.min((amount0 * _totalSupply) / _reserve0, (amount1 * _totalSupply) / _reserve1);
    }
    require(liquidity > 0, "JAMM: INSUFFICIENT_LIQUIDITY_MINTED");
    _mint(to, liquidity);

    _update(balance0, balance1, _reserve0, _reserve1);
    if (feeOn) kLast = uint(reserve0) * uint(reserve1);
    emit Mint(msg.sender, amount0, amount1);
}

Burning LP Tokens

// from JAMMPair.sol
function burn(address to) external lock returns (uint amount0, uint amount1) {
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
    address _token0 = token0;
    address _token1 = token1;
    uint balance0 = IERC20(_token0).balanceOf(address(this));
    uint balance1 = IERC20(_token1).balanceOf(address(this));
    uint liquidity = balanceOf[address(this)];

    bool feeOn = _mintFee(_reserve0, _reserve1);
    uint _totalSupply = totalSupply;
    amount0 = (liquidity * balance0) / _totalSupply; // prorated distribution
    amount1 = (liquidity * balance1) / _totalSupply; // prorated distribution
    require(amount0 > 0 && amount1 > 0, "JAMM: INSUFFICIENT_LIQUIDITY_BURNED");
    _burn(address(this), liquidity);
    _safeTransfer(_token0, to, amount0);
    _safeTransfer(_token1, to, amount1);
    balance0 = IERC20(_token0).balanceOf(address(this));
    balance1 = IERC20(_token1).balanceOf(address(this));

    _update(balance0, balance1, _reserve0, _reserve1);
    if (feeOn) kLast = uint(reserve0) * uint(reserve1);
    emit Burn(msg.sender, amount0, amount1, to);
}

Trade Execution

The swap Function

The core function of the pool is to execute token swaps:

// from JAMMPair.sol
function swap(
    uint amount0Out,
    uint amount1Out,
    address to,
    bytes calldata data,
    address _referrer
) external lock {
    require(amount0Out > 0 || amount1Out > 0, "JAMM: INSUFFICIENT_OUTPUT_AMOUNT");
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
    require(amount0Out < _reserve0 && amount1Out < _reserve1, "JAMM: INSUFFICIENT_LIQUIDITY");

    uint balance0;
    uint balance1;
    {
        require(to != token0 && to != token1, "JAMM: INVALID_TO");
        if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
        if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
        if (data.length > 0) IJAMMCallee(to).jammCall(msg.sender, amount0Out, amount1Out, data, _referrer);
        balance0 = IERC20(token0).balanceOf(address(this));
        balance1 = IERC20(token1).balanceOf(address(this));
    }

    uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
    uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
    require(amount0In > 0 || amount1In > 0, "JAMM: INSUFFICIENT_INPUT_AMOUNT");
    
    _collectFee(amount0In, amount1In, _referrer);
    
    // K-value check
    {
        balance0 = IERC20(token0).balanceOf(address(this));
        balance1 = IERC20(token1).balanceOf(address(this));
        uint balance0Adjusted = (balance0 * 10000 - (amount0In * fee * 4) / 5);
        uint balance1Adjusted = (balance1 * 10000 - (amount1In * fee * 4) / 5);
        require(
            balance0Adjusted * balance1Adjusted >= uint(_reserve0) * uint(_reserve1) * (10000 ** 2),
            "JAMM: K"
        );
    }

    _update(balance0, balance1, _reserve0, _reserve1);
    emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}

Security Mechanisms

Re-entrancy Guard

All critical functions are protected with the lock modifier to prevent re-entrancy attacks:

// from JAMMPair.sol
uint private unlocked = 1;
modifier lock() {
    require(unlocked == 1, "JAMM: LOCKED");
    unlocked = 0;
    _;
    unlocked = 1;
}

Minimum Liquidity Lock

To prevent the pool from being completely drained, a minimum amount of LP tokens is permanently locked on the first liquidity provision:

// from JAMMPair.sol
uint public constant MINIMUM_LIQUIDITY = 10**3;

Overflow Protection

Reserves use the uint112 type, and updates are checked for overflow, leveraging the built-in security features of Solidity 0.8.x:

// from JAMMPair.sol
require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "JAMM: OVERFLOW");

Utility Functions

skim Function

Used to remove any excess tokens that were accidentally sent to the pool and not accounted for in the reserves:

// from JAMMPair.sol
function skim(address to) external lock {
    address _token0 = token0;
    address _token1 = token1;
    _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)) - reserve0);
    _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)) - reserve1);
}

sync Function

Used to forcibly synchronize the reserves with the contract's actual token balances. This can be used to recover the pool state in certain edge cases:

// from JAMMPair.sol
function sync() external lock {
    _update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
}

Summary

Liquidity pools are the core components of JAMM DEX, and they:

  • Are created and managed centrally by the Factory contract.

  • Use deterministic address generation for easy calculation and lookup.

  • Implement full AMM functionality, including liquidity management and token swaps.

  • Have built-in security mechanisms and fee distribution logic.

  • Provide price oracle functionality.

Understanding how liquidity pools work is essential for effectively using JAMM DEX.