238 lines
10 KiB
Solidity
238 lines
10 KiB
Solidity
// 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(uint256 amount) external returns (uint256);
|
|
function sellQuote(uint256 amount) 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(tradeSize)
|
|
: IDODOPMMPoolStabilizer(pool).sellQuote(tradeSize);
|
|
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;
|
|
}
|
|
}
|