服务端阅读 05月28日 01:29
Solidity 中如何实现一个去中心化交易所(DEX)的核心功能?
去中心化交易所(DEX)是 DeFi 的基础设施,其核心依赖自动做市商(AMM)机制完成无订单簿交易。实现 DEX 的关键在于理解恒定乘积公式 x * y = k 如何驱动价格发现、流动性池如何管理代币储备、LP Token 如何表示份额,以及闪电贷如何在同一笔交易中完成借款与还款。以下从面试高频考点出发,逐层拆解 DEX 的合约实现。恒定乘积公式与价格计算AMM 的定价基础是恒定乘积公式:池中两种代币的储备量乘积始终为常数 k。当用户用 token0 换 token1 时,token0 储备增加、token1 储备减少,乘积不变,价格因此自动调整。实际交易还需扣除手续费(通常 0.3%),计算公式为:amountInWithFee = amountIn * (10000 - 30)amountOut = (amountInWithFee * reserveOut) / (reserveIn * 10000 + amountInWithFee)分母中加上 amountInWithFee 而非 amountIn,确保手续费从输入中扣除后再计算输出,防止 k 值被手续费稀释。这也是 Uniswap V2 的核心定价逻辑。滑点是价格偏离预期的程度。大额交易会显著改变储备比例,导致实际输出低于理论值。设置 _amountOutMin 参数就是滑点保护——如果实际输出低于此值,交易回滚。基础 AMM 合约实现核心合约维护两个代币的储备量和 LP Token 的发行与销毁:contract BasicAMM { IERC20 public token0; IERC20 public token1; uint256 public reserve0; uint256 public reserve1; uint256 public totalSupply; mapping(address => uint256) public balanceOf; uint256 public constant FEE = 30; uint256 public constant FEE_DENOMINATOR = 10000; uint256 public constant MINIMUM_LIQUIDITY = 1000; event Mint(address indexed sender, uint256 amount0, uint256 amount1); event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to); event Swap( address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to ); event Sync(uint256 reserve0, uint256 reserve1); constructor(address _token0, address _token1) { token0 = IERC20(_token0); token1 = IERC20(_token1); }添加流动性首次添加流动性时,LP Token 数量由两种代币数量的几何平均数决定:liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY。其中 MINIMUM_LIQUIDITY(1000 wei)永久锁定在零地址,防止流动性池被完全抽空导致除零错误。后续添加流动性时,按现有储备比例计算最优存入量,LP Token 数量取两个方向计算的较小值,确保新增份额不会超过实际贡献: function addLiquidity( uint256 _amount0Desired, uint256 _amount1Desired, uint256 _amount0Min, uint256 _amount1Min, address _to ) external returns (uint256 liquidity) { (uint256 amount0, uint256 amount1) = _calculateLiquidity( _amount0Desired, _amount1Desired, _amount0Min, _amount1Min ); token0.transferFrom(msg.sender, address(this), amount0); token1.transferFrom(msg.sender, address(this), amount1); if (totalSupply == 0) { liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY; _mint(address(0), MINIMUM_LIQUIDITY); } else { liquidity = min( (amount0 * totalSupply) / reserve0, (amount1 * totalSupply) / reserve1 ); } require(liquidity > 0, "Insufficient liquidity minted"); _mint(_to, liquidity); _updateReserves(); emit Mint(msg.sender, amount0, amount1); }移除流动性移除流动性是添加的逆操作:按 LP Token 占总量的比例赎回两种代币。赎回后储备量减少,但 k 值不变——因为手续费的累积效应,实际 k 值在交易过程中会缓慢增长,这也是流动性提供者收益的来源之一: function removeLiquidity( uint256 _liquidity, uint256 _amount0Min, uint256 _amount1Min, address _to ) external returns (uint256 amount0, uint256 amount1) { uint256 balance0 = token0.balanceOf(address(this)); uint256 balance1 = token1.balanceOf(address(this)); amount0 = (_liquidity * balance0) / totalSupply; amount1 = (_liquidity * balance1) / totalSupply; require(amount0 >= _amount0Min, "Insufficient amount0"); require(amount1 >= _amount1Min, "Insufficient amount1"); _burn(msg.sender, _liquidity); token0.transfer(_to, amount0); token1.transfer(_to, amount1); _updateReserves(); emit Burn(msg.sender, amount0, amount1, _to); }代币交换交换函数是 DEX 最核心的功能。以 token0 换 token1 为例:输入 amount0In,通过 getAmountOut 计算输出量,验证不低于滑点阈值后执行转账: function swap0For1( uint256 _amount0In, uint256 _amount1OutMin, address _to ) external returns (uint256 amount1Out) { require(_amount0In > 0, "Insufficient input amount"); amount1Out = getAmountOut(_amount0In, reserve0, reserve1); require(amount1Out >= _amount1OutMin, "Insufficient output amount"); token0.transferFrom(msg.sender, address(this), _amount0In); token1.transfer(_to, amount1Out); _updateReserves(); emit Swap(msg.sender, _amount0In, 0, 0, amount1Out, _to); } function swap1For0( uint256 _amount1In, uint256 _amount0OutMin, address _to ) external returns (uint256 amount0Out) { require(_amount1In > 0, "Insufficient input amount"); amount0Out = getAmountOut(_amount1In, reserve1, reserve0); require(amount0Out >= _amount0OutMin, "Insufficient output amount"); token1.transferFrom(msg.sender, address(this), _amount1In); token0.transfer(_to, amount0Out); _updateReserves(); emit Swap(msg.sender, 0, _amount1In, amount0Out, 0, _to); }定价与流动性计算getAmountOut 和 getAmountIn 是互逆函数。前者从输入算输出,后者从目标输出反推需要多少输入。反向计算时分子分母翻转,并加 1 向上取整确保池子不被占便宜: function getAmountOut( uint256 _amountIn, uint256 _reserveIn, uint256 _reserveOut ) public pure returns (uint256 amountOut) { require(_amountIn > 0, "Insufficient input amount"); require(_reserveIn > 0 && _reserveOut > 0, "Insufficient liquidity"); uint256 amountInWithFee = _amountIn * (FEE_DENOMINATOR - FEE); uint256 numerator = amountInWithFee * _reserveOut; uint256 denominator = (_reserveIn * FEE_DENOMINATOR) + amountInWithFee; amountOut = numerator / denominator; } function getAmountIn( uint256 _amountOut, uint256 _reserveIn, uint256 _reserveOut ) public pure returns (uint256 amountIn) { require(_amountOut > 0, "Insufficient output amount"); require(_reserveIn > 0 && _reserveOut > 0, "Insufficient liquidity"); uint256 numerator = _reserveIn * _amountOut * FEE_DENOMINATOR; uint256 denominator = (_reserveOut - _amountOut) * (FEE_DENOMINATOR - FEE); amountIn = (numerator / denominator) + 1; }辅助函数流动性计算函数处理两种场景:首次添加直接使用期望值,后续添加则按储备比例计算最优配比。如果按 token0 计算出的 token1 需求量超过期望值,则反过来用 token1 期望值推算 token0 的数量: function _calculateLiquidity( uint256 _amount0Desired, uint256 _amount1Desired, uint256 _amount0Min, uint256 _amount1Min ) internal view returns (uint256 amount0, uint256 amount1) { if (reserve0 == 0 && reserve1 == 0) { (amount0, amount1) = (_amount0Desired, _amount1Desired); } else { uint256 amount1Optimal = (_amount0Desired * reserve1) / reserve0; if (amount1Optimal <= _amount1Desired) { require(amount1Optimal >= _amount1Min, "Insufficient amount1"); (amount0, amount1) = (_amount0Desired, amount1Optimal); } else { uint256 amount0Optimal = (_amount1Desired * reserve0) / reserve1; assert(amount0Optimal <= _amount0Desired); require(amount0Optimal >= _amount0Min, "Insufficient amount0"); (amount0, amount1) = (amount0Optimal, _amount1Desired); } } } function _updateReserves() internal { reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); emit Sync(reserve0, reserve1); } function _mint(address _to, uint256 _amount) internal { totalSupply += _amount; balanceOf[_to] += _amount; } function _burn(address _from, uint256 _amount) internal { balanceOf[_from] -= _amount; totalSupply -= _amount; } function sqrt(uint256 y) internal pure returns (uint256 z) { if (y > 3) { z = y; uint256 x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } } function min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; }}interface IERC20 { function totalSupply() external view returns (uint256); function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool);}工厂合约与路由合约工厂合约管理所有交易对的创建与索引。它使用 CREATE2 操作码确保同一代币对只能创建一个池子地址,且地址可预测:contract Factory { mapping(address => mapping(address => address)) public getPair; address[] public allPairs; event PairCreated(address indexed token0, address indexed token1, address pair, uint256); function createPair(address _tokenA, address _tokenB) external returns (address pair) { require(_tokenA != _tokenB, "Identical addresses"); (address token0, address token1) = _tokenA < _tokenB ? (_tokenA, _tokenB) : (_tokenB, _tokenA); require(token0 != address(0), "Zero address"); require(getPair[token0][token1] == address(0), "Pair exists"); bytes memory bytecode = type(BasicAMM).creationCode; bytes32 salt = keccak256(abi.encodePacked(token0, token1)); assembly { pair := create2(0, add(bytecode, 32), mload(bytecode), salt) } BasicAMM(pair).initialize(token0, token1); getPair[token0][token1] = pair; getPair[token1][token0] = pair; allPairs.push(pair); emit PairCreated(token0, token1, pair, allPairs.length); }}路由合约是对交易对的封装,处理多跳交换、deadline 检查和代币转账等用户侧逻辑。它的核心价值在于让用户一次调用即可完成跨池交换,而不必手动与多个 Pair 合约交互:contract Router { address public factory; constructor(address _factory) { factory = _factory; } function addLiquidity( address _tokenA, address _tokenB, uint256 _amountADesired, uint256 _amountBDesired, uint256 _amountAMin, uint256 _amountBMin, address _to, uint256 _deadline ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity) { require(block.timestamp <= _deadline, "Expired"); address pair = Factory(factory).getPair(_tokenA, _tokenB); if (pair == address(0)) { pair = Factory(factory).createPair(_tokenA, _tokenB); } IERC20(_tokenA).transferFrom(msg.sender, pair, _amountADesired); IERC20(_tokenB).transferFrom(msg.sender, pair, _amountBDesired); liquidity = BasicAMM(pair).addLiquidity( _amountADesired, _amountBDesired, _amountAMin, _amountBMin, _to ); amountA = _amountADesired; amountB = _amountBDesired; }多跳路由是路由合约的关键能力。当 A/B 池不存在但 A/C 和 C/B 池都存在时,路由合约沿 path 依次执行交换,每一跳的输出成为下一跳的输入: function swapExactTokensForTokens( uint256 _amountIn, uint256 _amountOutMin, address[] calldata _path, address _to, uint256 _deadline ) external returns (uint256[] memory amounts) { require(block.timestamp <= _deadline, "Expired"); require(_path.length >= 2, "Invalid path"); amounts = getAmountsOut(_amountIn, _path); require(amounts[amounts.length - 1] >= _amountOutMin, "Insufficient output"); IERC20(_path[0]).transferFrom( msg.sender, Factory(factory).getPair(_path[0], _path[1]), amounts[0] ); _swap(amounts, _path, _to); } function _swap( uint256[] memory _amounts, address[] memory _path, address _to ) internal { for (uint i = 0; i < _path.length - 1; i++) { (address input, address output) = (_path[i], _path[i + 1]); address pair = Factory(factory).getPair(input, output); uint256 amountOut = _amounts[i + 1]; (uint256 amount0Out, uint256 amount1Out) = input < output ? (uint256(0), amountOut) : (amountOut, uint256(0)); address to = i < _path.length - 2 ? Factory(factory).getPair(output, _path[i + 2]) : _to; BasicAMM(pair).swap(amount0Out, amount1Out, to); } } function getAmountsOut( uint256 _amountIn, address[] memory _path ) public view returns (uint256[] memory amounts) { require(_path.length >= 2, "Invalid path"); amounts = new uint256[](_path.length); amounts[0] = _amountIn; for (uint i = 0; i < _path.length - 1; i++) { address pair = Factory(factory).getPair(_path[i], _path[i + 1]); require(pair != address(0), "Pair does not exist"); (uint256 reserveIn, uint256 reserveOut) = getReserves(pair, _path[i], _path[i + 1]); amounts[i + 1] = BasicAMM(pair).getAmountOut(amounts[i], reserveIn, reserveOut); } }}价格预言机与 TWAP链上价格容易被单笔大额交易操纵,直接使用现货价格作为预言机输入是常见的安全漏洞。时间加权平均价格(TWAP)通过累积价格对时间的积分来平滑瞬时波动,是 DEX 预言机的标准方案。实现方式是在每次储备更新时,将当前价格乘以时间间隔后累加到 price0CumulativeLast 和 price1CumulativeLast。外部合约记录两个时间点的累积价格之差,除以时间间隔即可得到 TWAP:contract PriceOracle { uint256 public price0CumulativeLast; uint256 public price1CumulativeLast; uint32 public blockTimestampLast; uint112 public reserve0; uint112 public reserve1; function _update( uint256 _balance0, uint256 _balance1, uint112 _reserve0, uint112 _reserve1 ) internal { require( _balance0 <= type(uint112).max && _balance1 <= type(uint112).max, "Overflow" ); uint32 blockTimestamp = uint32(block.timestamp % 2**32); uint32 timeElapsed = blockTimestamp - blockTimestampLast; if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { price0CumulativeLast += uint256(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast += uint256(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; } reserve0 = uint112(_balance0); reserve1 = uint112(_balance1); blockTimestampLast = blockTimestamp; } function getCurrentPrice() external view returns (uint256 price0, uint256 price1) { price0 = (uint256(reserve1) * 1e18) / reserve0; price1 = (uint256(reserve0) * 1e18) / reserve1; } function consult( address _token, uint256 _amountIn ) external view returns (uint256 amountOut) { if (_token == token0) { amountOut = (_amountIn * reserve1) / reserve0; } else { amountOut = (_amountIn * reserve0) / reserve1; } }}library UQ112x112 { uint224 constant Q112 = 2**112; function encode(uint112 y) internal pure returns (uint224 z) { z = uint224(y) * Q112; } function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) { z = x / uint224(y); }}UQ112x112 是一种定点数编码,将 112 位整数左移 112 位构成 224 位定点数。这种设计使得价格精度达到 2^-112,同时 reserve 用 uint112 存储不会溢出——因为以太坊总供应量约 1.2 亿 ETH,远小于 uint112 的上限。闪电贷闪电贷允许用户在同一笔交易中借款并还款,无需任何抵押。它的实现原理是先转账代币给调用者,然后通过回调函数让调用者执行逻辑,最后检查合约余额是否恢复到恒定乘积约束之上(含手续费):contract FlashSwap { interface IFlashSwapCallee { function uniswapV2Call( address sender, uint256 amount0, uint256 amount1, bytes calldata data ) external; } function swap( uint256 _amount0Out, uint256 _amount1Out, address _to, bytes calldata _data ) external { require(_amount0Out > 0 || _amount1Out > 0, "Insufficient output"); if (_amount0Out > 0) token0.transfer(_to, _amount0Out); if (_amount1Out > 0) token1.transfer(_to, _amount1Out); if (_data.length > 0) { IFlashSwapCallee(_to).uniswapV2Call( msg.sender, _amount0Out, _amount1Out, _data ); } uint256 balance0 = token0.balanceOf(address(this)); uint256 balance1 = token1.balanceOf(address(this)); uint256 amount0In = balance0 > reserve0 - _amount0Out ? balance0 - (reserve0 - _amount0Out) : 0; uint256 amount1In = balance1 > reserve1 - _amount1Out ? balance1 - (reserve1 - _amount1Out) : 0; require(amount0In > 0 || amount1In > 0, "Insufficient input"); uint256 balance0Adjusted = balance0 * 1000 - amount0In * 3; uint256 balance1Adjusted = balance1 * 1000 - amount1In * 3; require( balance0Adjusted * balance1Adjusted >= uint256(reserve0) * reserve1 * 1000**2, "K" ); _update(balance0, balance1, reserve0, reserve1); }}关键的验证逻辑在 balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * 1000**2。乘以 1000 再减去 amountIn * 3 等价于在手续费 0.3% 的约束下验证恒定乘积——如果用户偿还的金额加上手续费后 k 值不减少,交易合法;否则回滚。安全防护DEX 合约管理着大量资金,安全设计至关重要。以下是面试中高频考察的安全要点:重入攻击:遵循 Checks-Effects-Interactions 模式——先检查条件,再更新状态,最后进行外部调用。OpenZeppelin 的 ReentrancyGuard 提供了 nonReentrant 修饰符作为额外保护层。价格操纵:永远不要使用现货价格作为预言机。使用 TWAP 可以有效抵御闪电贷驱动的价格操纵攻击。如果必须使用现货价格,至少在同一个交易的开始和结束各读取一次储备量来检测异常。无常损失:当两种代币价格相对变化时,流动性提供者的持仓价值会低于单纯持有代币。这是 AMM 机制的固有代价,不是漏洞。设计合理的交易费率(如 0.3%)是对无常损失的补偿。前端运行(MEV):在以太坊上,交易在进入区块前对所有人可见,MEV 搜索者可以通过更高的 gas 价格抢跑。应对方式包括设置滑点容忍度、使用私有交易池(如 Flashbots Protect)、以及批量拍卖机制。contract SecureAMM is ReentrancyGuard { uint256 public constant MAX_SLIPPAGE = 100; uint256 public constant SLIPPAGE_DENOMINATOR = 10000; uint256 public maxSwapAmount; bool public paused; address public guardian; modifier whenNotPaused() { require(!paused, "Paused"); _; } modifier onlyGuardian() { require(msg.sender == guardian, "Not guardian"); _; } function swapWithSlippageProtection( uint256 _amountIn, uint256 _minAmountOut, address _to ) external nonReentrant whenNotPaused { require(_amountIn <= maxSwapAmount, "Exceeds max swap"); uint256 amountOut = getAmountOut(_amountIn); uint256 expectedOut = (_amountIn * reserve1) / reserve0; uint256 slippage = ((expectedOut - amountOut) * SLIPPAGE_DENOMINATOR) / expectedOut; require(slippage <= MAX_SLIPPAGE, "Slippage too high"); require(amountOut >= _minAmountOut, "Insufficient output"); } function pause() external onlyGuardian { paused = true; } function unpause() external onlyGuardian { paused = false; }}紧急暂停功能是 DeFi 合约的标准安全网。Guardian 角色可以在发现漏洞时暂停所有交易,但不应拥有冻结用户资金或修改合约逻辑的权限——权限最小化是设计原则。实现 DEX 远不止写出能编译通过的合约。恒定乘积公式决定了定价逻辑,LP Token 机制保证了流动性激励,TWAP 预言机抵御了价格操纵,闪电贷验证确保了原子性还款,而安全防护层则守住了资金安全的底线。理解每一层的设计意图和边界条件,才能在面试和实际开发中给出经得起追问的回答。