diff --git a/.changeset/multicollateral-typechain-package.md b/.changeset/multicollateral-typechain-package.md new file mode 100644 index 00000000000..bb2136a3bf7 --- /dev/null +++ b/.changeset/multicollateral-typechain-package.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/multicollateral': minor +--- + +The multicollateral package was promoted to a publishable contracts package with generated typechain factory exports for SDK integration. diff --git a/Dockerfile b/Dockerfile index a03d5c380a5..f97418ad768 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ COPY typescript/metrics/package.json ./typescript/metrics/ COPY typescript/warp-monitor/package.json ./typescript/warp-monitor/ COPY typescript/widgets/package.json ./typescript/widgets/ COPY solidity/package.json ./solidity/ +COPY solidity/multicollateral/package.json ./solidity/multicollateral/ COPY solhint-plugin/package.json ./solhint-plugin/ COPY starknet/package.json ./starknet/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 527708d53d3..521f58351d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,28 @@ importers: specifier: 'catalog:' version: 5.11.0(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + solidity/multicollateral: + dependencies: + '@ethersproject/abi': + specifier: '*' + version: 5.8.0 + '@ethersproject/providers': + specifier: '*' + version: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@hyperlane-xyz/core': + specifier: workspace:* + version: link:.. + devDependencies: + '@typechain/ethers-v5': + specifier: 11.1.2 + version: 11.1.2(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3))(typescript@5.8.3) + typechain: + specifier: 'catalog:' + version: 8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3) + typescript: + specifier: 'catalog:' + version: 5.8.3 + starknet: dependencies: starknet: @@ -27003,6 +27025,16 @@ snapshots: typechain: 8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3) typescript: 5.8.3 + '@typechain/ethers-v5@11.1.2(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3))(typescript@5.8.3)': + dependencies: + '@ethersproject/abi': 5.8.0 + '@ethersproject/providers': 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ethers: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + lodash: 4.17.21 + ts-essentials: 7.0.3(typescript@5.8.3) + typechain: 8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3) + typescript: 5.8.3 + '@typechain/ethers-v6@0.5.1(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3))(typescript@5.8.3)': dependencies: ethers: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -27628,7 +27660,7 @@ snapshots: debug: 4.4.3(supports-color@5.5.0) minimatch: 9.0.5 semver: 7.7.3 - tinyglobby: 0.2.15 + tinyglobby: 0.2.14 ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -32745,7 +32777,7 @@ snapshots: solc: 0.8.26(debug@4.4.3) source-map-support: 0.5.21 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tsort: 0.0.1 undici: 5.29.0 uuid: 8.3.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8efd947dbfe..ae799e3c3b8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - 'solhint-plugin' - 'solidity' + - 'solidity/multicollateral' - 'typescript/*' - 'starknet' diff --git a/solidity/eslint.config.mjs b/solidity/eslint.config.mjs index 2993ec6d957..4090a56ebdb 100644 --- a/solidity/eslint.config.mjs +++ b/solidity/eslint.config.mjs @@ -10,6 +10,7 @@ export default [ '**/lib/**/*', '**/typechain/**/*', '**/dependencies/**/*', + '**/multicollateral/**/*', '.solcover.js', 'generate-artifact-exports.mjs', ], diff --git a/solidity/multicollateral/contracts/MultiCollateral.sol b/solidity/multicollateral/contracts/MultiCollateral.sol new file mode 100644 index 00000000000..f809060b829 --- /dev/null +++ b/solidity/multicollateral/contracts/MultiCollateral.sol @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +// ============ Core Imports ============ +import {HypERC20Collateral} from "@hyperlane-xyz/core/token/HypERC20Collateral.sol"; +import {TokenMessage} from "@hyperlane-xyz/core/token/libs/TokenMessage.sol"; +import {TypeCasts} from "@hyperlane-xyz/core/libs/TypeCasts.sol"; +import {IPostDispatchHook} from "@hyperlane-xyz/core/interfaces/hooks/IPostDispatchHook.sol"; +import {Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +// ============ Local Imports ============ +import {IMultiCollateralFee} from "./interfaces/IMultiCollateralFee.sol"; + +/** + * @title MultiCollateral + * @notice Multi-router collateral: direct 1-message atomic transfers between + * collateral routers, both cross-chain and same-chain. + * @dev Extends HypERC20Collateral. Each deployed instance holds collateral for + * one ERC20. Enrolled routers are other MultiCollateral instances (same or + * different token) that this instance trusts to send/receive transfers. + * + * Overrides: + * - handle(): accepts messages from the mailbox (cross-chain) or directly + * from enrolled routers on the same chain. + */ +contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee { + using TypeCasts for address; + using TypeCasts for bytes32; + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.Bytes32Set; + + // ============ Events ============ + + event RouterEnrolled(uint32 indexed domain, bytes32 indexed router); + event RouterUnenrolled(uint32 indexed domain, bytes32 indexed router); + + // ============ Storage ============ + + /// @notice Additional enrolled routers by domain (beyond the standard + /// enrolled remote router). Local routers use localDomain as key. + mapping(uint32 => EnumerableSet.Bytes32Set) private _enrolledRouters; + + // ============ Constructor ============ + + constructor( + address erc20, + uint256 _scaleNumerator, + uint256 _scaleDenominator, + address _mailbox + ) HypERC20Collateral(erc20, _scaleNumerator, _scaleDenominator, _mailbox) {} + + // ============ Router Management (onlyOwner) ============ + + function enrollRouters( + uint32[] calldata _domains, + bytes32[] calldata _routers + ) external onlyOwner { + require(_domains.length == _routers.length, "MC: length mismatch"); + for (uint256 i = 0; i < _domains.length; i++) { + if (_enrolledRouters[_domains[i]].add(_routers[i])) { + emit RouterEnrolled(_domains[i], _routers[i]); + } + } + } + + function unenrollRouters( + uint32[] calldata _domains, + bytes32[] calldata _routers + ) external onlyOwner { + require(_domains.length == _routers.length, "MC: length mismatch"); + for (uint256 i = 0; i < _domains.length; i++) { + if (_enrolledRouters[_domains[i]].remove(_routers[i])) { + emit RouterUnenrolled(_domains[i], _routers[i]); + } + } + } + + function enrolledRouters( + uint32 _domain, + bytes32 _router + ) external view returns (bool) { + return _enrolledRouters[_domain].contains(_router); + } + + // ============ Enumeration ============ + + function getEnrolledRouters( + uint32 _domain + ) external view returns (bytes32[] memory) { + return _enrolledRouters[_domain].values(); + } + + // ============ Handle Override ============ + + /// @dev Overrides `Router.handle` from core (`client/Router.sol`) via + /// HypERC20Collateral -> TokenRouter -> GasRouter -> Router. + /// Accepts messages from the mailbox (cross-chain) or directly from + /// enrolled routers on the same chain. Removes the onlyMailbox modifier. + // solhint-disable-next-line hyperlane/no-virtual-override + function handle( + uint32 _origin, + bytes32 _sender, + bytes calldata _message + ) external payable override { + if (msg.sender == address(mailbox)) { + // Cross-chain via mailbox: sender must be enrolled + require( + _isRemoteRouter(_origin, _sender) || + _enrolledRouters[_origin].contains(_sender), + "MC: unauthorized router" + ); + } else { + // Same-chain direct call: caller must be an enrolled router + require( + _enrolledRouters[localDomain].contains( + TypeCasts.addressToBytes32(msg.sender) + ), + "MC: unauthorized router" + ); + } + _handle(_origin, _sender, _message); + } + + // ============ Per-Router Fee Lookup ============ + // Mirrors TokenRouter._feeRecipientAndAmount but routes through + // IMultiCollateralFee.quoteTransferRemoteTo (which includes _targetRouter) + // instead of ITokenFee.quoteTransferRemote (destination-only). + + function _feeRecipientAndAmountForRouter( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + bytes32 _targetRouter + ) internal view returns (address _feeRecipient, uint256 feeAmount) { + _feeRecipient = feeRecipient(); + if (_feeRecipient == address(0)) return (_feeRecipient, 0); + + // Only difference from base: quoteTransferRemoteTo with _targetRouter + Quote[] memory quotes = IMultiCollateralFee(_feeRecipient) + .quoteTransferRemoteTo( + _destination, + _recipient, + _amount, + _targetRouter + ); + if (quotes.length == 0) return (_feeRecipient, 0); + + require( + quotes.length == 1 && quotes[0].token == token(), + "MC: fee must match token" + ); + feeAmount = quotes[0].amount; + } + + // Mirrors TokenRouter._calculateFeesAndCharge. Identical charge/hook/transfer + // logic — only the fee lookup differs (router-aware via _feeRecipientAndAmountForRouter). + // Duplicated here because the base hardcodes _feeRecipientAndAmount. + function _calculateFeesAndChargeForRouter( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + uint256 _msgValue, + bytes32 _targetRouter + ) internal returns (uint256 externalFee, uint256 remainingNativeValue) { + // Only difference from base: router-aware fee lookup + ( + address _feeRecipient, + uint256 feeAmount + ) = _feeRecipientAndAmountForRouter( + _destination, + _recipient, + _amount, + _targetRouter + ); + // --- remainder identical to TokenRouter._calculateFeesAndCharge --- + externalFee = _externalFeeAmount(_destination, _recipient, _amount); + uint256 charge = _amount + feeAmount + externalFee; + + address _feeHook = feeHook(); + address _token = token(); + + // Same-domain transferRemoteTo calls handle() directly and does not dispatch + // through mailbox hooks, so do not charge hook fees in that path. + if (_feeHook != address(0) && _destination != localDomain) { + uint256 hookFee = _quoteGasPayment( + _destination, + _recipient, + _amount, + _token + ); + if (hookFee > 0) { + if (_token != address(this)) { + charge += hookFee; + } else { + IERC20(_token).safeTransferFrom( + msg.sender, + address(this), + hookFee + ); + } + IERC20(_token).forceApprove(_feeHook, hookFee); + } + } + + _transferFromSender(charge); + if (feeAmount > 0) { + _transferFee(_feeRecipient, feeAmount); + } + remainingNativeValue = _token != address(0) + ? _msgValue + : _msgValue - charge; + } + + // ============ Cross-chain Transfer to Specific Router ============ + + /** + * @notice Transfers tokens to the primary enrolled router for `_destination`. + * @dev Uses the enrolled primary remote router for `_destination` and routes through + * router-aware fee lookup (`IMultiCollateralFee`) via `transferRemoteTo`. + * @dev This override is required because TokenRouter's `_feeRecipientAndAmount` + * is non-virtual and hardcodes `ITokenFee`. Delegating through + * `transferRemoteTo` keeps both transfer paths on `IMultiCollateralFee`. + */ + function transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) public payable override returns (bytes32 messageId) { + bytes32 targetRouter = _mustHaveRemoteRouter(_destination); + return + transferRemoteTo(_destination, _recipient, _amount, targetRouter); + } + + /** + * @notice Transfer tokens cross-chain to a specific target router. + * @dev Follows TokenRouter.transferRemote() flow: fees → message → emit → dispatch. + * Bypasses _Router_dispatch (which hardcodes the enrolled router) to dispatch + * directly to the target router. + * @param _destination Destination domain. + * @param _recipient Final token recipient on destination. + * @param _amount Amount in local token decimals. + * @param _targetRouter The enrolled router to receive the message on destination. + * @return messageId The dispatched message ID. + */ + function transferRemoteTo( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + bytes32 _targetRouter + ) public payable returns (bytes32 messageId) { + require( + _isRemoteRouter(_destination, _targetRouter) || + _enrolledRouters[_destination].contains(_targetRouter), + "MC: unauthorized router" + ); + if (_destination == localDomain) { + require(msg.value == 0, "MC: local transfer no msg.value"); + } + + (, uint256 remainingValue) = _calculateFeesAndChargeForRouter( + _destination, + _recipient, + _amount, + msg.value, + _targetRouter + ); + + uint256 scaled = _outboundAmount(_amount); + bytes memory tokenMsg = TokenMessage.format(_recipient, scaled); + + if (_destination == localDomain) { + // Same-domain: call target router's handle directly + address target = _targetRouter.bytes32ToAddress(); + require(target.code.length > 0, "MC: target router not contract"); + MultiCollateral(target).handle( + localDomain, + TypeCasts.addressToBytes32(address(this)), + tokenMsg + ); + } else { + emit SentTransferRemote(_destination, _recipient, scaled); + messageId = mailbox.dispatch{value: remainingValue}( + _destination, + _targetRouter, + tokenMsg, + _generateHookMetadata(_destination, feeToken()), + IPostDispatchHook(address(hook)) + ); + } + } + + // ============ Quoting ============ + + // Mirrors TokenRouter.quoteTransferRemote. Same 3-element quote structure. + // Differences: (1) router-aware fee lookup, (2) same-domain returns 0 gas + // since handle() is called directly without mailbox dispatch. + + /// @inheritdoc IMultiCollateralFee + function quoteTransferRemoteTo( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + bytes32 _targetRouter + ) external view override returns (Quote[] memory quotes) { + quotes = new Quote[](3); + + // Same-domain: handle() called directly, no interchain gas + uint256 gasQuote = 0; + address _feeToken = feeToken(); + if (_destination != localDomain) { + gasQuote = _quoteGasPayment( + _destination, + _recipient, + _outboundAmount(_amount), + _feeToken + ); + } + quotes[0] = Quote({token: _feeToken, amount: gasQuote}); + + // Only difference from base: router-aware fee lookup + (, uint256 feeAmount) = _feeRecipientAndAmountForRouter( + _destination, + _recipient, + _amount, + _targetRouter + ); + quotes[1] = Quote({token: token(), amount: _amount + feeAmount}); + + quotes[2] = Quote({ + token: token(), + amount: _externalFeeAmount(_destination, _recipient, _amount) + }); + } +} diff --git a/solidity/multicollateral/contracts/MultiCollateralRoutingFee.sol b/solidity/multicollateral/contracts/MultiCollateralRoutingFee.sol new file mode 100644 index 00000000000..80c1321d5c1 --- /dev/null +++ b/solidity/multicollateral/contracts/MultiCollateralRoutingFee.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {IMultiCollateralFee} from "./interfaces/IMultiCollateralFee.sol"; +import {ITokenFee, Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol"; +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"; + +/** + * @title MultiCollateralRoutingFee + * @notice Routes fee lookups by destination + target router. Delegates to + * existing ITokenFee (3-param) fee contracts (LinearFee, ProgressiveFee, etc.). + */ +contract MultiCollateralRoutingFee is IMultiCollateralFee, ITokenFee, Ownable { + using SafeERC20 for IERC20; + + /// @notice Sentinel key for destination-level default fee contracts. + bytes32 public constant DEFAULT_ROUTER = + keccak256("RoutingFee.DEFAULT_ROUTER"); + + /// @notice Per destination + per target router → fee contract. + /// Destination defaults use DEFAULT_ROUTER as targetRouter. + mapping(uint32 dest => mapping(bytes32 targetRouter => address feeContract)) + public feeContracts; + + event FeeContractSet( + uint32 destination, + bytes32 targetRouter, + address feeContract + ); + + constructor(address _owner) Ownable() { + _transferOwnership(_owner); + } + + function setRouterFeeContracts( + uint32[] calldata destinations, + bytes32[] calldata targetRouters, + address[] calldata _feeContracts + ) external onlyOwner { + require( + destinations.length == targetRouters.length && + destinations.length == _feeContracts.length, + "MCF: length mismatch" + ); + + for (uint256 i = 0; i < destinations.length; i++) { + feeContracts[destinations[i]][targetRouters[i]] = _feeContracts[i]; + emit FeeContractSet( + destinations[i], + targetRouters[i], + _feeContracts[i] + ); + } + } + + function claim(address beneficiary, address token) external onlyOwner { + uint256 balance = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransfer(beneficiary, balance); + } + + /** + * @inheritdoc ITokenFee + * @dev Quotes destination-level fee using DEFAULT_ROUTER sentinel. + */ + function quoteTransferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) external view override returns (Quote[] memory) { + return + _quoteTransferRemote( + _destination, + _recipient, + _amount, + DEFAULT_ROUTER + ); + } + + /** + * @inheritdoc IMultiCollateralFee + * @dev Routes: specific router → destination default (DEFAULT_ROUTER). + */ + function quoteTransferRemoteTo( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + bytes32 _targetRouter + ) external view override returns (Quote[] memory) { + return + _quoteTransferRemote( + _destination, + _recipient, + _amount, + _targetRouter + ); + } + + function _quoteTransferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + bytes32 _targetRouter + ) internal view returns (Quote[] memory) { + // 1. Check per-router fee + address routerFee = feeContracts[_destination][_targetRouter]; + if (routerFee != address(0)) { + return + ITokenFee(routerFee).quoteTransferRemote( + _destination, + _recipient, + _amount + ); + } + // 2. Fallback to destination default + address destFee = feeContracts[_destination][DEFAULT_ROUTER]; + if (destFee != address(0)) { + return + ITokenFee(destFee).quoteTransferRemote( + _destination, + _recipient, + _amount + ); + } + // 3. No fee configured → empty quotes + return new Quote[](0); + } +} diff --git a/solidity/multicollateral/contracts/interfaces/IMultiCollateralFee.sol b/solidity/multicollateral/contracts/interfaces/IMultiCollateralFee.sol new file mode 100644 index 00000000000..10bd63cdca9 --- /dev/null +++ b/solidity/multicollateral/contracts/interfaces/IMultiCollateralFee.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol"; + +interface IMultiCollateralFee { + function quoteTransferRemoteTo( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + bytes32 _targetRouter + ) external view returns (Quote[] memory); +} diff --git a/solidity/multicollateral/eslint.config.mjs b/solidity/multicollateral/eslint.config.mjs new file mode 100644 index 00000000000..740bfbd625d --- /dev/null +++ b/solidity/multicollateral/eslint.config.mjs @@ -0,0 +1,20 @@ +export default [ + { + ignores: [ + '**/dist/**/*', + '**/typechain/**/*', + '**/artifacts/**/*', + '**/cache/**/*', + '**/forge-cache/**/*', + '**/out/**/*', + '**/test/**/*', + ], + }, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + }, +]; diff --git a/solidity/multicollateral/foundry.toml b/solidity/multicollateral/foundry.toml new file mode 100644 index 00000000000..593debfd95e --- /dev/null +++ b/solidity/multicollateral/foundry.toml @@ -0,0 +1,13 @@ +[profile.default] +src = 'contracts' +test = 'test' +out = 'out' +cache_path = 'forge-cache' +libs = ["../dependencies", "../lib"] +solc_version = '0.8.22' +evm_version = 'paris' +optimizer_runs = 200 +allow_paths = [".."] + +[fuzz] +runs = 50 diff --git a/solidity/multicollateral/hardhat.config.cts b/solidity/multicollateral/hardhat.config.cts new file mode 100644 index 00000000000..4c98db38f87 --- /dev/null +++ b/solidity/multicollateral/hardhat.config.cts @@ -0,0 +1,21 @@ +import type { HardhatUserConfig } from 'hardhat/config'; + +const config: HardhatUserConfig = { + solidity: { + version: '0.8.22', + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + paths: { + sources: './contracts', + tests: './test', + cache: './cache', + artifacts: './artifacts', + }, +}; + +export default config; diff --git a/solidity/multicollateral/index.ts b/solidity/multicollateral/index.ts new file mode 100644 index 00000000000..5efe5d2ae3e --- /dev/null +++ b/solidity/multicollateral/index.ts @@ -0,0 +1 @@ +export * from './typechain/index.js'; diff --git a/solidity/multicollateral/package.json b/solidity/multicollateral/package.json new file mode 100644 index 00000000000..cb1466ab113 --- /dev/null +++ b/solidity/multicollateral/package.json @@ -0,0 +1,51 @@ +{ + "name": "@hyperlane-xyz/multicollateral", + "description": "MultiCollateral solidity contracts and typechain artifacts for Hyperlane", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./typechain": "./dist/typechain/index.js", + "./typechain/*": "./dist/typechain/*", + "./contracts": "./contracts", + "./contracts/*": "./contracts/*" + }, + "files": [ + "/dist", + "/contracts" + ], + "engines": { + "node": ">=18" + }, + "homepage": "https://www.hyperlane.xyz", + "repository": "https://github.com/hyperlane-xyz/hyperlane-monorepo", + "keywords": [ + "Hyperlane", + "Solidity", + "MultiCollateral" + ], + "license": "Apache-2.0", + "scripts": { + "deps:soldeer": "[ -d ../dependencies/@openzeppelin-contracts-4.9.3 ] || pnpm -C ../ deps:soldeer", + "build": "pnpm clean && pnpm deps:soldeer && forge build && pnpm build:typechain && tsc -p tsconfig.json", + "build:typechain": "mkdir -p typechain && find typechain -mindepth 1 -delete && pnpm exec typechain --target ethers-v5 --node16-modules --out-dir typechain out/MultiCollateral.sol/MultiCollateral.json out/MultiCollateralRoutingFee.sol/MultiCollateralRoutingFee.json out/interfaces/IMultiCollateralFee.sol/IMultiCollateralFee.json", + "clean": "rm -rf ./artifacts ./cache ./dist ./typechain ./forge-cache ./out", + "lint": "eslint -c ./eslint.config.mjs ./index.ts", + "test": "pnpm deps:soldeer && forge test -vvv", + "test:ci": "pnpm test" + }, + "dependencies": { + "@hyperlane-xyz/core": "workspace:*" + }, + "devDependencies": { + "@typechain/ethers-v5": "catalog:", + "typechain": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "@ethersproject/abi": "*", + "@ethersproject/providers": "*" + } +} diff --git a/solidity/multicollateral/remappings.txt b/solidity/multicollateral/remappings.txt new file mode 100644 index 00000000000..c7b6ad314a7 --- /dev/null +++ b/solidity/multicollateral/remappings.txt @@ -0,0 +1,8 @@ +@hyperlane-xyz/core/=../contracts/ +@openzeppelin/contracts/=../dependencies/@openzeppelin-contracts-4.9.3/contracts/ +@openzeppelin/contracts-upgradeable/=../dependencies/@openzeppelin-contracts-upgradeable-4.9.3/contracts/ +@arbitrum/nitro-contracts/src/=../dependencies/@arbitrum-nitro-contracts-1.2.1/src/ +@chainlink/contracts-ccip/src/v0.8/=../dependencies/@chainlink-contracts-ccip-1.5.0/contracts/src/v0.8/ +@eth-optimism/contracts/=../dependencies/@eth-optimism-contracts-0.6.0/packages/contracts/contracts/ +forge-std/=../dependencies/forge-std-1.9.2/src/ +ds-test/=../dependencies/forge-std-1.9.2/lib/ds-test/src/ diff --git a/solidity/multicollateral/test/MultiCollateral.t.sol b/solidity/multicollateral/test/MultiCollateral.t.sol new file mode 100644 index 00000000000..371e03ebb3c --- /dev/null +++ b/solidity/multicollateral/test/MultiCollateral.t.sol @@ -0,0 +1,1155 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.13; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +import "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {TypeCasts} from "@hyperlane-xyz/core/libs/TypeCasts.sol"; +import {MockHyperlaneEnvironment} from "@hyperlane-xyz/core/mock/MockHyperlaneEnvironment.sol"; +import {MockMailbox} from "@hyperlane-xyz/core/mock/MockMailbox.sol"; +import {ERC20Test} from "@hyperlane-xyz/core/test/ERC20Test.sol"; +import {TestPostDispatchHook} from "@hyperlane-xyz/core/test/TestPostDispatchHook.sol"; +import {ITokenFee, Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol"; +import {IPostDispatchHook} from "@hyperlane-xyz/core/interfaces/hooks/IPostDispatchHook.sol"; + +import {MultiCollateral} from "../contracts/MultiCollateral.sol"; +import {MultiCollateralRoutingFee} from "../contracts/MultiCollateralRoutingFee.sol"; +import {IMultiCollateralFee} from "../contracts/interfaces/IMultiCollateralFee.sol"; +import {HypERC20Collateral} from "@hyperlane-xyz/core/token/HypERC20Collateral.sol"; +import {LinearFee} from "@hyperlane-xyz/core/token/fees/LinearFee.sol"; + +/// @notice Mock fee contract: fixed percentage fee. +/// Implements both ITokenFee (for base transferRemote) and IMultiCollateralFee (for transferRemoteTo). +contract MockDepositFee is ITokenFee, IMultiCollateralFee { + address public immutable token; + uint256 public immutable feeBps; + + constructor(address _token, uint256 _feeBps) { + token = _token; + feeBps = _feeBps; + } + + function quoteTransferRemote( + uint32, + bytes32, + uint256 _amount + ) external view override returns (Quote[] memory quotes) { + quotes = new Quote[](1); + quotes[0] = Quote(token, (_amount * feeBps) / 10000); + } + + function quoteTransferRemoteTo( + uint32, + bytes32, + uint256 _amount, + bytes32 /*_targetRouter*/ + ) external view override returns (Quote[] memory quotes) { + quotes = new Quote[](1); + quotes[0] = Quote(token, (_amount * feeBps) / 10000); + } +} + +/// @notice Mock fee contract that implements only IMultiCollateralFee. +contract MockRouterOnlyFee is IMultiCollateralFee { + address public immutable token; + uint256 public immutable feeBps; + + constructor(address _token, uint256 _feeBps) { + token = _token; + feeBps = _feeBps; + } + + function quoteTransferRemoteTo( + uint32, + bytes32, + uint256 _amount, + bytes32 /*_targetRouter*/ + ) external view override returns (Quote[] memory quotes) { + quotes = new Quote[](1); + quotes[0] = Quote(token, (_amount * feeBps) / 10000); + } +} + +contract FixedQuoteHook is IPostDispatchHook { + uint256 public immutable quote; + + constructor(uint256 _quote) { + quote = _quote; + } + + function hookType() external pure returns (uint8) { + return uint8(IPostDispatchHook.HookTypes.UNUSED); + } + + function supportsMetadata(bytes calldata) external pure returns (bool) { + return true; + } + + function postDispatch(bytes calldata, bytes calldata) external payable {} + + function quoteDispatch( + bytes calldata, + bytes calldata + ) external view returns (uint256) { + return quote; + } +} + +contract MultiCollateralTest is Test { + using TypeCasts for address; + using TypeCasts for bytes32; + + uint32 internal constant ORIGIN = 1; + uint32 internal constant DESTINATION = 2; + + // Scale: numerator/denominator. USDC 6→18: multiply by 1e12/1. USDT 18→18: 1/1. + uint256 internal constant USDC_SCALE_NUM = 1e12; + uint256 internal constant USDC_SCALE_DEN = 1; + uint256 internal constant USDT_SCALE_NUM = 1; + uint256 internal constant USDT_SCALE_DEN = 1; + uint256 internal constant DEFAULT_FEE_BPS = 5; // 0.05% + + address internal constant ALICE = address(0x1); + address internal constant BOB = address(0x2); + address internal constant PROXY_ADMIN = address(0x37); + address internal constant UNAUTHORIZED = address(0x999); + + // Environment + MockHyperlaneEnvironment internal env; + MockMailbox internal originMailbox; + MockMailbox internal destMailbox; + + // Tokens + ERC20Test internal originUSDC; // 6 decimals + ERC20Test internal originUSDT; // 18 decimals + ERC20Test internal destUSDC; // 6 decimals + ERC20Test internal destUSDT; // 18 decimals + + // Routers (behind proxies) + MultiCollateral internal usdcRouterA; // domain 1, USDC + MultiCollateral internal usdtRouterA; // domain 1, USDT + MultiCollateral internal usdcRouterB; // domain 2, USDC + MultiCollateral internal usdtRouterB; // domain 2, USDT + + // Fee contracts + MockDepositFee internal originUsdcFee; + MockDepositFee internal originUsdtFee; + MockDepositFee internal destUsdcFee; + MockDepositFee internal destUsdtFee; + + function setUp() public { + // ---- Environment ---- + env = new MockHyperlaneEnvironment(ORIGIN, DESTINATION); + originMailbox = env.mailboxes(ORIGIN); + destMailbox = env.mailboxes(DESTINATION); + + // ---- Tokens ---- + originUSDC = new ERC20Test("USD Coin", "USDC", 0, 6); + originUSDT = new ERC20Test("Tether USD", "USDT", 0, 18); + destUSDC = new ERC20Test("USD Coin", "USDC", 0, 6); + destUSDT = new ERC20Test("Tether USD", "USDT", 0, 18); + + // ---- Deploy routers behind proxies ---- + usdcRouterA = _deployRouter( + address(originUSDC), + USDC_SCALE_NUM, + USDC_SCALE_DEN, + address(originMailbox) + ); + usdtRouterA = _deployRouter( + address(originUSDT), + USDT_SCALE_NUM, + USDT_SCALE_DEN, + address(originMailbox) + ); + usdcRouterB = _deployRouter( + address(destUSDC), + USDC_SCALE_NUM, + USDC_SCALE_DEN, + address(destMailbox) + ); + usdtRouterB = _deployRouter( + address(destUSDT), + USDT_SCALE_NUM, + USDT_SCALE_DEN, + address(destMailbox) + ); + + // ---- Fee contracts ---- + originUsdcFee = new MockDepositFee( + address(originUSDC), + DEFAULT_FEE_BPS + ); + originUsdtFee = new MockDepositFee( + address(originUSDT), + DEFAULT_FEE_BPS + ); + destUsdcFee = new MockDepositFee(address(destUSDC), DEFAULT_FEE_BPS); + destUsdtFee = new MockDepositFee(address(destUSDT), DEFAULT_FEE_BPS); + + usdcRouterA.setFeeRecipient(address(originUsdcFee)); + usdtRouterA.setFeeRecipient(address(originUsdtFee)); + usdcRouterB.setFeeRecipient(address(destUsdcFee)); + usdtRouterB.setFeeRecipient(address(destUsdtFee)); + + // ---- Same-stablecoin: enroll as remote routers ---- + usdcRouterA.enrollRemoteRouter( + DESTINATION, + address(usdcRouterB).addressToBytes32() + ); + usdcRouterB.enrollRemoteRouter( + ORIGIN, + address(usdcRouterA).addressToBytes32() + ); + usdtRouterA.enrollRemoteRouter( + DESTINATION, + address(usdtRouterB).addressToBytes32() + ); + usdtRouterB.enrollRemoteRouter( + ORIGIN, + address(usdtRouterA).addressToBytes32() + ); + + // ---- Cross-stablecoin + same-chain: batch enroll routers ---- + _batchEnroll( + usdcRouterA, + _arr2(DESTINATION, ORIGIN), + _arr2( + address(usdtRouterB).addressToBytes32(), + address(usdtRouterA).addressToBytes32() + ) + ); + _batchEnroll( + usdtRouterB, + _arr2(ORIGIN, DESTINATION), + _arr2( + address(usdcRouterA).addressToBytes32(), + address(usdcRouterB).addressToBytes32() + ) + ); + _batchEnroll( + usdtRouterA, + _arr2(DESTINATION, ORIGIN), + _arr2( + address(usdcRouterB).addressToBytes32(), + address(usdcRouterA).addressToBytes32() + ) + ); + _batchEnroll( + usdcRouterB, + _arr2(ORIGIN, DESTINATION), + _arr2( + address(usdtRouterA).addressToBytes32(), + address(usdtRouterB).addressToBytes32() + ) + ); + + // ---- Mint collateral to routers ---- + originUSDC.mintTo(address(usdcRouterA), 1_000_000e6); + originUSDT.mintTo(address(usdtRouterA), 1_000_000e18); + destUSDC.mintTo(address(usdcRouterB), 1_000_000e6); + destUSDT.mintTo(address(usdtRouterB), 1_000_000e18); + + // ---- Mint tokens to users ---- + originUSDC.mintTo(ALICE, 100_000e6); + originUSDT.mintTo(ALICE, 100_000e18); + destUSDC.mintTo(BOB, 100_000e6); + destUSDT.mintTo(BOB, 100_000e18); + + // ---- Approvals ---- + vm.prank(ALICE); + originUSDC.approve(address(usdcRouterA), type(uint256).max); + vm.prank(ALICE); + originUSDT.approve(address(usdtRouterA), type(uint256).max); + vm.prank(BOB); + destUSDC.approve(address(usdcRouterB), type(uint256).max); + vm.prank(BOB); + destUSDT.approve(address(usdtRouterB), type(uint256).max); + } + + function _deployRouter( + address _token, + uint256 _scaleNum, + uint256 _scaleDen, + address _mailbox + ) internal returns (MultiCollateral) { + MultiCollateral impl = new MultiCollateral( + _token, + _scaleNum, + _scaleDen, + _mailbox + ); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), + PROXY_ADMIN, + abi.encodeWithSelector( + HypERC20Collateral.initialize.selector, + address(0), // hook (use mailbox default) + address(0), // ism (use mailbox default) + address(this) // owner + ) + ); + return MultiCollateral(address(proxy)); + } + + // ============ 1. Cross-chain same-stablecoin ============ + + function test_crossChain_sameStablecoin() public { + uint256 amount = 1000e6; + uint256 bobBefore = destUSDC.balanceOf(BOB); + + vm.prank(ALICE); + usdcRouterA.transferRemote(DESTINATION, BOB.addressToBytes32(), amount); + env.processNextPendingMessage(); + + assertEq(destUSDC.balanceOf(BOB), bobBefore + amount); + } + + // ============ 2. Cross-chain cross-stablecoin ============ + + function test_crossChain_crossStablecoin() public { + uint256 amount = 1000e6; // 1000 USDC (6 dec) + // USDC scaleNum=1e12 → canonical = amount * 1e12 + // USDT scaleNum=1 → local = canonical / 1 = amount * 1e12 + uint256 expectedUSDT = amount * USDC_SCALE_NUM; + + uint256 bobBefore = destUSDT.balanceOf(BOB); + + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + address(usdtRouterB).addressToBytes32() + ); + env.processNextPendingMessage(); + + assertEq(destUSDT.balanceOf(BOB), bobBefore + expectedUSDT); + } + + // ============ 3. Same-chain swap ============ + + function test_sameChain_swap() public { + uint256 amount = 1000e6; // 1000 USDC + uint256 expectedUSDT = amount * USDC_SCALE_NUM; + + uint256 aliceUSDTBefore = originUSDT.balanceOf(ALICE); + + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + amount, + address(usdtRouterA).addressToBytes32() + ); + + assertEq(originUSDT.balanceOf(ALICE), aliceUSDTBefore + expectedUSDT); + } + + function test_sameChain_swap_chargesFeeRecipient_notHookFees() public { + uint256 amount = 10000e6; + uint256 expectedFee = (amount * DEFAULT_FEE_BPS) / 10000; + FixedQuoteHook hook = new FixedQuoteHook(7e6); + usdcRouterA.setHook(address(hook)); + + uint256 aliceBefore = originUSDC.balanceOf(ALICE); + uint256 feeBalBefore = originUSDC.balanceOf(address(originUsdcFee)); + + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + amount, + address(usdtRouterA).addressToBytes32() + ); + + assertEq( + originUSDC.balanceOf(address(originUsdcFee)), + feeBalBefore + expectedFee + ); + assertEq( + aliceBefore - originUSDC.balanceOf(ALICE), + amount + expectedFee + ); + assertEq(originUSDC.balanceOf(address(hook)), 0); + } + + function test_revert_sameChain_swap_targetRouterNotContract() public { + uint32[] memory domains = new uint32[](1); + bytes32[] memory routers = new bytes32[](1); + domains[0] = ORIGIN; + routers[0] = address(0xdead).addressToBytes32(); + usdcRouterA.enrollRouters(domains, routers); + + vm.prank(ALICE); + vm.expectRevert("MC: target router not contract"); + usdcRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + 1000e6, + address(0xdead).addressToBytes32() + ); + } + + function test_revert_sameChain_swap_nonzeroMsgValue() public { + vm.deal(ALICE, 1 ether); + vm.prank(ALICE); + vm.expectRevert("MC: local transfer no msg.value"); + usdcRouterA.transferRemoteTo{value: 1}( + ORIGIN, + ALICE.addressToBytes32(), + 1000e6, + address(usdtRouterA).addressToBytes32() + ); + } + + // ============ 4. Fees on remote transfer ============ + + function test_fees_remoteTransfer() public { + uint256 amount = 10000e6; + uint256 expectedFee = (amount * DEFAULT_FEE_BPS) / 10000; // 5 USDC + + uint256 feeBalBefore = originUSDC.balanceOf(address(originUsdcFee)); + + vm.prank(ALICE); + usdcRouterA.transferRemote(DESTINATION, BOB.addressToBytes32(), amount); + + assertEq( + originUSDC.balanceOf(address(originUsdcFee)), + feeBalBefore + expectedFee + ); + } + + function test_transferRemote_withImultiCollateralFeeOnlyRecipient() public { + MockRouterOnlyFee routerOnlyFee = new MockRouterOnlyFee( + address(originUSDC), + DEFAULT_FEE_BPS + ); + usdcRouterA.setFeeRecipient(address(routerOnlyFee)); + + uint256 amount = 10000e6; + uint256 expectedFee = (amount * DEFAULT_FEE_BPS) / 10000; + uint256 feeBalBefore = originUSDC.balanceOf(address(routerOnlyFee)); + + vm.prank(ALICE); + usdcRouterA.transferRemote(DESTINATION, BOB.addressToBytes32(), amount); + + assertEq( + originUSDC.balanceOf(address(routerOnlyFee)), + feeBalBefore + expectedFee + ); + } + + // ============ 5. Fees on same-chain transfer ============ + + function test_fees_sameChainTransfer() public { + uint256 amount = 10000e6; + uint256 expectedFee = (amount * DEFAULT_FEE_BPS) / 10000; + + uint256 feeBalBefore = originUSDC.balanceOf(address(originUsdcFee)); + + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + amount, + address(usdtRouterA).addressToBytes32() + ); + + assertEq( + originUSDC.balanceOf(address(originUsdcFee)), + feeBalBefore + expectedFee + ); + } + + function test_fees_sameChainTransfer_noHookFeeCharged() public { + uint256 amount = 10000e6; + uint256 expectedFee = (amount * DEFAULT_FEE_BPS) / 10000; + uint256 hookFee = 777e6; + + TestPostDispatchHook testHook = new TestPostDispatchHook(); + testHook.setFee(hookFee); + usdcRouterA.setHook(address(testHook)); + + uint256 aliceBalBefore = originUSDC.balanceOf(ALICE); + uint256 feeBalBefore = originUSDC.balanceOf(address(originUsdcFee)); + + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + amount, + address(usdtRouterA).addressToBytes32() + ); + + uint256 aliceDebit = aliceBalBefore - originUSDC.balanceOf(ALICE); + assertEq(aliceDebit, amount + expectedFee); + assertEq( + originUSDC.balanceOf(address(originUsdcFee)), + feeBalBefore + expectedFee + ); + } + + // ============ 6. Decimal scaling ============ + + function test_decimalScaling_6to18() public { + usdcRouterA.setFeeRecipient(address(0)); + usdtRouterA.setFeeRecipient(address(0)); + + uint256 amount = 1e6; // 1 USDC + uint256 expectedUSDT = 1e18; + + uint256 before = originUSDT.balanceOf(ALICE); + + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + amount, + address(usdtRouterA).addressToBytes32() + ); + + assertEq(originUSDT.balanceOf(ALICE), before + expectedUSDT); + } + + function test_decimalScaling_18to6() public { + usdtRouterA.setFeeRecipient(address(0)); + usdcRouterA.setFeeRecipient(address(0)); + + uint256 amount = 1e18; // 1 USDT + uint256 expectedUSDC = 1e6; + + uint256 before = originUSDC.balanceOf(ALICE); + + vm.prank(ALICE); + usdtRouterA.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + amount, + address(usdcRouterA).addressToBytes32() + ); + + assertEq(originUSDC.balanceOf(ALICE), before + expectedUSDC); + } + + function test_decimalScaling_crossChain_roundTrip() public { + usdcRouterA.setFeeRecipient(address(0)); + usdtRouterB.setFeeRecipient(address(0)); + usdtRouterA.setFeeRecipient(address(0)); + usdcRouterB.setFeeRecipient(address(0)); + + uint256 amount = 1234e6; // 1234 USDC + uint256 bobUSDTBefore = destUSDT.balanceOf(BOB); + + // USDC(6) → USDT(18) cross-chain + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + address(usdtRouterB).addressToBytes32() + ); + env.processNextPendingMessage(); + + uint256 received = destUSDT.balanceOf(BOB) - bobUSDTBefore; + assertEq(received, 1234e18); + + // Now BOB sends 1234e18 USDT(18) → USDC(6) back cross-chain + uint256 aliceUSDCBefore = originUSDC.balanceOf(ALICE); + vm.prank(BOB); + destUSDT.approve(address(usdtRouterB), type(uint256).max); + vm.prank(BOB); + usdtRouterB.transferRemoteTo( + ORIGIN, + ALICE.addressToBytes32(), + received, + address(usdcRouterA).addressToBytes32() + ); + env.processNextPendingMessageFromDestination(); + + assertEq(originUSDC.balanceOf(ALICE), aliceUSDCBefore + 1234e6); + } + + // ============ 7. Reject unauthorized router in handle ============ + + function test_revert_handle_unauthorizedRouter() public { + MultiCollateral rogue = _deployRouter( + address(destUSDC), + USDC_SCALE_NUM, + USDC_SCALE_DEN, + address(destMailbox) + ); + rogue.enrollRemoteRouter( + ORIGIN, + address(usdcRouterA).addressToBytes32() + ); + + destUSDC.mintTo(address(rogue), 100e6); + vm.prank(address(destMailbox)); + vm.expectRevert("MC: unauthorized router"); + usdcRouterB.handle( + ORIGIN, + address(rogue).addressToBytes32(), + abi.encodePacked(BOB.addressToBytes32(), uint256(100e18)) + ); + } + + // ============ 8. Direct-call handle security ============ + + function test_revert_handle_directCall_unenrolledCaller() public { + bytes memory tokenMsg = abi.encodePacked( + BOB.addressToBytes32(), + uint256(100e18) + ); + vm.prank(UNAUTHORIZED); + vm.expectRevert("MC: unauthorized router"); + usdcRouterA.handle(ORIGIN, UNAUTHORIZED.addressToBytes32(), tokenMsg); + } + + // ============ 9. Reject unauthorized in transferRemoteTo ============ + + function test_revert_transferRemoteTo_unauthorizedRouter() public { + vm.prank(ALICE); + vm.expectRevert("MC: unauthorized router"); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + 1000e6, + UNAUTHORIZED.addressToBytes32() + ); + } + + // ============ 10. Owner-only router enrollment ============ + + function test_revert_enrollRouters_nonOwner() public { + uint32[] memory domains = new uint32[](1); + bytes32[] memory routers = new bytes32[](1); + domains[0] = DESTINATION; + routers[0] = UNAUTHORIZED.addressToBytes32(); + + vm.prank(UNAUTHORIZED); + vm.expectRevert("Ownable: caller is not the owner"); + usdcRouterA.enrollRouters(domains, routers); + } + + function test_revert_unenrollRouters_nonOwner() public { + uint32[] memory domains = new uint32[](1); + bytes32[] memory routers = new bytes32[](1); + domains[0] = DESTINATION; + routers[0] = address(usdtRouterB).addressToBytes32(); + + vm.prank(UNAUTHORIZED); + vm.expectRevert("Ownable: caller is not the owner"); + usdcRouterA.unenrollRouters(domains, routers); + } + + // ============ 11. Bidirectional ============ + + function test_bidirectional_destToOrigin() public { + uint256 amount = 500e6; + uint256 aliceBefore = originUSDC.balanceOf(ALICE); + + vm.prank(BOB); + usdcRouterB.transferRemote(ORIGIN, ALICE.addressToBytes32(), amount); + env.processNextPendingMessageFromDestination(); + + assertEq(originUSDC.balanceOf(ALICE), aliceBefore + amount); + } + + // ============ Router enrollment events ============ + + function test_enrollRouters_emitsEvent() public { + bytes32 router = address(0x42).addressToBytes32(); + uint32[] memory domains = new uint32[](1); + bytes32[] memory routers = new bytes32[](1); + domains[0] = DESTINATION; + routers[0] = router; + + vm.expectEmit(true, true, false, true); + emit MultiCollateral.RouterEnrolled(DESTINATION, router); + usdcRouterA.enrollRouters(domains, routers); + assertTrue(usdcRouterA.enrolledRouters(DESTINATION, router)); + } + + function test_unenrollRouters_emitsEvent() public { + bytes32 router = address(usdtRouterB).addressToBytes32(); + uint32[] memory domains = new uint32[](1); + bytes32[] memory routers = new bytes32[](1); + domains[0] = DESTINATION; + routers[0] = router; + + vm.expectEmit(true, true, false, true); + emit MultiCollateral.RouterUnenrolled(DESTINATION, router); + usdcRouterA.unenrollRouters(domains, routers); + assertFalse(usdcRouterA.enrolledRouters(DESTINATION, router)); + } + + // ============ Quoting ============ + + function test_quoteTransferRemoteTo() public view { + Quote[] memory quotes = usdcRouterA.quoteTransferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + 1000e6, + address(usdtRouterB).addressToBytes32() + ); + + assertEq(quotes.length, 3); + // [0] native gas quote + assertEq(quotes[0].token, address(0)); + // [1] token amount + fee + uint256 expectedFee = (1000e6 * DEFAULT_FEE_BPS) / 10000; + assertEq(quotes[1].token, address(originUSDC)); + assertEq(quotes[1].amount, 1000e6 + expectedFee); + // [2] external fee (0) + assertEq(quotes[2].amount, 0); + } + + // ============ Batch enrollment ============ + + function test_enrollRouters_batch() public { + uint32[] memory domains = new uint32[](2); + bytes32[] memory peers = new bytes32[](2); + domains[0] = 99; + domains[1] = 100; + peers[0] = address(0x10).addressToBytes32(); + peers[1] = address(0x11).addressToBytes32(); + + usdcRouterA.enrollRouters(domains, peers); + + assertTrue(usdcRouterA.enrolledRouters(99, peers[0])); + assertTrue(usdcRouterA.enrolledRouters(100, peers[1])); + } + + function test_revert_enrollRouters_lengthMismatch() public { + uint32[] memory domains = new uint32[](2); + bytes32[] memory routers = new bytes32[](1); + domains[0] = 99; + domains[1] = 100; + routers[0] = address(0x10).addressToBytes32(); + + vm.expectRevert("MC: length mismatch"); + usdcRouterA.enrollRouters(domains, routers); + } + + function test_unenrollRouters_batch() public { + uint32[] memory domains = new uint32[](2); + bytes32[] memory routers = new bytes32[](2); + domains[0] = 99; + domains[1] = 100; + routers[0] = address(0x10).addressToBytes32(); + routers[1] = address(0x11).addressToBytes32(); + usdcRouterA.enrollRouters(domains, routers); + + usdcRouterA.unenrollRouters(domains, routers); + + assertFalse(usdcRouterA.enrolledRouters(99, routers[0])); + assertFalse(usdcRouterA.enrolledRouters(100, routers[1])); + } + + function test_revert_unenrollRouters_lengthMismatch() public { + uint32[] memory domains = new uint32[](2); + bytes32[] memory routers = new bytes32[](1); + domains[0] = 99; + domains[1] = 100; + routers[0] = address(0x10).addressToBytes32(); + + vm.expectRevert("MC: length mismatch"); + usdcRouterA.unenrollRouters(domains, routers); + } + + // ============ Enumeration ============ + + function test_getEnrolledRouters_returnsCorrectList() public { + MultiCollateral fresh = _deployRouter( + address(originUSDC), + USDC_SCALE_NUM, + USDC_SCALE_DEN, + address(originMailbox) + ); + + bytes32 r1 = address(0xA1).addressToBytes32(); + bytes32 r2 = address(0xA2).addressToBytes32(); + bytes32 r3 = address(0xA3).addressToBytes32(); + + uint32[] memory domains = new uint32[](3); + bytes32[] memory routers = new bytes32[](3); + domains[0] = 10; + domains[1] = 10; + domains[2] = 20; + routers[0] = r1; + routers[1] = r2; + routers[2] = r3; + + fresh.enrollRouters(domains, routers); + + bytes32[] memory list10 = fresh.getEnrolledRouters(10); + assertEq(list10.length, 2); + assertEq(list10[0], r1); + assertEq(list10[1], r2); + + bytes32[] memory list20 = fresh.getEnrolledRouters(20); + assertEq(list20.length, 1); + assertEq(list20[0], r3); + + bytes32[] memory listEmpty = fresh.getEnrolledRouters(99); + assertEq(listEmpty.length, 0); + } + + function test_getEnrolledRouters_afterUnenroll() public { + MultiCollateral fresh = _deployRouter( + address(originUSDC), + USDC_SCALE_NUM, + USDC_SCALE_DEN, + address(originMailbox) + ); + + bytes32 r1 = address(0xB1).addressToBytes32(); + bytes32 r2 = address(0xB2).addressToBytes32(); + bytes32 r3 = address(0xB3).addressToBytes32(); + + uint32[] memory domains = new uint32[](3); + bytes32[] memory routers = new bytes32[](3); + domains[0] = 10; + domains[1] = 10; + domains[2] = 10; + routers[0] = r1; + routers[1] = r2; + routers[2] = r3; + + fresh.enrollRouters(domains, routers); + assertEq(fresh.getEnrolledRouters(10).length, 3); + + uint32[] memory ud = new uint32[](1); + bytes32[] memory ur = new bytes32[](1); + ud[0] = 10; + ur[0] = r2; + fresh.unenrollRouters(ud, ur); + + bytes32[] memory list = fresh.getEnrolledRouters(10); + assertEq(list.length, 2); + assertEq(list[0], r1); + assertEq(list[1], r3); + } + + function test_enrollRouters_skipsDuplicates() public { + MultiCollateral fresh = _deployRouter( + address(originUSDC), + USDC_SCALE_NUM, + USDC_SCALE_DEN, + address(originMailbox) + ); + + bytes32 r1 = address(0xC1).addressToBytes32(); + uint32[] memory domains = new uint32[](2); + bytes32[] memory routers = new bytes32[](2); + domains[0] = 10; + domains[1] = 10; + routers[0] = r1; + routers[1] = r1; + + fresh.enrollRouters(domains, routers); + + bytes32[] memory list = fresh.getEnrolledRouters(10); + assertEq(list.length, 1); + assertEq(list[0], r1); + } + + // ============ MultiCollateralRoutingFee Tests ============ + + function test_routingFee_perRouterFee() public { + LinearFee linearFee5bps = new LinearFee( + address(originUSDC), + 10e6, + 10000e6, + address(this) + ); + LinearFee linearFee10bps = new LinearFee( + address(originUSDC), + 20e6, + 10000e6, + address(this) + ); + + MultiCollateralRoutingFee routingFee = new MultiCollateralRoutingFee( + address(this) + ); + uint32[] memory destinations = new uint32[](2); + bytes32[] memory targetRouters = new bytes32[](2); + address[] memory feeContracts = new address[](2); + destinations[0] = DESTINATION; + destinations[1] = DESTINATION; + targetRouters[0] = address(usdtRouterB).addressToBytes32(); + targetRouters[1] = address(usdcRouterB).addressToBytes32(); + feeContracts[0] = address(linearFee5bps); + feeContracts[1] = address(linearFee10bps); + + routingFee.setRouterFeeContracts( + destinations, + targetRouters, + feeContracts + ); + + usdcRouterA.setFeeRecipient(address(routingFee)); + + uint256 amount = 10000e6; + + // Transfer to USDT router → 5bps fee + uint256 feeBalBefore = originUSDC.balanceOf(address(routingFee)); + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + address(usdtRouterB).addressToBytes32() + ); + uint256 fee5bps = originUSDC.balanceOf(address(routingFee)) - + feeBalBefore; + assertEq(fee5bps, 5e6, "5bps fee for USDT router"); + + // Transfer to USDC router → 10bps fee + feeBalBefore = originUSDC.balanceOf(address(routingFee)); + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + address(usdcRouterB).addressToBytes32() + ); + uint256 fee10bps = originUSDC.balanceOf(address(routingFee)) - + feeBalBefore; + assertEq(fee10bps, 10e6, "10bps fee for USDC router"); + } + + function test_routingFee_fallbackToDestinationFee() public { + LinearFee destFee = new LinearFee( + address(originUSDC), + 10e6, + 10000e6, + address(this) + ); + MultiCollateralRoutingFee routingFee = new MultiCollateralRoutingFee( + address(this) + ); + uint32[] memory destinations = new uint32[](1); + bytes32[] memory targetRouters = new bytes32[](1); + address[] memory feeContracts = new address[](1); + destinations[0] = DESTINATION; + targetRouters[0] = routingFee.DEFAULT_ROUTER(); + feeContracts[0] = address(destFee); + + routingFee.setRouterFeeContracts( + destinations, + targetRouters, + feeContracts + ); + + usdcRouterA.setFeeRecipient(address(routingFee)); + + uint256 amount = 10000e6; + + uint256 feeBalBefore = originUSDC.balanceOf(address(routingFee)); + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + address(usdtRouterB).addressToBytes32() + ); + uint256 charged = originUSDC.balanceOf(address(routingFee)) - + feeBalBefore; + assertEq(charged, 5e6, "fallback to destination fee"); + } + + function test_routingFee_batchSetRouterFeeContracts() public { + LinearFee linearFee5bps = new LinearFee( + address(originUSDC), + 10e6, + 10000e6, + address(this) + ); + LinearFee linearFee10bps = new LinearFee( + address(originUSDC), + 20e6, + 10000e6, + address(this) + ); + + MultiCollateralRoutingFee routingFee = new MultiCollateralRoutingFee( + address(this) + ); + + uint32[] memory destinations = new uint32[](2); + bytes32[] memory targetRouters = new bytes32[](2); + address[] memory feeContracts = new address[](2); + destinations[0] = DESTINATION; + destinations[1] = DESTINATION; + targetRouters[0] = routingFee.DEFAULT_ROUTER(); + targetRouters[1] = address(usdtRouterB).addressToBytes32(); + feeContracts[0] = address(linearFee5bps); + feeContracts[1] = address(linearFee10bps); + + routingFee.setRouterFeeContracts( + destinations, + targetRouters, + feeContracts + ); + + Quote[] memory defaultQuotes = routingFee.quoteTransferRemote( + DESTINATION, + BOB.addressToBytes32(), + 10000e6 + ); + assertEq(defaultQuotes.length, 1); + assertEq(defaultQuotes[0].amount, 5e6, "default sentinel fee"); + + Quote[] memory routerQuotes = routingFee.quoteTransferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + 10000e6, + address(usdtRouterB).addressToBytes32() + ); + assertEq(routerQuotes.length, 1); + assertEq(routerQuotes[0].amount, 10e6, "router-specific fee"); + } + + function test_routingFee_quoteMatchesCharge() public { + LinearFee linearFee5bps = new LinearFee( + address(originUSDC), + 10e6, + 10000e6, + address(this) + ); + MultiCollateralRoutingFee routingFee = new MultiCollateralRoutingFee( + address(this) + ); + uint32[] memory destinations = new uint32[](1); + bytes32[] memory targetRouters = new bytes32[](1); + address[] memory feeContracts = new address[](1); + destinations[0] = DESTINATION; + targetRouters[0] = address(usdtRouterB).addressToBytes32(); + feeContracts[0] = address(linearFee5bps); + + routingFee.setRouterFeeContracts( + destinations, + targetRouters, + feeContracts + ); + usdcRouterA.setFeeRecipient(address(routingFee)); + + uint256 amount = 10000e6; + bytes32 targetRouter = address(usdtRouterB).addressToBytes32(); + + // Get quote + Quote[] memory quotes = usdcRouterA.quoteTransferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + targetRouter + ); + uint256 quotedFee = quotes[1].amount - amount; + + // Execute transfer and measure actual fee + uint256 feeBalBefore = originUSDC.balanceOf(address(routingFee)); + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + targetRouter + ); + uint256 actualFee = originUSDC.balanceOf(address(routingFee)) - + feeBalBefore; + + assertEq(quotedFee, actualFee, "quote matches actual charge"); + } + + function test_routingFee_claim() public { + LinearFee linearFee5bps = new LinearFee( + address(originUSDC), + 10e6, + 10000e6, + address(this) + ); + MultiCollateralRoutingFee routingFee = new MultiCollateralRoutingFee( + address(this) + ); + uint32[] memory destinations = new uint32[](1); + bytes32[] memory targetRouters = new bytes32[](1); + address[] memory feeContracts = new address[](1); + destinations[0] = DESTINATION; + targetRouters[0] = address(usdtRouterB).addressToBytes32(); + feeContracts[0] = address(linearFee5bps); + routingFee.setRouterFeeContracts( + destinations, + targetRouters, + feeContracts + ); + usdcRouterA.setFeeRecipient(address(routingFee)); + + uint256 amount = 10000e6; + vm.prank(ALICE); + usdcRouterA.transferRemoteTo( + DESTINATION, + BOB.addressToBytes32(), + amount, + address(usdtRouterB).addressToBytes32() + ); + + uint256 accrued = originUSDC.balanceOf(address(routingFee)); + assertGt(accrued, 0, "expected accrued routing fee"); + uint256 beneficiaryBefore = originUSDC.balanceOf(BOB); + routingFee.claim(BOB, address(originUSDC)); + assertEq(originUSDC.balanceOf(address(routingFee)), 0); + assertEq(originUSDC.balanceOf(BOB), beneficiaryBefore + accrued); + } + + function test_revert_routingFee_claim_nonOwner() public { + MultiCollateralRoutingFee routingFee = new MultiCollateralRoutingFee( + address(this) + ); + vm.prank(ALICE); + vm.expectRevert("Ownable: caller is not the owner"); + routingFee.claim(ALICE, address(originUSDC)); + } + + // ============ Helpers ============ + + function _batchEnroll( + MultiCollateral _router, + uint32[] memory _domains, + bytes32[] memory _routers + ) internal { + _router.enrollRouters(_domains, _routers); + } + + function _arr2( + uint32 a, + uint32 b + ) internal pure returns (uint32[] memory arr) { + arr = new uint32[](2); + arr[0] = a; + arr[1] = b; + } + + function _arr2( + bytes32 a, + bytes32 b + ) internal pure returns (bytes32[] memory arr) { + arr = new bytes32[](2); + arr[0] = a; + arr[1] = b; + } +} diff --git a/solidity/multicollateral/tsconfig.json b/solidity/multicollateral/tsconfig.json new file mode 100644 index 00000000000..d5ed038c64e --- /dev/null +++ b/solidity/multicollateral/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@hyperlane-xyz/tsconfig/tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "declaration": true + }, + "include": ["index.ts", "typechain/**/*.ts"], + "exclude": ["dist", "artifacts", "cache", "forge-cache", "out", "test"] +} diff --git a/solidity/tsconfig.json b/solidity/tsconfig.json index d3a24c6c3c0..76410f6c6ac 100644 --- a/solidity/tsconfig.json +++ b/solidity/tsconfig.json @@ -13,6 +13,7 @@ "rootHardhatConfig.cts", "./plugins", "./dependencies", - "./artifacts-tron" + "./artifacts-tron", + "./multicollateral" ] }