From e1560a880b6d52002b1c18df8b805d235ea00d83 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Tue, 2 Jun 2026 06:09:36 -0700 Subject: [PATCH] WIP: Zedxion bridge, GRU monetary policy gate, LiIndex flash vault, cw-settlement --- contracts/bridge/CWMultiTokenBridgeL2.sol | 35 ++- contracts/bridge/ZedxionCustomBridge.sol | 217 ++++++++++++++++++ .../bridge/adapters/evm/ZedxionAdapter.sol | 158 +++++++++++++ .../bridge/interfaces/IZedxionTransport.sol | 35 +++ .../compliance/libraries/MonetaryFormulas.sol | 72 ++++++ contracts/cw-settlement/CWBuybackExecutor.sol | 93 ++++++++ contracts/cw-settlement/CWNavOracle.sol | 162 +++++++++++++ .../cw-settlement/CWProtocolTreasury.sol | 57 +++++ contracts/cw-settlement/CWRedemptionQueue.sol | 189 +++++++++++++++ contracts/cw-settlement/CWStabilityFund.sol | 66 ++++++ contracts/cw-settlement/CWVotingEscrow.sol | 74 ++++++ ...aveUniV2CwStableRebalanceFlashReceiver.sol | 193 ++++++++++++++++ contracts/registry/UniversalAssetRegistry.sol | 7 +- contracts/rwa/GruMonetaryPolicyGate.sol | 105 +++++++++ contracts/rwa/IGruMonetaryPolicyGate.sol | 43 ++++ contracts/rwa/LiIndexFlashVault.sol | 184 +++++++++++++++ contracts/rwa/RWAToken.sol | 16 ++ 17 files changed, 1694 insertions(+), 12 deletions(-) create mode 100644 contracts/bridge/ZedxionCustomBridge.sol create mode 100644 contracts/bridge/adapters/evm/ZedxionAdapter.sol create mode 100644 contracts/bridge/interfaces/IZedxionTransport.sol create mode 100644 contracts/compliance/libraries/MonetaryFormulas.sol create mode 100644 contracts/cw-settlement/CWBuybackExecutor.sol create mode 100644 contracts/cw-settlement/CWNavOracle.sol create mode 100644 contracts/cw-settlement/CWProtocolTreasury.sol create mode 100644 contracts/cw-settlement/CWRedemptionQueue.sol create mode 100644 contracts/cw-settlement/CWStabilityFund.sol create mode 100644 contracts/cw-settlement/CWVotingEscrow.sol create mode 100644 contracts/flash/AaveUniV2CwStableRebalanceFlashReceiver.sol create mode 100644 contracts/rwa/GruMonetaryPolicyGate.sol create mode 100644 contracts/rwa/IGruMonetaryPolicyGate.sol create mode 100644 contracts/rwa/LiIndexFlashVault.sol diff --git a/contracts/bridge/CWMultiTokenBridgeL2.sol b/contracts/bridge/CWMultiTokenBridgeL2.sol index de818d6..6d574f8 100644 --- a/contracts/bridge/CWMultiTokenBridgeL2.sol +++ b/contracts/bridge/CWMultiTokenBridgeL2.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.19; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "../ccip/IRouterClient.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol"; +import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol"; +import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol"; interface ICWMintBurnToken is IERC20 { function mint(address to, uint256 amount) external; @@ -15,14 +17,16 @@ interface ICWMintBurnToken is IERC20 { * @title CWMultiTokenBridgeL2 * @notice Destination/public-chain bridge for minting and burning cW* assets without prefunding. * @dev Supports multiple canonical -> mirrored token pairs behind one bridge address. + * Outbound CCIP uses Chainlink Client.EVM2AnyMessage encoding (required by public routers). */ -contract CWMultiTokenBridgeL2 { +contract CWMultiTokenBridgeL2 is IAny2EVMMessageReceiver { using SafeERC20 for IERC20; IRouterClient public sendRouter; address public receiveRouter; address public feeToken; address public admin; + uint256 public ccipGasLimit; struct DestinationConfig { address receiverBridge; @@ -64,6 +68,7 @@ contract CWMultiTokenBridgeL2 { event SendRouterUpdated(address indexed newRouter); event ReceiveRouterUpdated(address indexed newRouter); event FeeTokenUpdated(address indexed newFeeToken); + event CcipGasLimitUpdated(uint256 newGasLimit); event AdminChanged(address indexed newAdmin); modifier onlyAdmin() { @@ -83,6 +88,7 @@ contract CWMultiTokenBridgeL2 { receiveRouter = _receiveRouter; feeToken = _feeToken; admin = msg.sender; + ccipGasLimit = 300_000; } function configureTokenPair(address canonicalToken, address mirroredToken) external onlyAdmin { @@ -148,6 +154,12 @@ contract CWMultiTokenBridgeL2 { emit FeeTokenUpdated(newFeeToken); } + function setCcipGasLimit(uint256 newGasLimit) external onlyAdmin { + require(newGasLimit > 0, "CWMultiTokenBridgeL2: zero gas limit"); + ccipGasLimit = newGasLimit; + emit CcipGasLimitUpdated(newGasLimit); + } + function changeAdmin(address newAdmin) external onlyAdmin { require(newAdmin != address(0), "CWMultiTokenBridgeL2: zero admin"); admin = newAdmin; @@ -164,7 +176,10 @@ contract CWMultiTokenBridgeL2 { require(canonicalToken != address(0), "CWMultiTokenBridgeL2: token not configured"); DestinationConfig memory dest = destinations[destinationChainSelector]; require(dest.enabled, "CWMultiTokenBridgeL2: destination disabled"); - return sendRouter.getFee(destinationChainSelector, _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount)); + return sendRouter.getFee( + destinationChainSelector, + _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount) + ); } function circulatingSupply(address mirroredToken) external view returns (uint256) { @@ -173,7 +188,7 @@ contract CWMultiTokenBridgeL2 { return totalMinted > totalBurned ? totalMinted - totalBurned : 0; } - function ccipReceive(IRouterClient.Any2EVMMessage calldata message) external onlyReceiveRouter { + function ccipReceive(Client.Any2EVMMessage calldata message) external onlyReceiveRouter { require(!processed[message.messageId], "CWMultiTokenBridgeL2: replayed"); (address canonicalToken, address recipient, uint256 amount) = @@ -229,13 +244,13 @@ contract CWMultiTokenBridgeL2 { IERC20(mirroredToken).totalSupply() ); - IRouterClient.EVM2AnyMessage memory message = + Client.EVM2AnyMessage memory message = _buildMessage(dest.receiverBridge, canonicalToken, recipient, amount); uint256 fee = sendRouter.getFee(destinationChainSelector, message); _collectAndApproveFee(fee); - (messageId, ) = feeToken == address(0) + messageId = feeToken == address(0) ? sendRouter.ccipSend{value: fee}(destinationChainSelector, message) : sendRouter.ccipSend(destinationChainSelector, message); @@ -260,13 +275,13 @@ contract CWMultiTokenBridgeL2 { address canonicalToken, address recipient, uint256 amount - ) internal view returns (IRouterClient.EVM2AnyMessage memory message) { - message = IRouterClient.EVM2AnyMessage({ + ) internal view returns (Client.EVM2AnyMessage memory message) { + message = Client.EVM2AnyMessage({ receiver: abi.encode(receiverBridge), data: abi.encode(canonicalToken, recipient, amount), - tokenAmounts: new IRouterClient.TokenAmount[](0), + tokenAmounts: new Client.EVMTokenAmount[](0), feeToken: feeToken, - extraArgs: "" + extraArgs: Client._argsToBytes(Client.EVMExtraArgsV1({gasLimit: ccipGasLimit})) }); } diff --git a/contracts/bridge/ZedxionCustomBridge.sol b/contracts/bridge/ZedxionCustomBridge.sol new file mode 100644 index 0000000..c68bf88 --- /dev/null +++ b/contracts/bridge/ZedxionCustomBridge.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +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 "./interfaces/IZedxionTransport.sol"; + +interface ICWMintBurnToken is IERC20 { + function mint(address to, uint256 amount) external; + function burnFrom(address from, uint256 amount) external; +} + +/** + * @title ZedxionCustomBridge + * @notice GRU custom transport for Chain 138 ↔ ZEDXION (83872). No CCIP. + * @dev Deploy at same address on 138 and 83872 via CREATE2 (salt keccak256("ZedxionCustomBridge")). + * On 138: lock canonical c* and emit LockForZedxion. + * On 83872: relayer calls releaseOnZedxion to mint cW* or transfer escrowed assets. + * Reverse: lock on 83872 (LockFor138), relayer completes releaseOn138 on 138. + */ +contract ZedxionCustomBridge is IZedxionTransport, AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); + uint256 public constant ZEDXION_CHAIN_ID = 83872; + uint256 public constant HUB_CHAIN_ID = 138; + + mapping(bytes32 => LockRecord) public locks; + mapping(bytes32 => bool) public releasedOnDestination; + mapping(address => address) public canonicalToMirrored; + mapping(address => address) public mirroredToCanonical; + mapping(address => bool) public mintableMirroredToken; + mapping(address => uint256) public nonces; + bool private _hasRelayer; + + struct LockRecord { + address sender; + address token; + uint256 amount; + address recipient; + uint256 createdAt; + bool released; + } + + event LockForZedxion( + bytes32 indexed requestId, + address indexed sender, + address indexed token, + uint256 amount, + address recipient, + uint256 sourceChainId + ); + + event UnlockOnZedxion( + bytes32 indexed requestId, + address indexed recipient, + address indexed token, + uint256 amount + ); + + event LockFor138( + bytes32 indexed requestId, + address indexed sender, + address indexed token, + uint256 amount, + address recipient, + uint256 sourceChainId + ); + + event UnlockOn138( + bytes32 indexed requestId, + address indexed recipient, + address indexed token, + uint256 amount + ); + + event TokenPairConfigured(address indexed canonicalToken, address indexed mirroredToken); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(RELAYER_ROLE, admin); + _hasRelayer = true; + } + + function zedxionChainId() external pure override returns (uint256) { + return ZEDXION_CHAIN_ID; + } + + function configureTokenPair(address canonicalToken, address mirroredToken) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(canonicalToken != address(0) && mirroredToken != address(0), "zero token"); + canonicalToMirrored[canonicalToken] = mirroredToken; + mirroredToCanonical[mirroredToken] = canonicalToken; + emit TokenPairConfigured(canonicalToken, mirroredToken); + } + + function setMintableMirroredToken(address mirroredToken, bool enabled) external onlyRole(DEFAULT_ADMIN_ROLE) { + mintableMirroredToken[mirroredToken] = enabled; + } + + function bridgeToZedxion( + address token, + uint256 amount, + address recipientOn83872 + ) external payable override nonReentrant returns (bytes32 requestId) { + require(block.chainid == HUB_CHAIN_ID, "ZedxionCustomBridge: hub only"); + return _lock(token, amount, recipientOn83872, ZEDXION_CHAIN_ID); + } + + function bridgeFromZedxion( + address tokenOn83872, + uint256 amount, + address recipientOn138 + ) external payable override nonReentrant returns (bytes32 requestId) { + require(block.chainid == ZEDXION_CHAIN_ID, "ZedxionCustomBridge: zedxion only"); + return _lock(tokenOn83872, amount, recipientOn138, HUB_CHAIN_ID); + } + + function _lock( + address token, + uint256 amount, + address recipient, + uint256 destinationChainId + ) internal returns (bytes32 requestId) { + require(recipient != address(0), "zero recipient"); + require(amount > 0, "zero amount"); + + requestId = keccak256(abi.encodePacked( + msg.sender, + token, + amount, + recipient, + nonces[msg.sender]++, + block.chainid, + block.timestamp + )); + + if (token == address(0)) { + require(msg.value >= amount, "insufficient value"); + } else { + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + } + + locks[requestId] = LockRecord({ + sender: msg.sender, + token: token, + amount: amount, + recipient: recipient, + createdAt: block.timestamp, + released: false + }); + + if (destinationChainId == ZEDXION_CHAIN_ID) { + emit LockForZedxion(requestId, msg.sender, token, amount, recipient, block.chainid); + emit ZedxionBridgeInitiated(requestId, msg.sender, token, amount, recipient, ZEDXION_CHAIN_ID); + } else { + emit LockFor138(requestId, msg.sender, token, amount, recipient, block.chainid); + emit ZedxionBridgeInitiated(requestId, msg.sender, token, amount, recipient, HUB_CHAIN_ID); + } + } + + /** + * @notice On 83872: mint cW* or release escrow after 138 lock. On 138: release canonical c* after 83872 burn/lock. + */ + function releaseOnZedxion( + bytes32 requestId, + address token, + uint256 amount, + address recipient + ) external onlyRole(RELAYER_ROLE) nonReentrant { + require(block.chainid == ZEDXION_CHAIN_ID, "ZedxionCustomBridge: zedxion only"); + _release(requestId, token, amount, recipient, true); + } + + function releaseOn138( + bytes32 requestId, + address token, + uint256 amount, + address recipient + ) external onlyRole(RELAYER_ROLE) nonReentrant { + require(block.chainid == HUB_CHAIN_ID, "ZedxionCustomBridge: hub only"); + _release(requestId, token, amount, recipient, false); + } + + function _release( + bytes32 requestId, + address token, + uint256 amount, + address recipient, + bool onZedxion + ) internal { + require(!releasedOnDestination[requestId], "already released"); + require(recipient != address(0), "zero recipient"); + require(amount > 0, "zero amount"); + releasedOnDestination[requestId] = true; + + if (token == address(0)) { + (bool sent,) = payable(recipient).call{value: amount}(""); + require(sent, "transfer failed"); + } else if (mintableMirroredToken[token]) { + ICWMintBurnToken(token).mint(recipient, amount); + } else { + IERC20(token).safeTransfer(recipient, amount); + } + + emit ZedxionBridgeCompleted(requestId, bytes32(0)); + + if (onZedxion) { + emit UnlockOnZedxion(requestId, recipient, token, amount); + } else { + emit UnlockOn138(requestId, recipient, token, amount); + } + } + + receive() external payable {} +} diff --git a/contracts/bridge/adapters/evm/ZedxionAdapter.sol b/contracts/bridge/adapters/evm/ZedxionAdapter.sol new file mode 100644 index 0000000..fee0de5 --- /dev/null +++ b/contracts/bridge/adapters/evm/ZedxionAdapter.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +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 "../../interfaces/IChainAdapter.sol"; +import "../../interfaces/IZedxionTransport.sol"; + +/** + * @title ZedxionAdapter + * @notice Bridge adapter for ZEDXION Smart Chain (ZED20), chainId 83872. + * @dev CCIP does not list 83872. Wire ZedxionCustomBridge via setZedxionTransport(). + */ +contract ZedxionAdapter is IChainAdapter, AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant BRIDGE_OPERATOR_ROLE = keccak256("BRIDGE_OPERATOR_ROLE"); + + uint256 public constant ZEDXION_CHAIN_ID = 83872; + + IZedxionTransport public zedxionTransport; + bool public isActive; + uint256 public bridgeFee; + + mapping(bytes32 => BridgeRequest) public bridgeRequests; + mapping(address => uint256) public nonces; + + event ZedxionAdapterBridge( + bytes32 indexed requestId, + address indexed sender, + address indexed token, + uint256 amount, + address recipient + ); + + event ZedxionBridgeConfirmed(bytes32 indexed requestId, bytes32 indexed destinationTxHash); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(BRIDGE_OPERATOR_ROLE, admin); + isActive = true; + bridgeFee = 0; + } + + function setZedxionTransport(address transport) external onlyRole(DEFAULT_ADMIN_ROLE) { + zedxionTransport = IZedxionTransport(transport); + } + + function setActive(bool active) external onlyRole(DEFAULT_ADMIN_ROLE) { + isActive = active; + } + + function setBridgeFee(uint256 fee) external onlyRole(DEFAULT_ADMIN_ROLE) { + bridgeFee = fee; + } + + function getChainType() external pure override returns (string memory) { + return "EVM"; + } + + function getChainIdentifier() external pure override returns (uint256 chainId, string memory identifier) { + return (ZEDXION_CHAIN_ID, "ZEDXION"); + } + + function validateDestination(bytes calldata destination) external pure override returns (bool) { + if (destination.length != 20) return false; + return address(bytes20(destination)) != address(0); + } + + function bridge( + address token, + uint256 amount, + bytes calldata destination, + bytes calldata /* recipient */ + ) external payable override nonReentrant returns (bytes32 requestId) { + require(isActive, "ZedxionAdapter: inactive"); + require(amount > 0, "ZedxionAdapter: zero amount"); + require(address(zedxionTransport) != address(0), "ZedxionAdapter: transport not set"); + require(this.validateDestination(destination), "ZedxionAdapter: invalid destination"); + + address recipientAddr = address(bytes20(destination)); + + requestId = keccak256(abi.encodePacked( + msg.sender, + token, + amount, + recipientAddr, + nonces[msg.sender]++, + block.timestamp + )); + + if (token == address(0)) { + require(msg.value >= amount, "ZedxionAdapter: insufficient value"); + } else { + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + IERC20(token).forceApprove(address(zedxionTransport), amount); + } + + bridgeRequests[requestId] = BridgeRequest({ + sender: msg.sender, + token: token, + amount: amount, + destinationData: destination, + requestId: requestId, + status: BridgeStatus.Locked, + createdAt: block.timestamp, + completedAt: 0 + }); + + zedxionTransport.bridgeToZedxion{value: msg.value}(token, amount, recipientAddr); + + emit ZedxionAdapterBridge(requestId, msg.sender, token, amount, recipientAddr); + return requestId; + } + + function getBridgeStatus(bytes32 requestId) external view override returns (BridgeRequest memory) { + return bridgeRequests[requestId]; + } + + function cancelBridge(bytes32 requestId) external override returns (bool) { + BridgeRequest storage request = bridgeRequests[requestId]; + require( + request.status == BridgeStatus.Pending || request.status == BridgeStatus.Locked, + "ZedxionAdapter: cannot cancel" + ); + require(msg.sender == request.sender, "ZedxionAdapter: not sender"); + + if (request.token == address(0)) { + payable(request.sender).transfer(request.amount); + } else { + IERC20(request.token).safeTransfer(request.sender, request.amount); + } + + request.status = BridgeStatus.Cancelled; + return true; + } + + function estimateFee( + address /* token */, + uint256 /* amount */, + bytes calldata /* destination */ + ) external view override returns (uint256) { + return bridgeFee; + } + + function confirmBridge(bytes32 requestId, bytes32 destinationTxHash) + external + onlyRole(BRIDGE_OPERATOR_ROLE) + { + BridgeRequest storage request = bridgeRequests[requestId]; + require(request.status == BridgeStatus.Locked, "ZedxionAdapter: invalid status"); + request.status = BridgeStatus.Confirmed; + request.completedAt = block.timestamp; + emit ZedxionBridgeConfirmed(requestId, destinationTxHash); + } +} diff --git a/contracts/bridge/interfaces/IZedxionTransport.sol b/contracts/bridge/interfaces/IZedxionTransport.sol new file mode 100644 index 0000000..d0fb409 --- /dev/null +++ b/contracts/bridge/interfaces/IZedxionTransport.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IZedxionTransport + * @notice Transport layer for Chain 138 ↔ ZEDXION (83872) when CCIP is unavailable. + * @dev Production implementations may wrap Zedx official bridge contracts + * (https://bridge.zedxion.com/) or GRU CWMultiTokenBridge L1/L2 on 83872. + */ +interface IZedxionTransport { + event ZedxionBridgeInitiated( + bytes32 indexed requestId, + address indexed sender, + address indexed token, + uint256 amount, + address recipient, + uint256 destinationChainId + ); + + event ZedxionBridgeCompleted(bytes32 indexed requestId, bytes32 indexed destinationTxHash); + + function zedxionChainId() external view returns (uint256); + + function bridgeToZedxion( + address token, + uint256 amount, + address recipientOn83872 + ) external payable returns (bytes32 requestId); + + function bridgeFromZedxion( + address tokenOn83872, + uint256 amount, + address recipientOn138 + ) external payable returns (bytes32 requestId); +} diff --git a/contracts/compliance/libraries/MonetaryFormulas.sol b/contracts/compliance/libraries/MonetaryFormulas.sol new file mode 100644 index 0000000..97914a4 --- /dev/null +++ b/contracts/compliance/libraries/MonetaryFormulas.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title MonetaryFormulas + * @notice Classical + GRU monetary formulas (canonical library — promoted from archive). + * @dev Formulas MUST match vault COMPLIANCE_REQUIREMENTS.md and GRU_Formulas.md crosswalk. + * + * Classical: + * - M = C + D; M = MB × m + * - V = PQ / M + * - m = 1 / r; m = (1 + c) / (r + c) + * + * GRU layer fan-out (policy): 1 M00 = 5 M0 = 25 M1 face units + */ +library MonetaryFormulas { + uint256 internal constant BP = 10_000; + uint256 internal constant WAD = 1e18; + + /// @notice M = C + D + function moneySupplyCD(uint256 currency, uint256 deposits) internal pure returns (uint256) { + return currency + deposits; + } + + /// @notice M = MB × m (m in 18 decimals) + function moneySupplyFromBase(uint256 monetaryBase, uint256 moneyMultiplierWad) internal pure returns (uint256) { + return (monetaryBase * moneyMultiplierWad) / WAD; + } + + /// @notice V = PQ / M + function velocityPQOverM(uint256 priceLevel, uint256 quantity, uint256 moneySupply) internal pure returns (uint256) { + if (moneySupply == 0) return 0; + return (priceLevel * quantity) / moneySupply; + } + + /// @notice m = 1 / r (r in basis points) + function simpleMultiplier(uint256 reserveRatioBps) internal pure returns (uint256) { + require(reserveRatioBps > 0 && reserveRatioBps <= BP, "MonetaryFormulas: r"); + return (WAD * BP) / reserveRatioBps; + } + + /// @notice m = (1 + c) / (r + c) + function multiplierWithCurrency(uint256 reserveRatioBps, uint256 currencyRatioBps) internal pure returns (uint256) { + require(reserveRatioBps > 0 && reserveRatioBps <= BP, "MonetaryFormulas: r"); + require(currencyRatioBps <= BP, "MonetaryFormulas: c"); + uint256 num = WAD * (BP + currencyRatioBps); + uint256 den = reserveRatioBps + currencyRatioBps; + require(den > 0, "MonetaryFormulas: den"); + return num / den; + } + + /// @notice Coverage ratio in bps: reserveValue / circulatingValue + function coverageRatioBps(uint256 reserveValue, uint256 circulatingValue) internal pure returns (uint256) { + if (circulatingValue == 0) return type(uint256).max; + return (reserveValue * BP) / circulatingValue; + } + + /// @notice Effective GRU layer multiplier M00→M1 (25 face units per M00) + function gruM00ToM1Fanout() internal pure returns (uint256) { + return 25; + } + + /// @notice 7:10 atomic issuance factor as WAD (10/7) + function gruAtomicIssuanceFactorWad() internal pure returns (uint256) { + return (10 * WAD) / 7; + } + + /// @notice v_cov = v_adj × (coverageBps / 12000) + function coverageWeightedVelocity(uint256 vAdjWad, uint256 coverageBps) internal pure returns (uint256) { + return (vAdjWad * coverageBps) / 12_000; + } +} diff --git a/contracts/cw-settlement/CWBuybackExecutor.sol b/contracts/cw-settlement/CWBuybackExecutor.sol new file mode 100644 index 0000000..75e5d56 --- /dev/null +++ b/contracts/cw-settlement/CWBuybackExecutor.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +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 "../bridge/integration/ICWReserveVerifier.sol"; + +interface ICWBurnable { + function burn(uint256 amount) external; +} + +/** + * @title CWBuybackExecutor + * @notice Routes fee revenue into cW buybacks; burns purchased tokens or locks in treasury. + */ +contract CWBuybackExecutor is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + + ICWReserveVerifier public reserveVerifier; + address public treasuryLock; + bool public burnOnBuyback = true; + + event ReserveVerifierUpdated(address indexed verifier); + event TreasuryLockUpdated(address indexed treasuryLock); + event BurnModeUpdated(bool burnOnBuyback); + event BuybackExecuted(address indexed paymentToken, address indexed cwToken, uint256 paid, uint256 received, bool burned); + + error ZeroAddress(); + error ReserveCheckFailed(); + + constructor(address admin, address reserveVerifier_, address treasuryLock_) { + if (admin == address(0)) { + revert ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(EXECUTOR_ROLE, admin); + reserveVerifier = ICWReserveVerifier(reserveVerifier_); + treasuryLock = treasuryLock_; + } + + function setReserveVerifier(address verifier_) external onlyRole(DEFAULT_ADMIN_ROLE) { + reserveVerifier = ICWReserveVerifier(verifier_); + emit ReserveVerifierUpdated(verifier_); + } + + function setTreasuryLock(address treasuryLock_) external onlyRole(DEFAULT_ADMIN_ROLE) { + treasuryLock = treasuryLock_; + emit TreasuryLockUpdated(treasuryLock_); + } + + function setBurnOnBuyback(bool enabled) external onlyRole(DEFAULT_ADMIN_ROLE) { + burnOnBuyback = enabled; + emit BurnModeUpdated(enabled); + } + + /** + * @notice Operator delivers pre-purchased cW tokens; contract burns or locks them after reserve check. + */ + function executeBuyback( + address canonicalToken, + uint64 destinationChainSelector, + address cwToken, + address paymentToken, + uint256 paymentAmount, + uint256 cwAmount + ) external onlyRole(EXECUTOR_ROLE) nonReentrant { + if (address(reserveVerifier) != address(0)) { + if (!reserveVerifier.verifyLock(canonicalToken, destinationChainSelector, 0)) { + revert ReserveCheckFailed(); + } + } + + if (paymentAmount > 0 && paymentToken != address(0)) { + IERC20(paymentToken).safeTransferFrom(msg.sender, address(this), paymentAmount); + } + + IERC20(cwToken).safeTransferFrom(msg.sender, address(this), cwAmount); + + bool burned = false; + if (burnOnBuyback) { + ICWBurnable(cwToken).burn(cwAmount); + burned = true; + } else if (treasuryLock != address(0)) { + IERC20(cwToken).safeTransfer(treasuryLock, cwAmount); + } + + emit BuybackExecuted(paymentToken, cwToken, paymentAmount, cwAmount, burned); + } +} diff --git a/contracts/cw-settlement/CWNavOracle.sol b/contracts/cw-settlement/CWNavOracle.sol new file mode 100644 index 0000000..07474ca --- /dev/null +++ b/contracts/cw-settlement/CWNavOracle.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../reserve/IReserveSystem.sol"; + +interface ICWNavBridgeL1 { + function lockedBalance(address token) external view returns (uint256); + function totalOutstanding(address token) external view returns (uint256); +} + +/** + * @title CWNavOracle + * @notice Publishes reserve-backed NAV for canonical c* / global cW supply pairs. + * @dev Global cW supply is aggregated off-chain across mesh chains and pushed by OPERATOR_ROLE. + * On-chain locked/outstanding reads come from CWMultiTokenBridgeL1. + */ +contract CWNavOracle is AccessControl { + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + uint256 public constant BPS = 10_000; + uint256 public constant NAV_SCALE = 1e18; + + struct TokenConfig { + bool enabled; + address reserveAsset; + } + + struct NavSnapshot { + uint256 totalLockedAssets; + uint256 totalReserveAssets; + uint256 totalCwSupply; + uint256 backingRatioBps; + uint256 navPerShare; + uint256 chain138CollateralBps; + uint256 treasuryReservesBps; + uint256 protocolFeesBps; + bytes32 attestationHash; + uint256 updatedAt; + } + + ICWNavBridgeL1 public bridge; + IReserveSystem public reserveSystem; + + mapping(address => TokenConfig) public tokenConfigs; + mapping(address => uint256) public globalCwSupply; + mapping(address => NavSnapshot) public latestSnapshot; + + event BridgeUpdated(address indexed newBridge); + event ReserveSystemUpdated(address indexed newReserveSystem); + event TokenConfigured(address indexed canonicalToken, address indexed reserveAsset); + event TokenDisabled(address indexed canonicalToken); + event NavPublished(address indexed canonicalToken, uint256 backingRatioBps, uint256 navPerShare, bytes32 attestationHash); + + error ZeroAddress(); + error TokenNotConfigured(); + + constructor(address admin, address bridge_, address reserveSystem_) { + if (admin == address(0) || bridge_ == address(0)) { + revert ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(OPERATOR_ROLE, admin); + bridge = ICWNavBridgeL1(bridge_); + reserveSystem = IReserveSystem(reserveSystem_); + } + + function setBridge(address bridge_) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (bridge_ == address(0)) { + revert ZeroAddress(); + } + bridge = ICWNavBridgeL1(bridge_); + emit BridgeUpdated(bridge_); + } + + function setReserveSystem(address reserveSystem_) external onlyRole(DEFAULT_ADMIN_ROLE) { + reserveSystem = IReserveSystem(reserveSystem_); + emit ReserveSystemUpdated(reserveSystem_); + } + + function configureToken(address canonicalToken, address reserveAsset) external onlyRole(OPERATOR_ROLE) { + if (canonicalToken == address(0)) { + revert ZeroAddress(); + } + tokenConfigs[canonicalToken] = TokenConfig({enabled: true, reserveAsset: reserveAsset}); + emit TokenConfigured(canonicalToken, reserveAsset); + } + + function disableToken(address canonicalToken) external onlyRole(OPERATOR_ROLE) { + delete tokenConfigs[canonicalToken]; + emit TokenDisabled(canonicalToken); + } + + function publishNav( + address canonicalToken, + uint256 globalCwSupplyAmount, + uint256 chain138CollateralBps, + uint256 treasuryReservesBps, + uint256 protocolFeesBps, + bytes32 attestationHash + ) external onlyRole(OPERATOR_ROLE) { + TokenConfig memory config = tokenConfigs[canonicalToken]; + if (!config.enabled) { + revert TokenNotConfigured(); + } + require(chain138CollateralBps + treasuryReservesBps + protocolFeesBps == BPS, "CWNavOracle: bad decomposition"); + + globalCwSupply[canonicalToken] = globalCwSupplyAmount; + + uint256 locked = bridge.lockedBalance(canonicalToken); + uint256 reserveBalance = address(reserveSystem) == address(0) || config.reserveAsset == address(0) + ? 0 + : reserveSystem.getReserveBalance(config.reserveAsset); + + uint256 totalAssets = locked + reserveBalance; + uint256 backingRatioBps = globalCwSupplyAmount == 0 ? 0 : (totalAssets * BPS) / globalCwSupplyAmount; + uint256 navPerShareValue = globalCwSupplyAmount == 0 ? 0 : (totalAssets * NAV_SCALE) / globalCwSupplyAmount; + + NavSnapshot memory snap = NavSnapshot({ + totalLockedAssets: locked, + totalReserveAssets: reserveBalance, + totalCwSupply: globalCwSupplyAmount, + backingRatioBps: backingRatioBps, + navPerShare: navPerShareValue, + chain138CollateralBps: chain138CollateralBps, + treasuryReservesBps: treasuryReservesBps, + protocolFeesBps: protocolFeesBps, + attestationHash: attestationHash, + updatedAt: block.timestamp + }); + + latestSnapshot[canonicalToken] = snap; + emit NavPublished(canonicalToken, backingRatioBps, navPerShareValue, attestationHash); + } + + function totalLockedAssets(address canonicalToken) external view returns (uint256) { + return bridge.lockedBalance(canonicalToken); + } + + function totalReserveAssets(address canonicalToken) external view returns (uint256) { + TokenConfig memory config = tokenConfigs[canonicalToken]; + if (!config.enabled || address(reserveSystem) == address(0) || config.reserveAsset == address(0)) { + return 0; + } + return reserveSystem.getReserveBalance(config.reserveAsset); + } + + function totalCWUSupply(address canonicalToken) external view returns (uint256) { + return globalCwSupply[canonicalToken]; + } + + function backingRatio(address canonicalToken) external view returns (uint256 backingRatioBps) { + return latestSnapshot[canonicalToken].backingRatioBps; + } + + function navPerShare(address canonicalToken) external view returns (uint256) { + return latestSnapshot[canonicalToken].navPerShare; + } + + function getNavSnapshot(address canonicalToken) external view returns (NavSnapshot memory) { + return latestSnapshot[canonicalToken]; + } +} diff --git a/contracts/cw-settlement/CWProtocolTreasury.sol b/contracts/cw-settlement/CWProtocolTreasury.sol new file mode 100644 index 0000000..c233b0a --- /dev/null +++ b/contracts/cw-settlement/CWProtocolTreasury.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title CWProtocolTreasury + * @notice Holds protocol-owned liquidity proceeds and fee routing targets for POL flywheel. + */ +contract CWProtocolTreasury is AccessControl { + using SafeERC20 for IERC20; + + bytes32 public constant FEE_ROUTER_ROLE = keccak256("FEE_ROUTER_ROLE"); + bytes32 public constant POL_OPERATOR_ROLE = keccak256("POL_OPERATOR_ROLE"); + + address public buybackExecutor; + + event BuybackExecutorUpdated(address indexed executor); + event FeeRouted(address indexed token, uint256 amount, address indexed destination); + event PolPositionRegistered(bytes32 indexed positionId, address indexed venue, address pair, uint256 lpBalance); + + error ZeroAddress(); + + mapping(bytes32 => bool) public polPositionRegistered; + + constructor(address admin, address buybackExecutor_) { + if (admin == address(0)) { + revert ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(FEE_ROUTER_ROLE, admin); + _grantRole(POL_OPERATOR_ROLE, admin); + buybackExecutor = buybackExecutor_; + } + + function setBuybackExecutor(address executor_) external onlyRole(DEFAULT_ADMIN_ROLE) { + buybackExecutor = executor_; + emit BuybackExecutorUpdated(executor_); + } + + function routeFees(address token, uint256 amount, address destination) external onlyRole(FEE_ROUTER_ROLE) { + IERC20(token).safeTransfer(destination, amount); + emit FeeRouted(token, amount, destination); + } + + function registerPolPosition( + bytes32 positionId, + address venue, + address pair, + uint256 lpBalance + ) external onlyRole(POL_OPERATOR_ROLE) { + polPositionRegistered[positionId] = true; + emit PolPositionRegistered(positionId, venue, pair, lpBalance); + } +} diff --git a/contracts/cw-settlement/CWRedemptionQueue.sol b/contracts/cw-settlement/CWRedemptionQueue.sol new file mode 100644 index 0000000..c46f0ee --- /dev/null +++ b/contracts/cw-settlement/CWRedemptionQueue.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +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"; + +/** + * @title CWRedemptionQueue + * @notice Tiered redemption for hub c* collateral released from cW burn flows. + * @dev Instant <= instantTierMax, standard <= standardTierMax (24h delay), above => extended queue. + */ +contract CWRedemptionQueue is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant BRIDGE_ROLE = keccak256("BRIDGE_ROLE"); + bytes32 public constant PROCESSOR_ROLE = keccak256("PROCESSOR_ROLE"); + + enum Tier { + Instant, + Standard, + Extended + } + + enum Status { + Pending, + Ready, + Claimed, + Cancelled + } + + struct Request { + address beneficiary; + address token; + uint256 amount; + Tier tier; + Status status; + uint64 readyAt; + } + + uint256 public instantTierMax = 5_000 * 1e6; + uint256 public standardTierMax = 50_000 * 1e6; + uint256 public standardDelay = 24 hours; + uint256 public extendedDelay = 72 hours; + + uint256 public nextRequestId = 1; + mapping(uint256 => Request) public requests; + + event TierLimitsUpdated(uint256 instantMax, uint256 standardMax, uint256 standardDelaySec, uint256 extendedDelaySec); + event RedemptionEnqueued(uint256 indexed requestId, address indexed beneficiary, address indexed token, uint256 amount, Tier tier); + event RedemptionReady(uint256 indexed requestId); + event RedemptionClaimed(uint256 indexed requestId, address indexed beneficiary, uint256 amount); + + error ZeroAddress(); + error InvalidAmount(); + error NotReady(); + error InvalidStatus(); + + constructor(address admin) { + if (admin == address(0)) { + revert ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(PROCESSOR_ROLE, admin); + } + + function setTierLimits( + uint256 instantMax, + uint256 standardMax, + uint256 standardDelaySec, + uint256 extendedDelaySec + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + instantTierMax = instantMax; + standardTierMax = standardMax; + standardDelay = standardDelaySec; + extendedDelay = extendedDelaySec; + emit TierLimitsUpdated(instantMax, standardMax, standardDelaySec, extendedDelaySec); + } + + function enqueueRedemption( + address beneficiary, + address token, + uint256 amount + ) external onlyRole(BRIDGE_ROLE) nonReentrant returns (uint256 requestId) { + if (beneficiary == address(0) || token == address(0) || amount == 0) { + revert InvalidAmount(); + } + + Tier tier = _tierForAmount(amount); + uint64 readyAt = tier == Tier.Instant ? uint64(block.timestamp) : uint64(block.timestamp + _delayForTier(tier)); + Status status = tier == Tier.Instant ? Status.Ready : Status.Pending; + + requestId = nextRequestId++; + requests[requestId] = Request({ + beneficiary: beneficiary, + token: token, + amount: amount, + tier: tier, + status: status, + readyAt: readyAt + }); + + emit RedemptionEnqueued(requestId, beneficiary, token, amount, tier); + } + + function fundAndEnqueue( + address beneficiary, + address token, + uint256 amount + ) external nonReentrant returns (uint256 requestId) { + if (beneficiary == address(0) || token == address(0) || amount == 0) { + revert InvalidAmount(); + } + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + + Tier tier = _tierForAmount(amount); + uint64 readyAt = tier == Tier.Instant ? uint64(block.timestamp) : uint64(block.timestamp + _delayForTier(tier)); + Status status = tier == Tier.Instant ? Status.Ready : Status.Pending; + + requestId = nextRequestId++; + requests[requestId] = Request({ + beneficiary: beneficiary, + token: token, + amount: amount, + tier: tier, + status: status, + readyAt: readyAt + }); + + emit RedemptionEnqueued(requestId, beneficiary, token, amount, tier); + } + + function markReady(uint256 requestId) external onlyRole(PROCESSOR_ROLE) { + Request storage req = requests[requestId]; + if (req.status != Status.Pending) { + revert InvalidStatus(); + } + req.status = Status.Ready; + emit RedemptionReady(requestId); + } + + function claim(uint256 requestId) external nonReentrant { + Request storage req = requests[requestId]; + if (req.status != Status.Ready) { + revert InvalidStatus(); + } + if (block.timestamp < req.readyAt) { + revert NotReady(); + } + if (msg.sender != req.beneficiary && !hasRole(PROCESSOR_ROLE, msg.sender)) { + revert("CWRedemptionQueue: not beneficiary"); + } + + req.status = Status.Claimed; + IERC20(req.token).safeTransfer(req.beneficiary, req.amount); + emit RedemptionClaimed(requestId, req.beneficiary, req.amount); + } + + function processDueRequests(uint256[] calldata requestIds) external onlyRole(PROCESSOR_ROLE) { + for (uint256 i = 0; i < requestIds.length; i++) { + Request storage req = requests[requestIds[i]]; + if (req.status == Status.Pending && block.timestamp >= req.readyAt) { + req.status = Status.Ready; + emit RedemptionReady(requestIds[i]); + } + } + } + + function _tierForAmount(uint256 amount) internal view returns (Tier) { + if (amount <= instantTierMax) { + return Tier.Instant; + } + if (amount <= standardTierMax) { + return Tier.Standard; + } + return Tier.Extended; + } + + function _delayForTier(Tier tier) internal view returns (uint256) { + if (tier == Tier.Standard) { + return standardDelay; + } + if (tier == Tier.Extended) { + return extendedDelay; + } + return 0; + } +} diff --git a/contracts/cw-settlement/CWStabilityFund.sol b/contracts/cw-settlement/CWStabilityFund.sol new file mode 100644 index 0000000..ab24b87 --- /dev/null +++ b/contracts/cw-settlement/CWStabilityFund.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +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"; + +/** + * @title CWStabilityFund + * @notice Insurance / surplus buffer for bridge, oracle, and temporary depeg events. + */ +contract CWStabilityFund is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE"); + bytes32 public constant DEPOSITOR_ROLE = keccak256("DEPOSITOR_ROLE"); + + mapping(address => bool) public supportedAsset; + + event AssetSupported(address indexed asset, bool supported); + event Deposited(address indexed asset, address indexed from, uint256 amount); + event EmergencyWithdrawn(address indexed asset, address indexed to, uint256 amount, bytes32 reason); + + error ZeroAddress(); + error UnsupportedAsset(); + + constructor(address admin) { + if (admin == address(0)) { + revert ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(EMERGENCY_ROLE, admin); + _grantRole(DEPOSITOR_ROLE, admin); + } + + function setSupportedAsset(address asset, bool supported) external onlyRole(DEFAULT_ADMIN_ROLE) { + supportedAsset[asset] = supported; + emit AssetSupported(asset, supported); + } + + function deposit(address asset, uint256 amount) external onlyRole(DEPOSITOR_ROLE) nonReentrant { + if (!supportedAsset[asset]) { + revert UnsupportedAsset(); + } + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + emit Deposited(asset, msg.sender, amount); + } + + function emergencyWithdraw( + address asset, + address to, + uint256 amount, + bytes32 reason + ) external onlyRole(EMERGENCY_ROLE) nonReentrant { + if (to == address(0)) { + revert ZeroAddress(); + } + IERC20(asset).safeTransfer(to, amount); + emit EmergencyWithdrawn(asset, to, amount, reason); + } + + function balanceOf(address asset) external view returns (uint256) { + return IERC20(asset).balanceOf(address(this)); + } +} diff --git a/contracts/cw-settlement/CWVotingEscrow.sol b/contracts/cw-settlement/CWVotingEscrow.sol new file mode 100644 index 0000000..094712b --- /dev/null +++ b/contracts/cw-settlement/CWVotingEscrow.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title CWVotingEscrow + * @notice Minimal vote-escrow (veCW) for emission-direction governance. + */ +contract CWVotingEscrow is AccessControl { + using SafeERC20 for IERC20; + + IERC20 public immutable escrowToken; + uint256 public constant MAX_LOCK_DURATION = 4 * 365 days; + + struct Lock { + uint256 amount; + uint256 unlockAt; + uint256 votingPower; + } + + mapping(address => Lock[]) public locks; + + event Locked(address indexed account, uint256 amount, uint256 unlockAt, uint256 votingPower); + event Withdrawn(address indexed account, uint256 amount); + + error ZeroAddress(); + error InvalidLock(); + error NothingToWithdraw(); + + constructor(address admin, address escrowToken_) { + if (admin == address(0) || escrowToken_ == address(0)) { + revert ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + escrowToken = IERC20(escrowToken_); + } + + function createLock(uint256 amount, uint256 lockDuration) external { + if (amount == 0 || lockDuration == 0 || lockDuration > MAX_LOCK_DURATION) { + revert InvalidLock(); + } + escrowToken.safeTransferFrom(msg.sender, address(this), amount); + + uint256 unlockAt = block.timestamp + lockDuration; + uint256 votingPower = (amount * lockDuration) / MAX_LOCK_DURATION; + + locks[msg.sender].push(Lock({amount: amount, unlockAt: unlockAt, votingPower: votingPower})); + emit Locked(msg.sender, amount, unlockAt, votingPower); + } + + function withdraw(uint256 lockIndex) external { + Lock storage entry = locks[msg.sender][lockIndex]; + if (entry.amount == 0 || block.timestamp < entry.unlockAt) { + revert NothingToWithdraw(); + } + uint256 amount = entry.amount; + entry.amount = 0; + entry.votingPower = 0; + escrowToken.safeTransfer(msg.sender, amount); + emit Withdrawn(msg.sender, amount); + } + + function votingPowerOf(address account) external view returns (uint256 total) { + Lock[] storage userLocks = locks[account]; + for (uint256 i = 0; i < userLocks.length; i++) { + if (userLocks[i].amount > 0 && block.timestamp < userLocks[i].unlockAt) { + total += userLocks[i].votingPower; + } + } + } +} diff --git a/contracts/flash/AaveUniV2CwStableRebalanceFlashReceiver.sol b/contracts/flash/AaveUniV2CwStableRebalanceFlashReceiver.sol new file mode 100644 index 0000000..9e4aca8 --- /dev/null +++ b/contracts/flash/AaveUniV2CwStableRebalanceFlashReceiver.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { + IAavePoolLike, + IAaveFlashLoanReceiver +} from "./AaveQuotePushFlashReceiver.sol"; + +interface IUniswapV2Router02Minimal { + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + address[] calldata path, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); + + function removeLiquidity( + address tokenA, + address tokenB, + uint256 liquidity, + uint256 amountAMin, + uint256 amountBMin, + address to, + uint256 deadline + ) external returns (uint256 amountA, uint256 amountB); +} + +/** + * @title AaveUniV2CwStableRebalanceFlashReceiver + * @notice Same-block Aave V3 USDC flash loan to quote-side rebalance a skewed cW/stable UniV2 pair, + * remove owned LP, sell cW* back into the pair for USDC, and repay Aave. + * + * @dev LP tokens must be on this contract before `runRebalanceRemove` (transfer or `pullLpFrom`). + */ +contract AaveUniV2CwStableRebalanceFlashReceiver is IAaveFlashLoanReceiver, Ownable { + using SafeERC20 for IERC20; + + address public immutable pool; + + struct RebalanceRemoveParams { + address router; + address pair; + address cwToken; + address stableToken; + uint256 lpAmount; + uint256 rebalanceStableIn; + uint256 minCwFromRebalance; + uint256 minStableFromRemove; + uint256 minCwFromRemove; + uint256 cwToSellForRepay; + uint256 minStableFromRepaySwap; + address recipient; + } + + error UntrustedPool(); + error UntrustedInitiator(); + error BadParams(); + error InsufficientToRepay(); + error NothingToSweep(); + + event RebalanceRemoveExecuted( + address indexed pair, + address indexed stableToken, + uint256 borrowedAmount, + uint256 premium, + uint256 lpRemoved, + uint256 stableSurplus + ); + event TokenSwept(address indexed token, address indexed to, uint256 amount); + + constructor(address pool_, address initialOwner) Ownable(initialOwner) { + if (pool_ == address(0) || initialOwner == address(0)) revert BadParams(); + pool = pool_; + } + + function pullLpFrom(address pair, address from, uint256 amount) external onlyOwner { + if (pair == address(0) || from == address(0) || amount == 0) revert BadParams(); + IERC20(pair).safeTransferFrom(from, address(this), amount); + } + + function runRebalanceRemove(address stableToken, uint256 amount, RebalanceRemoveParams calldata params) + external + onlyOwner + { + if (stableToken == address(0) || amount == 0) revert BadParams(); + if (params.lpAmount == 0 || params.rebalanceStableIn == 0 || params.recipient == address(0)) { + revert BadParams(); + } + if (IERC20(params.pair).balanceOf(address(this)) < params.lpAmount) revert BadParams(); + + address[] memory assets = new address[](1); + uint256[] memory amts = new uint256[](1); + uint256[] memory modes = new uint256[](1); + assets[0] = stableToken; + amts[0] = amount; + modes[0] = 0; + IAavePoolLike(pool).flashLoan( + address(this), assets, amts, modes, address(this), abi.encode(address(this), params), 0 + ); + } + + function executeOperation( + address[] calldata assets, + uint256[] calldata amounts, + uint256[] calldata premiums, + address initiator, + bytes calldata params + ) external override returns (bool) { + if (msg.sender != pool) revert UntrustedPool(); + if (assets.length != 1 || amounts.length != 1 || premiums.length != 1) revert BadParams(); + _executeRebalanceRemove(assets[0], amounts[0], premiums[0], initiator, params); + return true; + } + + function sweepToken(address token, address to, uint256 amount) external onlyOwner { + if (token == address(0) || to == address(0) || amount == 0) revert BadParams(); + IERC20(token).safeTransfer(to, amount); + emit TokenSwept(token, to, amount); + } + + function _executeRebalanceRemove( + address stableToken, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata params + ) internal { + (address expectedInitiator, RebalanceRemoveParams memory p) = + abi.decode(params, (address, RebalanceRemoveParams)); + if (initiator != expectedInitiator) revert UntrustedInitiator(); + if ( + p.router == address(0) || p.pair == address(0) || p.cwToken == address(0) + || p.stableToken != stableToken + ) revert BadParams(); + if (p.rebalanceStableIn != amount) revert BadParams(); + + uint256 deadline = block.timestamp; + address[] memory stableToCw = _path(p.stableToken, p.cwToken); + address[] memory cwToStable = _path(p.cwToken, p.stableToken); + + IERC20(p.stableToken).forceApprove(p.router, p.rebalanceStableIn); + IUniswapV2Router02Minimal(p.router).swapExactTokensForTokens( + p.rebalanceStableIn, p.minCwFromRebalance, stableToCw, address(this), deadline + ); + + IERC20(p.pair).forceApprove(p.router, p.lpAmount); + (address tokenA, address tokenB) = _sortTokens(p.cwToken, p.stableToken); + (uint256 minA, uint256 minB) = p.cwToken < p.stableToken + ? (p.minCwFromRemove, p.minStableFromRemove) + : (p.minStableFromRemove, p.minCwFromRemove); + IUniswapV2Router02Minimal(p.router).removeLiquidity( + tokenA, tokenB, p.lpAmount, minA, minB, address(this), deadline + ); + + uint256 repayNeed = amount + premium; + uint256 stableBal = IERC20(p.stableToken).balanceOf(address(this)); + if (stableBal < repayNeed && p.cwToSellForRepay > 0) { + IERC20(p.cwToken).forceApprove(p.router, p.cwToSellForRepay); + IUniswapV2Router02Minimal(p.router).swapExactTokensForTokens( + p.cwToSellForRepay, p.minStableFromRepaySwap, cwToStable, address(this), deadline + ); + stableBal = IERC20(p.stableToken).balanceOf(address(this)); + } + + if (stableBal < repayNeed) revert InsufficientToRepay(); + uint256 surplus = stableBal - repayNeed; + IERC20(p.stableToken).forceApprove(pool, repayNeed); + if (surplus > 0) { + IERC20(p.stableToken).safeTransfer(p.recipient, surplus); + } + + uint256 cwLeft = IERC20(p.cwToken).balanceOf(address(this)); + if (cwLeft > 0) { + IERC20(p.cwToken).safeTransfer(p.recipient, cwLeft); + } + + emit RebalanceRemoveExecuted(p.pair, p.stableToken, amount, premium, p.lpAmount, surplus); + } + + function _path(address tokenIn, address tokenOut) internal pure returns (address[] memory path) { + path = new address[](2); + path[0] = tokenIn; + path[1] = tokenOut; + } + + function _sortTokens(address tokenA, address tokenB) internal pure returns (address, address) { + return tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + } +} diff --git a/contracts/registry/UniversalAssetRegistry.sol b/contracts/registry/UniversalAssetRegistry.sol index 15f60b5..9cc2162 100644 --- a/contracts/registry/UniversalAssetRegistry.sol +++ b/contracts/registry/UniversalAssetRegistry.sol @@ -414,7 +414,7 @@ contract UniversalAssetRegistry is jurisdiction, 50, 1, - type(uint256).max / 2 + 100_000_000 * 10 ** 6 ); } @@ -462,7 +462,10 @@ contract UniversalAssetRegistry is asset.volatilityScore = volatilityScore; asset.minBridgeAmount = minBridgeAmount; asset.maxBridgeAmount = maxBridgeAmount; - asset.dailyVolumeLimit = maxBridgeAmount * 10; + unchecked { + asset.dailyVolumeLimit = + maxBridgeAmount > type(uint256).max / 10 ? type(uint256).max : maxBridgeAmount * 10; + } asset.requiresGovernance = _requiresGovernance(assetType, complianceLevel); asset.isActive = true; asset.registeredAt = block.timestamp; diff --git a/contracts/rwa/GruMonetaryPolicyGate.sol b/contracts/rwa/GruMonetaryPolicyGate.sol new file mode 100644 index 0000000..6c1260d --- /dev/null +++ b/contracts/rwa/GruMonetaryPolicyGate.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "../compliance/libraries/MonetaryFormulas.sol"; +import "./IGruMonetaryPolicyGate.sol"; + +/** + * @title GruMonetaryPolicyGate + * @notice Enforces GRU Monetary Policy guardrails before Li* / M00 mint. + * @dev Operator updates metrics from `run-gru-monetary-metrics.mjs`; RWAToken mint calls canMint. + */ +contract GruMonetaryPolicyGate is IGruMonetaryPolicyGate, AccessControl { + bytes32 public constant METRICS_PUBLISHER_ROLE = keccak256("METRICS_PUBLISHER_ROLE"); + bytes32 public constant GATE_ADMIN_ROLE = keccak256("GATE_ADMIN_ROLE"); + + uint256 public immutable coverageMinBps; + uint256 public immutable coverageAlertBps; + uint256 public immutable maxM1ToM00Utilization; + + MetricsSnapshot private _metrics; + mapping(address => bool) public override tokenGateEnabled; + + constructor( + address admin, + uint256 coverageMinBps_, + uint256 coverageAlertBps_, + uint256 maxM1ToM00Utilization_ + ) { + require(admin != address(0), "GruMonetaryPolicyGate: admin"); + require(coverageMinBps_ >= coverageAlertBps_, "GruMonetaryPolicyGate: coverage"); + require(maxM1ToM00Utilization_ > 0, "GruMonetaryPolicyGate: util"); + + coverageMinBps = coverageMinBps_; + coverageAlertBps = coverageAlertBps_; + maxM1ToM00Utilization = maxM1ToM00Utilization_; + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(GATE_ADMIN_ROLE, admin); + _grantRole(METRICS_PUBLISHER_ROLE, admin); + } + + function metrics() external view override returns (MetricsSnapshot memory) { + return _metrics; + } + + function setTokenGate(address token, bool enabled) external onlyRole(GATE_ADMIN_ROLE) { + tokenGateEnabled[token] = enabled; + emit PolicyGateSet(token, enabled); + } + + function updateMetrics( + uint256 coverageRatioBps, + uint256 m1ToM00Utilization, + VelocityZone zone, + bool issuancePaused + ) external onlyRole(METRICS_PUBLISHER_ROLE) { + _metrics = MetricsSnapshot({ + coverageRatioBps: coverageRatioBps, + m1ToM00Utilization: m1ToM00Utilization, + velocityZone: zone, + metricsUpdatedAt: block.timestamp, + issuancePaused: issuancePaused + }); + emit MetricsUpdated(coverageRatioBps, m1ToM00Utilization, zone, issuancePaused); + } + + function canMint(address token, uint256) external view override returns (bool) { + (bool ok,) = canMintReason(token, 0); + return ok; + } + + function canMintReason(address token, uint256) + public + view + override + returns (bool ok, bytes32 reason) + { + if (!tokenGateEnabled[token]) { + return (true, bytes32(0)); + } + if (_metrics.issuancePaused) { + return (false, keccak256("ISSUANCE_PAUSED")); + } + if (_metrics.velocityZone == VelocityZone.Black || _metrics.velocityZone == VelocityZone.Red) { + return (false, keccak256("VELOCITY_ZONE_BLOCK")); + } + if (_metrics.coverageRatioBps < coverageAlertBps) { + return (false, keccak256("COVERAGE_BELOW_ALERT")); + } + if (_metrics.m1ToM00Utilization > maxM1ToM00Utilization) { + return (false, keccak256("M1_M00_UTILIZATION_EXCEEDED")); + } + if (_metrics.coverageRatioBps < coverageMinBps && _metrics.velocityZone >= VelocityZone.AmberHigh) { + return (false, keccak256("COVERAGE_VELOCITY_COMBO")); + } + token; + return (true, bytes32(0)); + } + + /// @notice Pure helper mirroring off-chain metrics job (coverage bps). + function computeCoverageBps(uint256 reserveValue, uint256 circulatingValue) external pure returns (uint256) { + return MonetaryFormulas.coverageRatioBps(reserveValue, circulatingValue); + } +} diff --git a/contracts/rwa/IGruMonetaryPolicyGate.sol b/contracts/rwa/IGruMonetaryPolicyGate.sol new file mode 100644 index 0000000..e508d7f --- /dev/null +++ b/contracts/rwa/IGruMonetaryPolicyGate.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @notice On-chain GRU Monetary Policy mint gate (coverage, velocity zone, utilization). + */ +interface IGruMonetaryPolicyGate { + enum VelocityZone { + Green, + AmberLow, + AmberHigh, + Red, + Black + } + + struct MetricsSnapshot { + uint256 coverageRatioBps; + uint256 m1ToM00Utilization; // whole number ratio numerator (e.g. 25 = 25:1) + VelocityZone velocityZone; + uint256 metricsUpdatedAt; + bool issuancePaused; + } + + event MetricsUpdated(uint256 coverageRatioBps, uint256 m1ToM00Utilization, VelocityZone zone, bool issuancePaused); + event PolicyGateSet(address indexed token, bool enabled); + + function metrics() external view returns (MetricsSnapshot memory); + + function tokenGateEnabled(address token) external view returns (bool); + + function setTokenGate(address token, bool enabled) external; + + function updateMetrics( + uint256 coverageRatioBps, + uint256 m1ToM00Utilization, + VelocityZone zone, + bool issuancePaused + ) external; + + function canMint(address token, uint256 amount) external view returns (bool); + + function canMintReason(address token, uint256 amount) external view returns (bool ok, bytes32 reason); +} diff --git a/contracts/rwa/LiIndexFlashVault.sol b/contracts/rwa/LiIndexFlashVault.sol new file mode 100644 index 0000000..574603b --- /dev/null +++ b/contracts/rwa/LiIndexFlashVault.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; +import {IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol"; +import "./IRWAToken.sol"; + +/** + * @title LiIndexFlashVault + * @notice IERC3156 flash lender for M00 Li* index tokens held in OMNL institutional vault. + * @dev Flash borrow permitted only when utilization LTV is below GRU Monetary Policy ceiling + * (default 80% = 20% operational buffer). Enforces index freshness and MPAP parity band. + */ +contract LiIndexFlashVault is IERC3156FlashLender, AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant VAULT_MANAGER_ROLE = keccak256("VAULT_MANAGER_ROLE"); + bytes32 private constant _FLASH_RETURN = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + /// @notice Max utilization LTV before flash disabled (8000 = 80%, retains 20% buffer). + uint256 public maxUtilizationLtvBps; + /// @notice Max single flash as share of vault balance (5000 = 50%). + uint256 public maxFlashShareOfVaultBps; + /// @notice Flash fee in basis points (≤ 9 bps per policy). + uint256 public flashFeeBps; + /// @notice MPAP parity band — block flash if index drift vs XAU anchor exceeds this. + uint256 public mpapParityBandBps; + /// @notice Max indexValue age (seconds) for flash eligibility. + uint256 public maxIndexAgeSeconds; + /// @notice XAU/USD anchor for parity drift check (8 decimals, e.g. 2400_00000000). + uint256 public xauUsdAnchor8; + + mapping(address => bool) public supportedToken; + mapping(address => uint256) public vaultBalance; + mapping(address => uint256) public outstandingFlash; + + event TokenSupported(address indexed token, bool supported); + event Deposited(address indexed token, address indexed from, uint256 amount); + event Withdrawn(address indexed token, address indexed to, uint256 amount); + event RiskParamsUpdated( + uint256 maxUtilizationLtvBps, + uint256 maxFlashShareOfVaultBps, + uint256 flashFeeBps, + uint256 mpapParityBandBps + ); + + constructor( + address admin, + uint256 maxUtilizationLtvBps_, + uint256 maxFlashShareOfVaultBps_, + uint256 flashFeeBps_, + uint256 mpapParityBandBps_, + uint256 maxIndexAgeSeconds_, + uint256 xauUsdAnchor8_ + ) { + require(admin != address(0), "LiIndexFlashVault: zero admin"); + require(maxUtilizationLtvBps_ <= 10_000, "LiIndexFlashVault: ltv"); + require(maxFlashShareOfVaultBps_ <= 10_000, "LiIndexFlashVault: flash share"); + require(flashFeeBps_ <= 1000, "LiIndexFlashVault: fee"); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(VAULT_MANAGER_ROLE, admin); + + maxUtilizationLtvBps = maxUtilizationLtvBps_; + maxFlashShareOfVaultBps = maxFlashShareOfVaultBps_; + flashFeeBps = flashFeeBps_; + mpapParityBandBps = mpapParityBandBps_; + maxIndexAgeSeconds = maxIndexAgeSeconds_; + xauUsdAnchor8 = xauUsdAnchor8_; + } + + function setSupportedToken(address token, bool supported) external onlyRole(VAULT_MANAGER_ROLE) { + supportedToken[token] = supported; + emit TokenSupported(token, supported); + } + + function setRiskParams( + uint256 maxUtilizationLtvBps_, + uint256 maxFlashShareOfVaultBps_, + uint256 flashFeeBps_, + uint256 mpapParityBandBps_ + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(maxUtilizationLtvBps_ <= 10_000, "LiIndexFlashVault: ltv"); + require(maxFlashShareOfVaultBps_ <= 10_000, "LiIndexFlashVault: flash share"); + require(flashFeeBps_ <= 1000, "LiIndexFlashVault: fee"); + maxUtilizationLtvBps = maxUtilizationLtvBps_; + maxFlashShareOfVaultBps = maxFlashShareOfVaultBps_; + flashFeeBps = flashFeeBps_; + mpapParityBandBps = mpapParityBandBps_; + emit RiskParamsUpdated(maxUtilizationLtvBps_, maxFlashShareOfVaultBps_, flashFeeBps_, mpapParityBandBps_); + } + + function deposit(address token, uint256 amount) external nonReentrant { + require(supportedToken[token], "LiIndexFlashVault: unsupported"); + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + vaultBalance[token] += amount; + emit Deposited(token, msg.sender, amount); + } + + function withdraw(address token, uint256 amount, address to) external nonReentrant onlyRole(VAULT_MANAGER_ROLE) { + require(to != address(0), "LiIndexFlashVault: zero to"); + vaultBalance[token] -= amount; + IERC20(token).safeTransfer(to, amount); + emit Withdrawn(token, to, amount); + } + + function currentUtilizationBps(address token) public view returns (uint256) { + uint256 bal = vaultBalance[token]; + if (bal == 0) return 0; + return (outstandingFlash[token] * 10_000) / bal; + } + + function flashBorrowEligible(address token) public view returns (bool) { + if (!supportedToken[token]) return false; + if (currentUtilizationBps(token) >= maxUtilizationLtvBps) return false; + + IRWAToken idx = IRWAToken(token); + if (!idx.isRwaIndex()) return false; + + uint256 updatedAt = idx.indexUpdatedAt(); + if (updatedAt == 0 || block.timestamp - updatedAt > maxIndexAgeSeconds) return false; + + uint256 indexLevel = idx.indexValue(); + if (indexLevel == 0) return false; + + if (xauUsdAnchor8 > 0 && mpapParityBandBps > 0) { + uint256 anchorLevel6 = (xauUsdAnchor8 / 1e2); + if (anchorLevel6 > 0) { + uint256 driftBps = indexLevel > anchorLevel6 + ? ((indexLevel - anchorLevel6) * 10_000) / indexLevel + : ((anchorLevel6 - indexLevel) * 10_000) / anchorLevel6; + if (driftBps >= mpapParityBandBps) return false; + } + } + return true; + } + + function maxFlashLoan(address token) external view returns (uint256) { + if (!flashBorrowEligible(token)) return 0; + uint256 bal = vaultBalance[token]; + uint256 outstanding = outstandingFlash[token]; + uint256 lendable = (bal * maxUtilizationLtvBps) / 10_000; + if (outstanding >= lendable) return 0; + uint256 headroom = lendable - outstanding; + uint256 cap = (bal * maxFlashShareOfVaultBps) / 10_000; + return headroom < cap ? headroom : cap; + } + + function flashFee(address token, uint256 amount) external view returns (uint256) { + token; + amount; + return (amount * flashFeeBps) / 10_000; + } + + function flashLoan( + IERC3156FlashBorrower receiver, + address token, + uint256 amount, + bytes calldata data + ) external nonReentrant returns (bool) { + require(flashBorrowEligible(token), "LiIndexFlashVault: not eligible"); + require(amount <= this.maxFlashLoan(token), "LiIndexFlashVault: exceeds max"); + + uint256 fee = (amount * flashFeeBps) / 10_000; + outstandingFlash[token] += amount; + IERC20(token).safeTransfer(address(receiver), amount); + + require( + receiver.onFlashLoan(msg.sender, token, amount, fee, data) == _FLASH_RETURN, + "LiIndexFlashVault: callback failed" + ); + + IERC20(token).safeTransferFrom(address(receiver), address(this), amount + fee); + outstandingFlash[token] -= amount; + if (fee > 0) { + vaultBalance[token] += fee; + } + return true; + } +} diff --git a/contracts/rwa/RWAToken.sol b/contracts/rwa/RWAToken.sol index 2c8b68a..f1100a8 100644 --- a/contracts/rwa/RWAToken.sol +++ b/contracts/rwa/RWAToken.sol @@ -11,6 +11,7 @@ import "./IRWAToken.sol"; import "./RWATokenInterfaces.sol"; import "./RWAEIP712.sol"; import "./libraries/RWATaxonomy.sol"; +import "./IGruMonetaryPolicyGate.sol"; /** * @title RWAToken @@ -31,6 +32,9 @@ contract RWAToken is ERC20, Pausable, Ownable, LegallyCompliantBase, IRWAToken { Classification private _classification; uint256 private _indexValue; uint256 private _indexUpdatedAt; + address private _policyGate; + + event PolicyGateUpdated(address indexed previousGate, address indexed newGate); constructor( string memory name_, @@ -146,6 +150,15 @@ contract RWAToken is ERC20, Pausable, Ownable, LegallyCompliantBase, IRWAToken { || super.supportsInterface(interfaceId); } + function policyGate() external view returns (address) { + return _policyGate; + } + + function setPolicyGate(address gate) external onlyOwner { + emit PolicyGateUpdated(_policyGate, gate); + _policyGate = gate; + } + function updateIndexValue(uint256 newValue) external override onlyRole(INDEX_PUBLISHER_ROLE) { uint256 old = _indexValue; _setIndexValue(newValue); @@ -153,6 +166,9 @@ contract RWAToken is ERC20, Pausable, Ownable, LegallyCompliantBase, IRWAToken { } function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { + if (_policyGate != address(0)) { + require(IGruMonetaryPolicyGate(_policyGate).canMint(address(this), amount), "RWAToken: policy gate"); + } _mint(to, amount); }