// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "../../../dex/PrivatePoolRegistry.sol"; import "./IStablecoinPegManager.sol"; import "./ICommodityPegManager.sol"; /** * @title Minimal DODO PMM pool interface for Stabilizer swaps */ interface IDODOPMMPoolStabilizer { function _BASE_TOKEN_() external view returns (address); function _QUOTE_TOKEN_() external view returns (address); function sellBase(address to) external returns (uint256); function sellQuote(address to) external returns (uint256); function getMidPrice() external view returns (uint256); } /** * @title Stabilizer * @notice Phase 3: Deviation-triggered private swaps via XAU-anchored pools. Phase 6: TWAP/sustained deviation, per-block cap, flash containment. * @dev Implements Appendix A of VAULT_SYSTEM_MASTER_TECHNICAL_PLAN. Only STABILIZER_KEEPER_ROLE may call executePrivateSwap. */ contract Stabilizer is AccessControl, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 public constant STABILIZER_KEEPER_ROLE = keccak256("STABILIZER_KEEPER_ROLE"); PrivatePoolRegistry public immutable privatePoolRegistry; uint256 public lastExecutionBlock; uint256 public volumeThisBlock; uint256 public volumeBlockNumber; uint256 public minBlocksBetweenExecution = 3; uint256 public maxStabilizationVolumePerBlock; uint256 public thresholdBps = 50; uint256 public sustainedDeviationBlocks = 3; uint256 public maxSlippageBps = 100; uint256 public maxGasPriceForStabilizer; IStablecoinPegManager public stablecoinPegManager; address public stablecoinPegAsset; ICommodityPegManager public commodityPegManager; address public commodityPegAsset; bool public useStablecoinPeg; // true = use stablecoin peg, false = use commodity peg (when set) struct DeviationSample { uint256 blockNumber; int256 deviationBps; } uint256 public constant MAX_DEVIATION_SAMPLES = 32; DeviationSample[MAX_DEVIATION_SAMPLES] private _samples; uint256 private _sampleIndex; uint256 private _sampleCount; event PrivateSwapExecuted(address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); event ConfigUpdated(string key, uint256 value); event PegSourceStablecoinSet(address manager, address asset); event PegSourceCommoditySet(address manager, address asset); error NoPrivatePool(); error ShouldNotRebalance(); error BlockDelayNotMet(); error VolumeCapExceeded(); error SlippageExceeded(); error GasPriceTooHigh(); error ZeroAmount(); error NoDeviationSource(); error InsufficientBalance(); constructor(address admin, address _privatePoolRegistry) { require(admin != address(0), "Stabilizer: zero admin"); require(_privatePoolRegistry != address(0), "Stabilizer: zero registry"); _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(STABILIZER_KEEPER_ROLE, admin); privatePoolRegistry = PrivatePoolRegistry(_privatePoolRegistry); } function setStablecoinPegSource(address manager, address asset) external onlyRole(DEFAULT_ADMIN_ROLE) { stablecoinPegManager = IStablecoinPegManager(manager); stablecoinPegAsset = asset; useStablecoinPeg = true; emit PegSourceStablecoinSet(manager, asset); } function setCommodityPegSource(address manager, address asset) external onlyRole(DEFAULT_ADMIN_ROLE) { commodityPegManager = ICommodityPegManager(manager); commodityPegAsset = asset; useStablecoinPeg = false; emit PegSourceCommoditySet(manager, asset); } function setMinBlocksBetweenExecution(uint256 v) external onlyRole(DEFAULT_ADMIN_ROLE) { minBlocksBetweenExecution = v; emit ConfigUpdated("minBlocksBetweenExecution", v); } function setMaxStabilizationVolumePerBlock(uint256 v) external onlyRole(DEFAULT_ADMIN_ROLE) { maxStabilizationVolumePerBlock = v; emit ConfigUpdated("maxStabilizationVolumePerBlock", v); } function setThresholdBps(uint256 v) external onlyRole(DEFAULT_ADMIN_ROLE) { thresholdBps = v; emit ConfigUpdated("thresholdBps", v); } function setSustainedDeviationBlocks(uint256 v) external onlyRole(DEFAULT_ADMIN_ROLE) { require(v <= MAX_DEVIATION_SAMPLES, "Stabilizer: sample limit"); sustainedDeviationBlocks = v; emit ConfigUpdated("sustainedDeviationBlocks", v); } function setMaxSlippageBps(uint256 v) external onlyRole(DEFAULT_ADMIN_ROLE) { maxSlippageBps = v; emit ConfigUpdated("maxSlippageBps", v); } function setMaxGasPriceForStabilizer(uint256 v) external onlyRole(DEFAULT_ADMIN_ROLE) { maxGasPriceForStabilizer = v; emit ConfigUpdated("maxGasPriceForStabilizer", v); } /** * @notice Get current deviation in bps and whether rebalance is recommended (sustained over threshold). * @return deviationBps Current peg deviation in basis points. * @return shouldRebalance True if over threshold for sustainedDeviationBlocks samples. */ function checkDeviation() external view returns (int256 deviationBps, bool shouldRebalance) { deviationBps = _getDeviationBps(); // forge-lint: disable-next-line unsafe-typecast -- thresholdBps is uint256, casting to int256 for comparison with _abs result; thresholdBps is small (basis points) if (_abs(deviationBps) <= int256(uint256(thresholdBps))) { return (deviationBps, false); } shouldRebalance = _sustainedOverThreshold(); return (deviationBps, shouldRebalance); } /** * @notice Record current deviation for sustained-deviation check (call from keeper each block or before executePrivateSwap). */ function recordDeviation() external { int256 d = _getDeviationBps(); _pushSample(block.number, d); } /** * @notice Execute a private swap via the private pool registry (only keeper when shouldRebalance). * @param tradeSize Amount of tokenIn to swap. * @param tokenIn Token to sell (must have balance on this contract). * @param tokenOut Token to buy. * @return amountOut Amount of tokenOut received (reverts on slippage or volume cap). */ function executePrivateSwap( uint256 tradeSize, address tokenIn, address tokenOut ) external nonReentrant onlyRole(STABILIZER_KEEPER_ROLE) returns (uint256 amountOut) { if (tradeSize == 0) revert ZeroAmount(); if (maxGasPriceForStabilizer != 0 && block.basefee > maxGasPriceForStabilizer) revert GasPriceTooHigh(); if (block.number < lastExecutionBlock + minBlocksBetweenExecution) revert BlockDelayNotMet(); _pushSample(block.number, _getDeviationBps()); (int256 deviationBps, bool shouldRebalance) = this.checkDeviation(); if (!shouldRebalance) revert ShouldNotRebalance(); if (block.number != volumeBlockNumber) { volumeBlockNumber = block.number; volumeThisBlock = 0; } if (volumeThisBlock + tradeSize > maxStabilizationVolumePerBlock) revert VolumeCapExceeded(); volumeThisBlock += tradeSize; lastExecutionBlock = block.number; address pool = privatePoolRegistry.getPrivatePool(tokenIn, tokenOut); if (pool == address(0)) revert NoPrivatePool(); address base = IDODOPMMPoolStabilizer(pool)._BASE_TOKEN_(); address quote = IDODOPMMPoolStabilizer(pool)._QUOTE_TOKEN_(); uint256 midPrice = IDODOPMMPoolStabilizer(pool).getMidPrice(); uint256 expectedOut = tokenIn == base ? (tradeSize * midPrice) / 1e18 : (tradeSize * 1e18) / midPrice; uint256 minAmountOut = (expectedOut * (10000 - maxSlippageBps)) / 10000; if (IERC20(tokenIn).balanceOf(address(this)) < tradeSize) revert InsufficientBalance(); IERC20(tokenIn).safeTransfer(pool, tradeSize); amountOut = tokenIn == base ? IDODOPMMPoolStabilizer(pool).sellBase(address(this)) : IDODOPMMPoolStabilizer(pool).sellQuote(address(this)); if (amountOut < minAmountOut) revert SlippageExceeded(); emit PrivateSwapExecuted(tokenIn, tokenOut, tradeSize, amountOut); return amountOut; } function _getDeviationBps() internal view returns (int256) { if (useStablecoinPeg && address(stablecoinPegManager) != address(0) && stablecoinPegAsset != address(0)) { (, int256 d) = stablecoinPegManager.checkUSDpeg(stablecoinPegAsset); return d; } if (!useStablecoinPeg && address(commodityPegManager) != address(0) && commodityPegAsset != address(0)) { (, int256 d) = commodityPegManager.checkCommodityPeg(commodityPegAsset); return d; } return 0; } function _sustainedOverThreshold() internal view returns (bool) { if (sustainedDeviationBlocks == 0) return true; if (_sampleCount < sustainedDeviationBlocks) return false; uint256 n = sustainedDeviationBlocks; for (uint256 i = 0; i < n; i++) { uint256 idx = (_sampleIndex + MAX_DEVIATION_SAMPLES - 1 - i) % MAX_DEVIATION_SAMPLES; int256 d = _samples[idx].deviationBps; // forge-lint: disable-next-line unsafe-typecast -- d is deviationBps (small int256), safe to cast to uint256 for abs uint256 absD = d < 0 ? uint256(-d) : uint256(d); if (absD <= thresholdBps) return false; } return true; } function _pushSample(uint256 blockNum, int256 deviationBps) internal { uint256 idx = _sampleIndex % MAX_DEVIATION_SAMPLES; _samples[idx] = DeviationSample({ blockNumber: blockNum, deviationBps: deviationBps }); _sampleIndex = ( _sampleIndex + 1) % MAX_DEVIATION_SAMPLES; if (_sampleCount < MAX_DEVIATION_SAMPLES) _sampleCount++; } function _abs(int256 x) internal pure returns (int256) { return x < 0 ? -x : x; } }