diff --git a/contracts/dbis/BUILD_NOTES.md b/contracts/dbis/BUILD_NOTES.md new file mode 100644 index 0000000..1f9693b --- /dev/null +++ b/contracts/dbis/BUILD_NOTES.md @@ -0,0 +1,8 @@ +# DBIS Rail — Build notes + +- **Contracts:** All DBIS Rail contracts (RootRegistry, ParticipantRegistry, SignerRegistry, SettlementRouter, GRU_MintController, StablecoinReferenceRegistry, Conversion Router) are in this folder and implement Technical Spec v1 and v1.5 add-ons. +- **Tests:** `test/dbis/DBIS_Rail.t.sol` covers submitMintAuth success, replay revert, and signer-revoked-at-block. +- **Build:** With default Foundry config (`via_ir = true`, `optimizer_runs = 200`) the compiler may report a Yul stack-too-deep error. If so: + - Try `FOUNDRY_PROFILE=lite forge test --match-path "test/dbis/*.t.sol"` (note: lite uses `via_ir = false`, which can cause Solidity “Stack too deep” in other units). + - Or reduce complexity in the heaviest functions (e.g. further split `submitMintAuth` / `submitSwapAuth` or reduce locals in SignerRegistry) until the default profile builds. +- **Deploy:** Run `DeployDBISRail.s.sol` on Chain 138; then set GRU token on MintController, grant MINTER_ROLE on c* tokens to MintController, register stablecoins, and add venues/quote issuers as needed. diff --git a/contracts/dbis/DBIS_ConversionRouter.sol b/contracts/dbis/DBIS_ConversionRouter.sol new file mode 100644 index 0000000..1154de5 --- /dev/null +++ b/contracts/dbis/DBIS_ConversionRouter.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "./DBIS_RootRegistry.sol"; +import "./DBIS_SignerRegistry.sol"; +import "./StablecoinReferenceRegistry.sol"; + +interface IBlocklist { + function isBlocked(address account) external view returns (bool); +} + +struct SwapAuth { + bytes32 messageId; + bytes32 lpaId; + bytes32 venue; + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + uint256 deadline; + bytes32 quoteHash; + address quoteIssuer; + uint256 chainId; + address verifyingContract; +} + +contract DBIS_ConversionRouter is AccessControl, Pausable, ReentrancyGuard { + bytes32 public constant ROUTER_ADMIN_ROLE = keccak256("ROUTER_ADMIN"); + uint256 public constant CHAIN_ID = 138; + + bytes32 public constant SIGNER_REGISTRY_KEY = keccak256("SignerRegistry"); + bytes32 public constant STABLECOIN_REGISTRY_KEY = keccak256("StablecoinReferenceRegistry"); + + bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 private constant SWAPAUTH_TYPEHASH = keccak256( + "SwapAuth(bytes32 messageId,bytes32 lpaId,bytes32 venue,address tokenIn,address tokenOut,uint256 amountIn,uint256 minAmountOut,uint256 deadline,bytes32 quoteHash,address quoteIssuer,uint256 chainId,address verifyingContract)" + ); + + DBIS_RootRegistry public rootRegistry; + mapping(bytes32 => bool) public usedSwapMessageIds; + mapping(bytes32 => bool) public venueAllowlist; + mapping(address => bool) public quoteIssuerAllowlist; + address public blocklistContract; + + event ConversionExecuted( + bytes32 indexed messageId, + bytes32 indexed quoteHash, + bytes32 venue, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut, + address quoteIssuer + ); + + constructor(address admin, address _rootRegistry) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ROUTER_ADMIN_ROLE, admin); + rootRegistry = DBIS_RootRegistry(_rootRegistry); + } + + function addVenue(bytes32 venue) external onlyRole(ROUTER_ADMIN_ROLE) { + venueAllowlist[venue] = true; + } + + function removeVenue(bytes32 venue) external onlyRole(ROUTER_ADMIN_ROLE) { + venueAllowlist[venue] = false; + } + + function addQuoteIssuer(address issuer) external onlyRole(ROUTER_ADMIN_ROLE) { + quoteIssuerAllowlist[issuer] = true; + } + + function removeQuoteIssuer(address issuer) external onlyRole(ROUTER_ADMIN_ROLE) { + quoteIssuerAllowlist[issuer] = false; + } + + function setBlocklist(address _blocklist) external onlyRole(ROUTER_ADMIN_ROLE) { + blocklistContract = _blocklist; + } + + function pause() external onlyRole(ROUTER_ADMIN_ROLE) { + _pause(); + } + + function unpause() external onlyRole(ROUTER_ADMIN_ROLE) { + _unpause(); + } + + function _domainSeparator() private view returns (bytes32) { + return keccak256( + abi.encode( + EIP712_DOMAIN_TYPEHASH, + keccak256("DBISConversionRouter"), + keccak256("1"), + CHAIN_ID, + address(this) + ) + ); + } + + function _hashSwapAuth(SwapAuth calldata auth) private pure returns (bytes32) { + return keccak256( + abi.encode( + SWAPAUTH_TYPEHASH, + auth.messageId, + auth.lpaId, + auth.venue, + auth.tokenIn, + auth.tokenOut, + auth.amountIn, + auth.minAmountOut, + auth.deadline, + auth.quoteHash, + auth.quoteIssuer, + auth.chainId, + auth.verifyingContract + ) + ); + } + + function getSwapAuthDigest(SwapAuth calldata auth) external view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), _hashSwapAuth(auth))); + } + + function submitSwapAuth(SwapAuth calldata auth, bytes[] calldata signatures, uint256 amountOut) external nonReentrant whenNotPaused { + require(auth.chainId == CHAIN_ID, "DBIS: wrong chain"); + require(auth.verifyingContract == address(this), "DBIS: wrong contract"); + require(block.timestamp <= auth.deadline, "DBIS: expired"); + require(!usedSwapMessageIds[auth.messageId], "DBIS: replay"); + require(venueAllowlist[auth.venue], "DBIS: venue not allowed"); + require(quoteIssuerAllowlist[auth.quoteIssuer], "DBIS: quote issuer not allowed"); + require(amountOut >= auth.minAmountOut, "DBIS: slippage"); + _requireStablecoinActive(auth.tokenOut); + _requireNotBlocked(); + address[] memory signers = _recoverSwapSigners(auth, signatures); + DBIS_SignerRegistry signerReg = DBIS_SignerRegistry(rootRegistry.getComponent(SIGNER_REGISTRY_KEY)); + (bool ok, ) = signerReg.validateSignersForSwap(signers, auth.amountIn); + require(ok, "DBIS: quorum not met"); + usedSwapMessageIds[auth.messageId] = true; + emit ConversionExecuted( + auth.messageId, + auth.quoteHash, + auth.venue, + auth.tokenIn, + auth.tokenOut, + auth.amountIn, + amountOut, + auth.quoteIssuer + ); + } + + function _requireStablecoinActive(address tokenOut) private view { + StablecoinReferenceRegistry stableReg = StablecoinReferenceRegistry(rootRegistry.getComponent(STABLECOIN_REGISTRY_KEY)); + if (address(stableReg) != address(0)) require(stableReg.isActive(tokenOut), "DBIS: tokenOut not active"); + } + + function _requireNotBlocked() private view { + if (blocklistContract != address(0)) require(!IBlocklist(blocklistContract).isBlocked(msg.sender), "DBIS: blocked"); + } + + function _recoverSwapSigners(SwapAuth calldata auth, bytes[] calldata signatures) private view returns (address[] memory signers) { + DBIS_SignerRegistry signerReg = DBIS_SignerRegistry(rootRegistry.getComponent(SIGNER_REGISTRY_KEY)); + require(address(signerReg) != address(0), "DBIS: no signer registry"); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), _hashSwapAuth(auth))); + signers = new address[](signatures.length); + for (uint256 i = 0; i < signatures.length; i++) { + require(signatures[i].length == 65, "DBIS: bad sig len"); + address signer = _recover(digest, signatures[i]); + require(signer != address(0), "DBIS: invalid sig"); + require(signerReg.isSignerActiveAtBlock(signer, block.number), "DBIS: signer not active"); + for (uint256 j = 0; j < i; j++) require(signers[j] != signer, "DBIS: duplicate signer"); + signers[i] = signer; + } + } + + function _recover(bytes32 digest, bytes calldata signature) private pure returns (address) { + require(signature.length == 65, "DBIS: sig length"); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 32)) + v := byte(0, calldataload(add(signature.offset, 64))) + } + if (v < 27) v += 27; + require(v == 27 || v == 28, "DBIS: invalid v"); + return ecrecover(digest, v, r, s); + } +} diff --git a/contracts/dbis/DBIS_EIP712Lib.sol b/contracts/dbis/DBIS_EIP712Lib.sol new file mode 100644 index 0000000..c074c15 --- /dev/null +++ b/contracts/dbis/DBIS_EIP712Lib.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title DBIS_EIP712Lib + * @notice Library for EIP-712 hashing and ecrecover to avoid stack-too-deep in router. + */ +library DBIS_EIP712Lib { + function hashAddressArray(address[] calldata arr) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](arr.length); + for (uint256 i = 0; i < arr.length; i++) { + hashes[i] = keccak256(abi.encode(arr[i])); + } + return keccak256(abi.encodePacked(hashes)); + } + + function hashUint256Array(uint256[] calldata arr) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](arr.length); + for (uint256 i = 0; i < arr.length; i++) { + hashes[i] = keccak256(abi.encode(arr[i])); + } + return keccak256(abi.encodePacked(hashes)); + } + + function getMintAuthStructHash( + bytes32 typeHash, + bytes32 messageId, + bytes32 isoType, + bytes32 isoHash, + bytes32 accountingRef, + uint8 fundsStatus, + bytes32 corridor, + uint8 assetClass, + bytes32 recipientsHash, + bytes32 amountsHash, + uint64 notBefore, + uint64 expiresAt, + uint256 chainId, + address verifyingContract + ) internal pure returns (bytes32) { + return keccak256(abi.encode( + typeHash, + messageId, + isoType, + isoHash, + accountingRef, + fundsStatus, + corridor, + assetClass, + recipientsHash, + amountsHash, + notBefore, + expiresAt, + chainId, + verifyingContract + )); + } + + function getDigest(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function recover(bytes32 digest, bytes calldata signature) internal pure returns (address) { + require(signature.length == 65, "DBIS: sig length"); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 32)) + v := byte(0, calldataload(add(signature.offset, 64))) + } + if (v < 27) v += 27; + require(v == 27 || v == 28, "DBIS: invalid v"); + return ecrecover(digest, v, r, s); + } +} diff --git a/contracts/dbis/DBIS_GRU_MintController.sol b/contracts/dbis/DBIS_GRU_MintController.sol new file mode 100644 index 0000000..c291a43 --- /dev/null +++ b/contracts/dbis/DBIS_GRU_MintController.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "./IDBISTypes.sol"; + +interface IERC20Mintable { + function mint(address to, uint256 amount) external; +} + +contract DBIS_GRU_MintController is AccessControl, Pausable, ReentrancyGuard { + bytes32 public constant ROUTER_ADMIN_ROLE = keccak256("ROUTER_ADMIN"); + + address public settlementRouter; + address public gruToken; + + event MintFromAuthorization(bytes32 indexed messageId, address indexed recipient, uint256 amount, uint8 assetClass); + + constructor(address admin, address _settlementRouter) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ROUTER_ADMIN_ROLE, admin); + settlementRouter = _settlementRouter; + } + + function setSettlementRouter(address _router) external onlyRole(ROUTER_ADMIN_ROLE) { + settlementRouter = _router; + } + + function setGruToken(address _token) external onlyRole(ROUTER_ADMIN_ROLE) { + gruToken = _token; + } + + function pause() external onlyRole(ROUTER_ADMIN_ROLE) { + _pause(); + } + + function unpause() external onlyRole(ROUTER_ADMIN_ROLE) { + _unpause(); + } + + function mintFromAuthorization(IDBISTypes.MintAuth calldata auth) external nonReentrant whenNotPaused { + require(msg.sender == settlementRouter, "DBIS: only router"); + require(gruToken != address(0), "DBIS: token not set"); + require(auth.recipients.length == auth.amounts.length, "DBIS: length mismatch"); + IERC20Mintable token = IERC20Mintable(gruToken); + for (uint256 i = 0; i < auth.recipients.length; i++) { + require(auth.recipients[i] != address(0), "DBIS: zero recipient"); + token.mint(auth.recipients[i], auth.amounts[i]); + emit MintFromAuthorization(auth.messageId, auth.recipients[i], auth.amounts[i], uint8(auth.assetClass)); + } + } +} diff --git a/contracts/dbis/DBIS_ParticipantRegistry.sol b/contracts/dbis/DBIS_ParticipantRegistry.sol new file mode 100644 index 0000000..790b7e4 --- /dev/null +++ b/contracts/dbis/DBIS_ParticipantRegistry.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract DBIS_ParticipantRegistry is AccessControl { + bytes32 public constant PARTICIPANT_ADMIN_ROLE = keccak256("PARTICIPANT_ADMIN"); + + enum EntityType { BANK, MSB, CUSTODIAN, TRUSTEE, OPERATOR, AUDITOR } + enum Status { ACTIVE, SUSPENDED, REVOKED } + + struct Participant { + bytes32 participantId; + string legalName; + string jurisdiction; + EntityType entityType; + Status status; + bytes32[] policyTags; + address[] operationalWallets; + } + + mapping(bytes32 => Participant) private _participants; + mapping(bytes32 => uint8) private _participantStatus; + mapping(address => bytes32) private _walletToParticipant; + + event ParticipantRegistered(bytes32 indexed participantId, string legalName, string jurisdiction, uint8 entityType); + event ParticipantStatusChanged(bytes32 indexed participantId, uint8 status); + event OperationalWalletAdded(bytes32 indexed participantId, address wallet); + event OperationalWalletRemoved(bytes32 indexed participantId, address wallet); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(PARTICIPANT_ADMIN_ROLE, admin); + } + + function registerParticipant(Participant calldata p) external onlyRole(PARTICIPANT_ADMIN_ROLE) { + require(p.participantId != bytes32(0), "DBIS: zero participantId"); + require(_participants[p.participantId].participantId == bytes32(0), "DBIS: already registered"); + _participants[p.participantId] = Participant({ + participantId: p.participantId, + legalName: p.legalName, + jurisdiction: p.jurisdiction, + entityType: p.entityType, + status: p.status, + policyTags: p.policyTags, + operationalWallets: p.operationalWallets + }); + _participantStatus[p.participantId] = uint8(p.status); + _linkWalletsToParticipant(p.participantId, p.operationalWallets); + emit ParticipantRegistered(p.participantId, p.legalName, p.jurisdiction, uint8(p.entityType)); + } + + function setParticipantStatus(bytes32 participantId, Status s) external onlyRole(PARTICIPANT_ADMIN_ROLE) { + require(_participants[participantId].participantId != bytes32(0), "DBIS: unknown participant"); + _participants[participantId].status = s; + _participantStatus[participantId] = uint8(s); + emit ParticipantStatusChanged(participantId, uint8(s)); + } + + function addOperationalWallet(bytes32 participantId, address wallet) external onlyRole(PARTICIPANT_ADMIN_ROLE) { + require(_participants[participantId].participantId != bytes32(0), "DBIS: unknown participant"); + require(wallet != address(0), "DBIS: zero wallet"); + _participants[participantId].operationalWallets.push(wallet); + _walletToParticipant[wallet] = participantId; + emit OperationalWalletAdded(participantId, wallet); + } + + function removeOperationalWallet(bytes32 participantId, address wallet) external onlyRole(PARTICIPANT_ADMIN_ROLE) { + require(_participants[participantId].participantId != bytes32(0), "DBIS: unknown participant"); + address[] storage wallets = _participants[participantId].operationalWallets; + for (uint256 i = 0; i < wallets.length; i++) { + if (wallets[i] == wallet) { + wallets[i] = wallets[wallets.length - 1]; + wallets.pop(); + if (_walletToParticipant[wallet] == participantId) delete _walletToParticipant[wallet]; + emit OperationalWalletRemoved(participantId, wallet); + return; + } + } + revert("DBIS: wallet not found"); + } + + function isOperationalWallet(address wallet) external view returns (bool) { + bytes32 pid = _walletToParticipant[wallet]; + if (pid == bytes32(0)) return false; + return _participantStatus[pid] == uint8(Status.ACTIVE); + } + + function getParticipantByWallet(address wallet) external view returns (bytes32 participantId) { + return _walletToParticipant[wallet]; + } + + function getParticipantStatus(bytes32 participantId) external view returns (Status) { + return _participants[participantId].status; + } + + function getParticipant(bytes32 participantId) external view returns (Participant memory) { + return _participants[participantId]; + } + + function _linkWalletsToParticipant(bytes32 participantId, address[] calldata wallets) private { + for (uint256 i = 0; i < wallets.length; i++) { + if (wallets[i] != address(0)) _walletToParticipant[wallets[i]] = participantId; + } + } +} diff --git a/contracts/dbis/DBIS_RootRegistry.sol b/contracts/dbis/DBIS_RootRegistry.sol new file mode 100644 index 0000000..883cc24 --- /dev/null +++ b/contracts/dbis/DBIS_RootRegistry.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract DBIS_RootRegistry is AccessControl { + bytes32 public constant ROOT_ADMIN_ROLE = keccak256("ROOT_ADMIN"); + mapping(bytes32 => address) private _components; + string public railVersion; + + event ComponentUpdated(bytes32 indexed key, address indexed addr, string railVersion); + + constructor(address admin, string memory _railVersion) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ROOT_ADMIN_ROLE, admin); + railVersion = _railVersion; + } + + function setComponent(bytes32 key, address addr) external onlyRole(ROOT_ADMIN_ROLE) { + _components[key] = addr; + emit ComponentUpdated(key, addr, railVersion); + } + + function getComponent(bytes32 key) external view returns (address) { + return _components[key]; + } + + function setRailVersion(string calldata _version) external onlyRole(ROOT_ADMIN_ROLE) { + railVersion = _version; + } +} diff --git a/contracts/dbis/DBIS_SettlementRouter.sol b/contracts/dbis/DBIS_SettlementRouter.sol new file mode 100644 index 0000000..73ba5b3 --- /dev/null +++ b/contracts/dbis/DBIS_SettlementRouter.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "./IDBISTypes.sol"; +import "./DBIS_EIP712Lib.sol"; +import "./DBIS_RootRegistry.sol"; +import "./DBIS_ParticipantRegistry.sol"; +import "./DBIS_SignerRegistry.sol"; +import "./DBIS_GRU_MintController.sol"; + +contract DBIS_SettlementRouter is AccessControl, Pausable, ReentrancyGuard { + bytes32 public constant ROUTER_ADMIN_ROLE = keccak256("ROUTER_ADMIN"); + + uint256 public constant CHAIN_ID = 138; + + bytes32 public constant PARTICIPANT_REGISTRY_KEY = keccak256("ParticipantRegistry"); + bytes32 public constant SIGNER_REGISTRY_KEY = keccak256("SignerRegistry"); + bytes32 public constant GRU_MINT_CONTROLLER_KEY = keccak256("GRUMintController"); + + bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 private constant MINTAUTH_TYPEHASH = keccak256( + "MintAuth(bytes32 messageId,bytes32 isoType,bytes32 isoHash,bytes32 accountingRef,uint8 fundsStatus,bytes32 corridor,uint8 assetClass,bytes32 recipientsHash,bytes32 amountsHash,uint64 notBefore,uint64 expiresAt,uint256 chainId,address verifyingContract)" + ); + + DBIS_RootRegistry public rootRegistry; + mapping(bytes32 => bool) public usedMessageIds; + uint256 public maxAmountPerMessage; + mapping(bytes32 => uint256) public corridorDailyCap; + mapping(bytes32 => mapping(uint256 => uint256)) public corridorUsedToday; + + event SettlementRecorded( + bytes32 indexed messageId, + bytes32 indexed isoType, + bytes32 isoHash, + bytes32 accountingRef, + uint8 fundsStatus, + bytes32 corridor, + uint8 assetClass, + uint256 totalAmount + ); + event MintExecuted(bytes32 indexed messageId, address indexed recipient, uint256 amount, uint8 assetClass); + event MessageIdConsumed(bytes32 indexed messageId); + event RouterPaused(bool paused); + + constructor(address admin, address _rootRegistry) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(ROUTER_ADMIN_ROLE, admin); + rootRegistry = DBIS_RootRegistry(_rootRegistry); + maxAmountPerMessage = type(uint256).max; + } + + function setMaxAmountPerMessage(uint256 cap) external onlyRole(ROUTER_ADMIN_ROLE) { + maxAmountPerMessage = cap; + } + + function setCorridorDailyCap(bytes32 corridor, uint256 cap) external onlyRole(ROUTER_ADMIN_ROLE) { + corridorDailyCap[corridor] = cap; + } + + function pause() external onlyRole(ROUTER_ADMIN_ROLE) { + _pause(); + emit RouterPaused(true); + } + + function unpause() external onlyRole(ROUTER_ADMIN_ROLE) { + _unpause(); + emit RouterPaused(false); + } + + function _domainSeparator() private view returns (bytes32) { + return keccak256( + abi.encode( + EIP712_DOMAIN_TYPEHASH, + keccak256("DBISSettlementRouter"), + keccak256("1"), + CHAIN_ID, + address(this) + ) + ); + } + + function _hashMintAuth(IDBISTypes.MintAuth calldata auth) private pure returns (bytes32) { + bytes32 rh = DBIS_EIP712Lib.hashAddressArray(auth.recipients); + bytes32 ah = DBIS_EIP712Lib.hashUint256Array(auth.amounts); + return DBIS_EIP712Lib.getMintAuthStructHash( + MINTAUTH_TYPEHASH, + auth.messageId, + auth.isoType, + auth.isoHash, + auth.accountingRef, + uint8(auth.fundsStatus), + auth.corridor, + uint8(auth.assetClass), + rh, + ah, + auth.notBefore, + auth.expiresAt, + auth.chainId, + auth.verifyingContract + ); + } + + function getMintAuthDigest(IDBISTypes.MintAuth calldata auth) external view returns (bytes32) { + return DBIS_EIP712Lib.getDigest(_domainSeparator(), _hashMintAuth(auth)); + } + + function submitMintAuth(IDBISTypes.MintAuth calldata auth, bytes[] calldata signatures) external nonReentrant whenNotPaused { + require(auth.chainId == CHAIN_ID, "DBIS: wrong chain"); + require(auth.verifyingContract == address(this), "DBIS: wrong contract"); + require(block.timestamp >= auth.notBefore && block.timestamp <= auth.expiresAt, "DBIS: time window"); + require(!usedMessageIds[auth.messageId], "DBIS: replay"); + require(auth.recipients.length == auth.amounts.length, "DBIS: length mismatch"); + require(auth.recipients.length > 0, "DBIS: no recipients"); + + uint256 totalAmount = _sumAmounts(auth.amounts); + require(totalAmount <= maxAmountPerMessage, "DBIS: cap exceeded"); + + uint256 day = block.timestamp / 1 days; + require(_checkCorridorCap(auth.corridor, day, totalAmount), "DBIS: corridor cap"); + + _requireRecipientsOperational(auth.recipients); + address[] memory signers = _recoverSigners(auth, signatures); + DBIS_SignerRegistry signerReg = DBIS_SignerRegistry(rootRegistry.getComponent(SIGNER_REGISTRY_KEY)); + (bool ok, ) = signerReg.validateSigners(signers); + require(ok, "DBIS: quorum not met"); + + usedMessageIds[auth.messageId] = true; + corridorUsedToday[auth.corridor][day] = corridorUsedToday[auth.corridor][day] + totalAmount; + + _emitSettlementEvents(auth, totalAmount); + DBIS_GRU_MintController mintController = DBIS_GRU_MintController(rootRegistry.getComponent(GRU_MINT_CONTROLLER_KEY)); + require(address(mintController) != address(0), "DBIS: no mint controller"); + mintController.mintFromAuthorization(auth); + } + + function _sumAmounts(uint256[] calldata amounts) private pure returns (uint256) { + uint256 total = 0; + for (uint256 i = 0; i < amounts.length; i++) total += amounts[i]; + return total; + } + + function _checkCorridorCap(bytes32 corridor, uint256 day, uint256 totalAmount) private view returns (bool) { + uint256 cap = corridorDailyCap[corridor]; + if (cap == 0) return true; + return corridorUsedToday[corridor][day] + totalAmount <= cap; + } + + function _requireRecipientsOperational(address[] calldata recipients) private view { + DBIS_ParticipantRegistry participantReg = DBIS_ParticipantRegistry(rootRegistry.getComponent(PARTICIPANT_REGISTRY_KEY)); + require(address(participantReg) != address(0), "DBIS: no participant registry"); + for (uint256 i = 0; i < recipients.length; i++) { + require(participantReg.isOperationalWallet(recipients[i]), "DBIS: not operational wallet"); + } + } + + function _recoverSigners(IDBISTypes.MintAuth calldata auth, bytes[] calldata signatures) private view returns (address[] memory signers) { + DBIS_SignerRegistry signerReg = DBIS_SignerRegistry(rootRegistry.getComponent(SIGNER_REGISTRY_KEY)); + require(address(signerReg) != address(0), "DBIS: no signer registry"); + bytes32 digest = DBIS_EIP712Lib.getDigest(_domainSeparator(), _hashMintAuth(auth)); + signers = new address[](signatures.length); + for (uint256 i = 0; i < signatures.length; i++) { + require(signatures[i].length == 65, "DBIS: bad sig len"); + address signer = DBIS_EIP712Lib.recover(digest, signatures[i]); + require(signer != address(0), "DBIS: invalid sig"); + require(signerReg.isSignerActiveAtBlock(signer, block.number), "DBIS: signer not active at block"); + for (uint256 j = 0; j < i; j++) require(signers[j] != signer, "DBIS: duplicate signer"); + signers[i] = signer; + } + } + + function _emitSettlementEvents(IDBISTypes.MintAuth calldata auth, uint256 totalAmount) private { + emit SettlementRecorded( + auth.messageId, + auth.isoType, + auth.isoHash, + auth.accountingRef, + uint8(auth.fundsStatus), + auth.corridor, + uint8(auth.assetClass), + totalAmount + ); + for (uint256 i = 0; i < auth.recipients.length; i++) { + emit MintExecuted(auth.messageId, auth.recipients[i], auth.amounts[i], uint8(auth.assetClass)); + } + emit MessageIdConsumed(auth.messageId); + } + +} diff --git a/contracts/dbis/DBIS_SignerRegistry.sol b/contracts/dbis/DBIS_SignerRegistry.sol new file mode 100644 index 0000000..645d2fe --- /dev/null +++ b/contracts/dbis/DBIS_SignerRegistry.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract DBIS_SignerRegistry is AccessControl { + bytes32 public constant SIGNER_ADMIN_ROLE = keccak256("SIGNER_ADMIN"); + + uint8 public constant CATEGORY_OPS = 0; + uint8 public constant CATEGORY_COMPLIANCE = 1; + uint8 public constant CATEGORY_CUSTODY = 2; + uint8 public constant CATEGORY_RISK = 3; + uint8 public constant CATEGORY_AUDITOR = 4; + + uint256 private constant NEVER_REVOKED = type(uint256).max; + + struct SignerInfo { + uint8 category; + uint256 effectiveFromBlock; + uint256 revokedAtBlock; + bool exists; + } + + mapping(address => SignerInfo) private _signers; + address[] private _signerList; + + uint8 public requiredSignatures = 3; + uint256 public categoryMaskRequired = 1 << CATEGORY_COMPLIANCE; + uint256 public categoryMaskAllowed = (1 << CATEGORY_OPS) | (1 << CATEGORY_COMPLIANCE) | (1 << CATEGORY_CUSTODY) | (1 << CATEGORY_RISK) | (1 << CATEGORY_AUDITOR); + + event SignerAdded(address indexed signer, uint8 category); + event SignerRemoved(address indexed signer); + event QuorumUpdated(uint8 requiredSigs, uint256 requiredMask, uint256 allowedMask); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(SIGNER_ADMIN_ROLE, admin); + } + + function addSigner(address signer, uint8 category) external onlyRole(SIGNER_ADMIN_ROLE) { + require(signer != address(0), "DBIS: zero signer"); + require(!_signers[signer].exists, "DBIS: already signer"); + require(category <= CATEGORY_AUDITOR, "DBIS: invalid category"); + _signers[signer] = SignerInfo({ + category: category, + effectiveFromBlock: block.number, + revokedAtBlock: NEVER_REVOKED, + exists: true + }); + _signerList.push(signer); + emit SignerAdded(signer, category); + } + + function removeSigner(address signer) external onlyRole(SIGNER_ADMIN_ROLE) { + require(_signers[signer].exists, "DBIS: not signer"); + if (_signers[signer].revokedAtBlock == NEVER_REVOKED) { + _signers[signer].revokedAtBlock = block.number; + } + _signers[signer].exists = false; + for (uint256 i = 0; i < _signerList.length; i++) { + if (_signerList[i] == signer) { + _signerList[i] = _signerList[_signerList.length - 1]; + _signerList.pop(); + break; + } + } + emit SignerRemoved(signer); + } + + function revokeSignerAtBlock(address signer) external onlyRole(SIGNER_ADMIN_ROLE) { + require(_signers[signer].exists, "DBIS: not signer"); + _signers[signer].revokedAtBlock = block.number; + } + + function setQuorum(uint8 requiredSigs, uint256 requiredMask, uint256 allowedMask) external onlyRole(SIGNER_ADMIN_ROLE) { + requiredSignatures = requiredSigs; + categoryMaskRequired = requiredMask; + categoryMaskAllowed = allowedMask; + emit QuorumUpdated(requiredSigs, requiredMask, allowedMask); + } + + function isSigner(address signer) external view returns (bool) { + return _signers[signer].exists && _signers[signer].revokedAtBlock == NEVER_REVOKED; + } + + function isSignerActiveAtBlock(address signer, uint256 blockNum) external view returns (bool) { + SignerInfo memory info = _signers[signer]; + if (!info.exists) return false; + if (blockNum < info.effectiveFromBlock) return false; + if (info.revokedAtBlock != NEVER_REVOKED && blockNum >= info.revokedAtBlock) return false; + return true; + } + + function getSignerInfo(address signer) external view returns (uint8 category, uint256 effectiveFromBlock, uint256 revokedAtBlock) { + SignerInfo memory info = _signers[signer]; + require(info.exists, "DBIS: not signer"); + return (info.category, info.effectiveFromBlock, info.revokedAtBlock); + } + + function validateSigners(address[] calldata signers) external view returns (bool ok, string memory reason) { + if (signers.length < requiredSignatures) return (false, "insufficient count"); + uint256 categoryMask = 0; + for (uint256 i = 0; i < signers.length; i++) { + address s = signers[i]; + (bool exists, uint8 cat, uint256 revoked) = _getSignerInfo(s); + if (!exists) return (false, "not signer"); + if (revoked != NEVER_REVOKED) return (false, "signer revoked"); + if ((categoryMaskAllowed & (1 << cat)) == 0) return (false, "category not allowed"); + if (_hasDuplicate(signers, i, s)) return (false, "duplicate signer"); + categoryMask |= (1 << cat); + } + if ((categoryMask & categoryMaskRequired) != categoryMaskRequired) return (false, "required category missing"); + return (true, ""); + } + + function _getSignerInfo(address signer) private view returns (bool exists, uint8 category, uint256 revokedAtBlock) { + SignerInfo memory info = _signers[signer]; + return (info.exists, info.category, info.revokedAtBlock); + } + + function _hasDuplicate(address[] calldata signers, uint256 currentIndex, address signer) private pure returns (bool) { + for (uint256 j = currentIndex + 1; j < signers.length; j++) { + if (signers[j] == signer) return true; + } + return false; + } + + function getSignerCount() external view returns (uint256) { + return _signerList.length; + } + + uint256 public largeSwapAmountThreshold; + uint8 public requiredSignaturesSmall = 2; + uint8 public requiredSignaturesLarge = 3; + + function setSwapQuorum(uint256 largeThreshold, uint8 smallSigs, uint8 largeSigs) external onlyRole(SIGNER_ADMIN_ROLE) { + largeSwapAmountThreshold = largeThreshold; + requiredSignaturesSmall = smallSigs; + requiredSignaturesLarge = largeSigs; + } + + function validateSignersForSwap(address[] calldata signers, uint256 amountIn) external view returns (bool ok, string memory reason) { + uint8 required = amountIn > largeSwapAmountThreshold ? requiredSignaturesLarge : requiredSignaturesSmall; + if (signers.length < required) return (false, "insufficient count"); + uint256 categoryMask = 0; + for (uint256 i = 0; i < signers.length; i++) { + address s = signers[i]; + (bool exists, uint8 cat, uint256 revoked) = _getSignerInfo(s); + if (!exists) return (false, "not signer"); + if (revoked != NEVER_REVOKED) return (false, "signer revoked"); + if ((categoryMaskAllowed & (1 << cat)) == 0) return (false, "category not allowed"); + if (_hasDuplicate(signers, i, s)) return (false, "duplicate signer"); + categoryMask |= (1 << cat); + } + if (amountIn > largeSwapAmountThreshold && (categoryMask & categoryMaskRequired) != categoryMaskRequired) { + return (false, "required category missing"); + } + return (true, ""); + } +} diff --git a/contracts/dbis/IDBISTypes.sol b/contracts/dbis/IDBISTypes.sol new file mode 100644 index 0000000..cb7c88f --- /dev/null +++ b/contracts/dbis/IDBISTypes.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IDBISTypes { + enum FundsStatus { ON_LEDGER_FINAL, OFF_LEDGER_FINAL } + enum AssetClass { GRU_M00, GRU_M0, GRU_M1 } + + struct MintAuth { + bytes32 messageId; + bytes32 isoType; + bytes32 isoHash; + bytes32 accountingRef; + FundsStatus fundsStatus; + bytes32 corridor; + AssetClass assetClass; + address[] recipients; + uint256[] amounts; + uint64 notBefore; + uint64 expiresAt; + uint256 chainId; + address verifyingContract; + } +} diff --git a/contracts/dbis/StablecoinReferenceRegistry.sol b/contracts/dbis/StablecoinReferenceRegistry.sol new file mode 100644 index 0000000..6d6127a --- /dev/null +++ b/contracts/dbis/StablecoinReferenceRegistry.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract StablecoinReferenceRegistry is AccessControl { + bytes32 public constant STABLECOIN_REGISTRAR_ROLE = keccak256("STABLECOIN_REGISTRAR"); + + enum StablecoinStatus { ACTIVE, SUSPENDED, REVOKED } + + struct StablecoinEntry { + string tokenSymbol; + address tokenAddress; + string issuerOrBridge; + string legalClaimType; + string redemptionPath; + string reserveDisclosureRef; + uint8 riskTier; + address pauseAuthority; + StablecoinStatus status; + bool exists; + } + + mapping(address => StablecoinEntry) private _byAddress; + address[] private _addressList; + + event StablecoinRegistered( + address indexed tokenAddress, + string tokenSymbol, + StablecoinStatus status + ); + event StablecoinStatusUpdated(address indexed tokenAddress, StablecoinStatus status); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(STABLECOIN_REGISTRAR_ROLE, admin); + } + + function register( + address tokenAddress, + string calldata tokenSymbol, + string calldata issuerOrBridge, + string calldata legalClaimType, + string calldata redemptionPath, + string calldata reserveDisclosureRef, + uint8 riskTier, + address pauseAuthority + ) external onlyRole(STABLECOIN_REGISTRAR_ROLE) { + require(tokenAddress != address(0), "DBIS: zero address"); + require(!_byAddress[tokenAddress].exists, "DBIS: already registered"); + _byAddress[tokenAddress] = StablecoinEntry({ + tokenSymbol: tokenSymbol, + tokenAddress: tokenAddress, + issuerOrBridge: issuerOrBridge, + legalClaimType: legalClaimType, + redemptionPath: redemptionPath, + reserveDisclosureRef: reserveDisclosureRef, + riskTier: riskTier, + pauseAuthority: pauseAuthority, + status: StablecoinStatus.ACTIVE, + exists: true + }); + _addressList.push(tokenAddress); + emit StablecoinRegistered(tokenAddress, tokenSymbol, StablecoinStatus.ACTIVE); + } + + function setStatus(address tokenAddress, StablecoinStatus status) external onlyRole(STABLECOIN_REGISTRAR_ROLE) { + require(_byAddress[tokenAddress].exists, "DBIS: not registered"); + _byAddress[tokenAddress].status = status; + emit StablecoinStatusUpdated(tokenAddress, status); + } + + function getEntry(address tokenAddress) external view returns (StablecoinEntry memory) { + return _byAddress[tokenAddress]; + } + + function isActive(address tokenAddress) external view returns (bool) { + return _byAddress[tokenAddress].exists && _byAddress[tokenAddress].status == StablecoinStatus.ACTIVE; + } + + function getRegisteredCount() external view returns (uint256) { + return _addressList.length; + } + + function getRegisteredAt(uint256 index) external view returns (address) { + require(index < _addressList.length, "DBIS: index"); + return _addressList[index]; + } +} diff --git a/contracts/tokens/CompliantFiatToken.sol b/contracts/tokens/CompliantFiatToken.sol index d3b228a..9e26573 100644 --- a/contracts/tokens/CompliantFiatToken.sol +++ b/contracts/tokens/CompliantFiatToken.sol @@ -13,6 +13,8 @@ import "../compliance/LegallyCompliantBase.sol"; * Full ERC-20 for DEX liquidity pools; inherits LegallyCompliantBase. */ contract CompliantFiatToken is ERC20, Pausable, Ownable, LegallyCompliantBase { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + uint8 private immutable _decimalsStorage; string private _currencyCode; @@ -41,6 +43,7 @@ contract CompliantFiatToken is ERC20, Pausable, Ownable, LegallyCompliantBase { { _decimalsStorage = decimals_; _currencyCode = currencyCode_; + _grantRole(MINTER_ROLE, initialOwner); if (initialSupply > 0) { _mint(msg.sender, initialSupply); } @@ -82,7 +85,8 @@ contract CompliantFiatToken is ERC20, Pausable, Ownable, LegallyCompliantBase { _unpause(); } - function mint(address to, uint256 amount) public onlyOwner { + /// @notice Mint (DBIS Rail: grant MINTER_ROLE only to DBIS_GRU_MintController; revoke from others) + function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { _mint(to, amount); } diff --git a/script/deploy/DeployDBISRail.s.sol b/script/deploy/DeployDBISRail.s.sol new file mode 100644 index 0000000..ea731a7 --- /dev/null +++ b/script/deploy/DeployDBISRail.s.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "../../contracts/dbis/DBIS_RootRegistry.sol"; +import "../../contracts/dbis/DBIS_ParticipantRegistry.sol"; +import "../../contracts/dbis/DBIS_SignerRegistry.sol"; +import "../../contracts/dbis/DBIS_GRU_MintController.sol"; +import "../../contracts/dbis/DBIS_SettlementRouter.sol"; +import "../../contracts/dbis/StablecoinReferenceRegistry.sol"; +import "../../contracts/dbis/DBIS_ConversionRouter.sol"; + +/** + * @title DeployDBISRail + * @notice Deploy all DBIS Rail contracts (Spec v1 + v1.5) and wire RootRegistry. + * @dev Run on Chain 138. Env: PRIVATE_KEY; optional ADMIN_ADDRESS (default deployer). + * After deploy: set GRU token on MintController; grant MINTER_ROLE on c* tokens to MintController; register stablecoins; add venues/quote issuers. + */ +contract DeployDBISRail is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address admin = vm.envOr("ADMIN_ADDRESS", vm.addr(pk)); + + vm.startBroadcast(pk); + + DBIS_RootRegistry root = new DBIS_RootRegistry(admin, "v1"); + DBIS_ParticipantRegistry participantReg = new DBIS_ParticipantRegistry(admin); + DBIS_SignerRegistry signerReg = new DBIS_SignerRegistry(admin); + DBIS_GRU_MintController mintController = new DBIS_GRU_MintController(admin, address(0)); + DBIS_SettlementRouter router = new DBIS_SettlementRouter(admin, address(root)); + StablecoinReferenceRegistry stableReg = new StablecoinReferenceRegistry(admin); + DBIS_ConversionRouter conversionRouter = new DBIS_ConversionRouter(admin, address(root)); + + root.setComponent(keccak256("ParticipantRegistry"), address(participantReg)); + root.setComponent(keccak256("SignerRegistry"), address(signerReg)); + root.setComponent(keccak256("GRUMintController"), address(mintController)); + mintController.setSettlementRouter(address(router)); + + root.setComponent(keccak256("SettlementRouter"), address(router)); + root.setComponent(keccak256("StablecoinReferenceRegistry"), address(stableReg)); + root.setComponent(keccak256("ConversionRouter"), address(conversionRouter)); + + signerReg.setSwapQuorum(1e24, 2, 3); + + console.log("DBIS_RootRegistry", address(root)); + console.log("DBIS_ParticipantRegistry", address(participantReg)); + console.log("DBIS_SignerRegistry", address(signerReg)); + console.log("DBIS_GRU_MintController", address(mintController)); + console.log("DBIS_SettlementRouter", address(router)); + console.log("StablecoinReferenceRegistry", address(stableReg)); + console.log("DBIS_ConversionRouter", address(conversionRouter)); + + vm.stopBroadcast(); + } +} diff --git a/scripts/mint-for-liquidity.sh b/scripts/mint-for-liquidity.sh new file mode 100755 index 0000000..a1b77ce --- /dev/null +++ b/scripts/mint-for-liquidity.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Mint cUSDT and cUSDC to the deployer for adding PMM liquidity on Chain 138. +# Deployer must be owner of both token contracts. After minting, run add-liquidity (see step 2). +# +# Usage: +# cd smom-dbis-138 && ./scripts/mint-for-liquidity.sh +# MINT_CUSDT_AMOUNT=2000000 MINT_CUSDC_AMOUNT=2000000 ./scripts/mint-for-liquidity.sh # 2M each +# ./scripts/mint-for-liquidity.sh --add-liquidity # mint then run AddLiquidityPMMPoolsChain138 +# +# Env (in smom-dbis-138/.env): PRIVATE_KEY, RPC_URL_138 (or RPC_URL). +# Optional: MINT_CUSDT_AMOUNT, MINT_CUSDC_AMOUNT (human units, default 1000000 = 1M each). +# For --add-liquidity: ADD_LIQUIDITY_BASE_AMOUNT, ADD_LIQUIDITY_QUOTE_AMOUNT (base units, 6 decimals). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + +[[ -f .env ]] && set -a && source .env && set +a + +RPC="${RPC_URL_138:-${RPC_URL:-http://192.168.11.211:8545}}" +CUSDT="${COMPLIANT_USDT:-0x93E66202A11B1772E55407B32B44e5Cd8eda7f22}" +CUSDC="${COMPLIANT_USDC:-0xf22258f57794CC8E06237084b353Ab30fFfa640b}" +DECIMALS=6 + +# Amounts in human units (e.g. 1000000 = 1M tokens) +MINT_CUSDT="${MINT_CUSDT_AMOUNT:-1000000}" +MINT_CUSDC="${MINT_CUSDC_AMOUNT:-1000000}" + +RUN_ADD_LIQUIDITY=false +for a in "$@"; do [[ "$a" == "--add-liquidity" ]] && RUN_ADD_LIQUIDITY=true && break; done + +if [[ -z "${PRIVATE_KEY:-}" ]]; then + echo "ERROR: PRIVATE_KEY not set. Source smom-dbis-138/.env" + exit 1 +fi + +DEPLOYER=$(cast wallet address "$PRIVATE_KEY" 2>/dev/null || true) +if [[ -z "$DEPLOYER" ]]; then + echo "ERROR: Could not derive address from PRIVATE_KEY" + exit 1 +fi + +# Base units (6 decimals) +BASE_CUSDT=$((MINT_CUSDT * 10**DECIMALS)) +BASE_CUSDC=$((MINT_CUSDC * 10**DECIMALS)) + +echo "=== Mint cUSDT / cUSDC for liquidity (Chain 138) ===" +echo " Deployer: $DEPLOYER" +echo " RPC: $RPC" +echo " cUSDT: $MINT_CUSDT tokens ($BASE_CUSDT base units) -> $CUSDT" +echo " cUSDC: $MINT_CUSDC tokens ($BASE_CUSDC base units) -> $CUSDC" +echo "" + +mint_one() { + local addr="$1" + local name="$2" + local amount_base="$3" + echo "Minting $name to deployer..." + OWNER=$(cast call "$addr" "owner()(address)" --rpc-url "$RPC" 2>/dev/null || echo "") + if [[ -n "$OWNER" && "$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')" != "$(echo "$DEPLOYER" | tr '[:upper:]' '[:lower:]')" ]]; then + echo " SKIP $name: contract owner is $OWNER, deployer is $DEPLOYER (only owner can mint)" + return 0 + fi + if cast send "$addr" "mint(address,uint256)" "$DEPLOYER" "$amount_base" \ + --rpc-url "$RPC" --private-key "$PRIVATE_KEY" --legacy; then + echo " OK $name" + else + echo " FAIL $name" + return 1 + fi +} + +mint_one "$CUSDT" "cUSDT" "$BASE_CUSDT" +mint_one "$CUSDC" "cUSDC" "$BASE_CUSDC" + +echo "" +echo "Mint done. Deployer balances:" +cast call "$CUSDT" "balanceOf(address)(uint256)" "$DEPLOYER" --rpc-url "$RPC" 2>/dev/null | xargs -I {} echo " cUSDT: {}" +cast call "$CUSDC" "balanceOf(address)(uint256)" "$DEPLOYER" --rpc-url "$RPC" 2>/dev/null | xargs -I {} echo " cUSDC: {}" +echo "" + +if [[ "$RUN_ADD_LIQUIDITY" == true ]]; then + if [[ -z "${ADD_LIQUIDITY_BASE_AMOUNT:-}" || -z "${ADD_LIQUIDITY_QUOTE_AMOUNT:-}" ]]; then + # Default: use half of what we just minted (so we add liquidity for the cUSDT/cUSDC pool) + ADD_LIQUIDITY_BASE_AMOUNT=${ADD_LIQUIDITY_BASE_AMOUNT:-$((BASE_CUSDT / 2))} + ADD_LIQUIDITY_QUOTE_AMOUNT=${ADD_LIQUIDITY_QUOTE_AMOUNT:-$((BASE_CUSDC / 2))} + echo "Using default add-liquidity amounts (half of minted): base=$ADD_LIQUIDITY_BASE_AMOUNT quote=$ADD_LIQUIDITY_QUOTE_AMOUNT" + fi + export ADD_LIQUIDITY_BASE_AMOUNT ADD_LIQUIDITY_QUOTE_AMOUNT + # Default pool addresses (Chain 138) if not in .env + export POOL_CUSDTCUSDC="${POOL_CUSDTCUSDC:-0x9fcB06Aa1FD5215DC0E91Fd098aeff4B62fEa5C8}" + export POOL_CUSDTUSDT="${POOL_CUSDTUSDT:-0xa3Ee6091696B28e5497b6F491fA1e99047250c59}" + export POOL_CUSDCUSDC="${POOL_CUSDCUSDC:-0x90bd9Bf18Daa26Af3e814ea224032d015db58Ea5}" + if [[ -n "${DODO_PMM_INTEGRATION:-}" || -n "${DODO_PMM_INTEGRATION_ADDRESS:-}" ]]; then + echo "Running AddLiquidityPMMPoolsChain138 (cUSDT/cUSDC pool only if base/quote set)..." + forge script script/dex/AddLiquidityPMMPoolsChain138.s.sol:AddLiquidityPMMPoolsChain138 \ + --rpc-url "$RPC" --broadcast --private-key "$PRIVATE_KEY" --with-gas-price 1000000000 + echo "Add-liquidity done." + else + echo "Set DODO_PMM_INTEGRATION (or DODO_PMM_INTEGRATION_ADDRESS) and POOL_* in .env, then run:" + echo " forge script script/dex/AddLiquidityPMMPoolsChain138.s.sol:AddLiquidityPMMPoolsChain138 --rpc-url \$RPC_URL_138 --broadcast --private-key \$PRIVATE_KEY" + fi +else + echo "To add liquidity next: set ADD_LIQUIDITY_BASE_AMOUNT and ADD_LIQUIDITY_QUOTE_AMOUNT (base units, 6 decimals)," + echo "POOL_CUSDTCUSDC (and optional POOL_CUSDTUSDT, POOL_CUSDCUSDC), DODO_PMM_INTEGRATION in .env, then run:" + echo " forge script script/dex/AddLiquidityPMMPoolsChain138.s.sol:AddLiquidityPMMPoolsChain138 --rpc-url \$RPC_URL_138 --broadcast --private-key \$PRIVATE_KEY" + echo "Or run this script with --add-liquidity to mint and add in one go (uses half of minted for cUSDT/cUSDC pool)." +fi diff --git a/test/dbis/DBIS_Rail.t.sol b/test/dbis/DBIS_Rail.t.sol new file mode 100644 index 0000000..c817fe4 --- /dev/null +++ b/test/dbis/DBIS_Rail.t.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../../contracts/dbis/IDBISTypes.sol"; +import "../../contracts/dbis/DBIS_RootRegistry.sol"; +import "../../contracts/dbis/DBIS_ParticipantRegistry.sol"; +import "../../contracts/dbis/DBIS_SignerRegistry.sol"; +import "../../contracts/dbis/DBIS_GRU_MintController.sol"; +import "../../contracts/dbis/DBIS_SettlementRouter.sol"; +import "../../contracts/dbis/StablecoinReferenceRegistry.sol"; +import "../../contracts/tokens/CompliantFiatToken.sol"; + +contract DBIS_RailTest is Test, IDBISTypes { + DBIS_RootRegistry public root; + DBIS_ParticipantRegistry public participantReg; + DBIS_SignerRegistry public signerReg; + DBIS_GRU_MintController public mintController; + DBIS_SettlementRouter public router; + StablecoinReferenceRegistry public stableReg; + CompliantFiatToken public token; + + address public admin; + address public alice; + address public signer1; + address public signer2; + address public signer3; + uint256 signer1Key; + uint256 signer2Key; + uint256 signer3Key; + + function setUp() public { + admin = makeAddr("admin"); + alice = makeAddr("alice"); + (signer1, signer1Key) = makeAddrAndKey("signer1"); + (signer2, signer2Key) = makeAddrAndKey("signer2"); + (signer3, signer3Key) = makeAddrAndKey("signer3"); + + vm.startPrank(admin); + root = new DBIS_RootRegistry(admin, "v1"); + participantReg = new DBIS_ParticipantRegistry(admin); + signerReg = new DBIS_SignerRegistry(admin); + mintController = new DBIS_GRU_MintController(admin, address(0)); + router = new DBIS_SettlementRouter(admin, address(root)); + stableReg = new StablecoinReferenceRegistry(admin); + + root.setComponent(keccak256("ParticipantRegistry"), address(participantReg)); + root.setComponent(keccak256("SignerRegistry"), address(signerReg)); + root.setComponent(keccak256("GRUMintController"), address(mintController)); + mintController.setSettlementRouter(address(router)); + + token = new CompliantFiatToken("Test GRU", "tGRU", 6, "USD", admin, admin, 0); + mintController.setGruToken(address(token)); + token.grantRole(token.MINTER_ROLE(), address(mintController)); + + signerReg.addSigner(signer1, 0); + signerReg.addSigner(signer2, 1); + signerReg.addSigner(signer3, 2); + + address[] memory wallets = new address[](1); + wallets[0] = alice; + bytes32[] memory tags; + participantReg.registerParticipant(DBIS_ParticipantRegistry.Participant({ + participantId: keccak256("participant1"), + legalName: "Test FI", + jurisdiction: "US", + entityType: DBIS_ParticipantRegistry.EntityType.BANK, + status: DBIS_ParticipantRegistry.Status.ACTIVE, + policyTags: tags, + operationalWallets: wallets + })); + + vm.stopPrank(); + } + + function test_submitMintAuth_success() public { + address[] memory recipients = new address[](1); + recipients[0] = alice; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000 * 1e6; + + MintAuth memory auth = MintAuth({ + messageId: keccak256("msg1"), + isoType: keccak256("pacs.008"), + isoHash: keccak256("isobundle"), + accountingRef: keccak256("acctref"), + fundsStatus: FundsStatus.ON_LEDGER_FINAL, + corridor: bytes32(0), + assetClass: AssetClass.GRU_M00, + recipients: recipients, + amounts: amounts, + notBefore: uint64(block.timestamp - 1), + expiresAt: uint64(block.timestamp + 300), + chainId: 138, + verifyingContract: address(router) + }); + + bytes32 digest = router.getMintAuthDigest(auth); + bytes memory sig1 = _sign(digest, signer1Key); + bytes memory sig2 = _sign(digest, signer2Key); + bytes memory sig3 = _sign(digest, signer3Key); + bytes[] memory sigs = new bytes[](3); + sigs[0] = sig1; + sigs[1] = sig2; + sigs[2] = sig3; + + vm.prank(alice); + router.submitMintAuth(auth, sigs); + + assertEq(token.balanceOf(alice), 1000 * 1e6); + assertTrue(router.usedMessageIds(auth.messageId)); + } + + function test_submitMintAuth_replayReverts() public { + address[] memory recipients = new address[](1); + recipients[0] = alice; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000 * 1e6; + MintAuth memory auth = MintAuth({ + messageId: keccak256("msg2"), + isoType: keccak256("pacs.008"), + isoHash: keccak256("isobundle"), + accountingRef: keccak256("acctref"), + fundsStatus: FundsStatus.ON_LEDGER_FINAL, + corridor: bytes32(0), + assetClass: AssetClass.GRU_M00, + recipients: recipients, + amounts: amounts, + notBefore: uint64(block.timestamp - 1), + expiresAt: uint64(block.timestamp + 300), + chainId: 138, + verifyingContract: address(router) + }); + bytes32 digest = router.getMintAuthDigest(auth); + bytes[] memory sigs = new bytes[](3); + sigs[0] = _sign(digest, signer1Key); + sigs[1] = _sign(digest, signer2Key); + sigs[2] = _sign(digest, signer3Key); + + vm.prank(alice); + router.submitMintAuth(auth, sigs); + vm.expectRevert("DBIS: replay"); + vm.prank(alice); + router.submitMintAuth(auth, sigs); + } + + function test_signerActiveAtBlock_afterRevoke() public { + vm.prank(admin); + signerReg.revokeSignerAtBlock(signer1); + address[] memory recipients = new address[](1); + recipients[0] = alice; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000 * 1e6; + MintAuth memory auth = MintAuth({ + messageId: keccak256("msg3"), + isoType: keccak256("pacs.008"), + isoHash: keccak256("isobundle"), + accountingRef: keccak256("acctref"), + fundsStatus: FundsStatus.ON_LEDGER_FINAL, + corridor: bytes32(0), + assetClass: AssetClass.GRU_M00, + recipients: recipients, + amounts: amounts, + notBefore: uint64(block.timestamp - 1), + expiresAt: uint64(block.timestamp + 300), + chainId: 138, + verifyingContract: address(router) + }); + bytes32 digest = router.getMintAuthDigest(auth); + bytes[] memory sigs = new bytes[](3); + sigs[0] = _sign(digest, signer1Key); + sigs[1] = _sign(digest, signer2Key); + sigs[2] = _sign(digest, signer3Key); + vm.prank(alice); + vm.expectRevert("DBIS: signer not active at block"); + router.submitMintAuth(auth, sigs); + } + + function _sign(bytes32 digest, uint256 pk) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encodePacked(r, s, v); + } +}