From f8da8cd40bc9c08e575e5a0078ddb61ab28e6389 Mon Sep 17 00:00:00 2001 From: larryob Date: Thu, 26 Jun 2025 16:35:55 -0400 Subject: [PATCH 01/36] refactor: Remove ValueTransferBridge (#6605) --- .changeset/clever-carpets-double.md | 5 ++++ .../contracts/token/HypERC20Collateral.sol | 4 ++-- solidity/contracts/token/HypNative.sol | 12 +++++----- .../token/libs/MovableCollateralRouter.sol | 23 ++++++++----------- .../token/HypERC20MovableCollateral.t.sol | 7 +++--- solidity/test/token/HypnativeMovable.t.sol | 8 +++---- .../test/token/MovableCollateralRouter.t.sol | 8 +++---- 7 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 .changeset/clever-carpets-double.md diff --git a/.changeset/clever-carpets-double.md b/.changeset/clever-carpets-double.md new file mode 100644 index 0000000000..ab6d5cf853 --- /dev/null +++ b/.changeset/clever-carpets-double.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Remove ValueTransferBridge and use ITokenBridge. ValueTransferBridge is a deprecated name for the interface. diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index 7cad5340dc..bde3d3d765 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -18,7 +18,7 @@ import {TokenMessage} from "./libs/TokenMessage.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; -import {ValueTransferBridge} from "./interfaces/ValueTransferBridge.sol"; +import {ITokenBridge} from "../interfaces/ITokenBridge.sol"; // ============ External Imports ============ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -104,7 +104,7 @@ contract HypERC20Collateral is MovableCollateralRouter { uint32 domain, bytes32 recipient, uint256 amount, - ValueTransferBridge bridge + ITokenBridge bridge ) internal override { wrappedToken.safeApprove({spender: address(bridge), value: amount}); MovableCollateralRouter._rebalance({ diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 9fb63ce277..cef8e387bb 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -4,8 +4,8 @@ pragma solidity >=0.8.0; import {TokenRouter} from "./libs/TokenRouter.sol"; import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; -import {ValueTransferBridge} from "./interfaces/ValueTransferBridge.sol"; -import {Quote} from "../interfaces/ITokenBridge.sol"; +import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; +import {Quote} from "contracts/interfaces/ITokenBridge.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; @@ -120,7 +120,7 @@ contract HypNative is MovableCollateralRouter { uint32 domain, bytes32 recipient, uint256 amount, - ValueTransferBridge bridge + ITokenBridge bridge ) internal override { uint fee = msg.value + amount; require( @@ -128,9 +128,9 @@ contract HypNative is MovableCollateralRouter { "Native: rebalance amount exceeds balance" ); bridge.transferRemote{value: fee}({ - destinationDomain: domain, - recipient: recipient, - amountOut: amount + _destination: domain, + _recipient: recipient, + _amount: amount }); } } diff --git a/solidity/contracts/token/libs/MovableCollateralRouter.sol b/solidity/contracts/token/libs/MovableCollateralRouter.sol index fe774619ac..c71b98e558 100644 --- a/solidity/contracts/token/libs/MovableCollateralRouter.sol +++ b/solidity/contracts/token/libs/MovableCollateralRouter.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0; import {Router} from "contracts/client/Router.sol"; import {FungibleTokenRouter} from "./FungibleTokenRouter.sol"; -import {ValueTransferBridge} from "../interfaces/ValueTransferBridge.sol"; +import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -40,7 +40,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { _; } - modifier onlyAllowedBridge(uint32 domain, ValueTransferBridge bridge) { + modifier onlyAllowedBridge(uint32 domain, ITokenBridge bridge) { EnumerableSet.AddressSet storage bridges = _allowedBridges[domain]; require(bridges.contains(address(bridge)), "MCR: Not allowed bridge"); _; @@ -66,10 +66,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { delete allowedRecipient[domain]; } - function addBridge( - uint32 domain, - ValueTransferBridge bridge - ) external onlyOwner { + function addBridge(uint32 domain, ITokenBridge bridge) external onlyOwner { // constrain to a subset of Router.domains() _mustHaveRemoteRouter(domain); _allowedBridges[domain].add(address(bridge)); @@ -77,7 +74,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { function removeBridge( uint32 domain, - ValueTransferBridge bridge + ITokenBridge bridge ) external onlyOwner { _allowedBridges[domain].remove(address(bridge)); } @@ -90,7 +87,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { */ function approveTokenForBridge( IERC20 token, - ValueTransferBridge bridge + ITokenBridge bridge ) external onlyOwner { token.safeApprove(address(bridge), type(uint256).max); } @@ -114,7 +111,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { function rebalance( uint32 domain, uint256 amount, - ValueTransferBridge bridge + ITokenBridge bridge ) external payable onlyRebalancer onlyAllowedBridge(domain, bridge) { address rebalancer = _msgSender(); @@ -159,12 +156,12 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { uint32 domain, bytes32 recipient, uint256 amount, - ValueTransferBridge bridge + ITokenBridge bridge ) internal virtual { bridge.transferRemote{value: msg.value}({ - destinationDomain: domain, - recipient: recipient, - amountOut: amount + _destination: domain, + _recipient: recipient, + _amount: amount }); } } diff --git a/solidity/test/token/HypERC20MovableCollateral.t.sol b/solidity/test/token/HypERC20MovableCollateral.t.sol index f9c860c71d..90f13e41f1 100644 --- a/solidity/test/token/HypERC20MovableCollateral.t.sol +++ b/solidity/test/token/HypERC20MovableCollateral.t.sol @@ -1,8 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity ^0.8.13; -import {ValueTransferBridge} from "contracts/token/interfaces/ValueTransferBridge.sol"; -import {MockValueTransferBridge} from "./MovableCollateralRouter.t.sol"; +import {MockTokenBridge} from "./MovableCollateralRouter.t.sol"; import {HypERC20Collateral} from "contracts/token/HypERC20Collateral.sol"; // import {HypERC20MovableCollateral} from "contracts/token/HypERC20MovableCollateral.sol"; @@ -13,7 +12,7 @@ import "forge-std/Test.sol"; contract HypERC20MovableCollateralRouterTest is Test { HypERC20Collateral internal router; - MockValueTransferBridge internal vtb; + MockTokenBridge internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); @@ -28,7 +27,7 @@ contract HypERC20MovableCollateralRouterTest is Test { // Initialize the router -> we are the admin router.initialize(address(0), address(0), address(this)); - vtb = new MockValueTransferBridge(token); + vtb = new MockTokenBridge(token); } function _configure(bytes32 _recipient) internal { diff --git a/solidity/test/token/HypnativeMovable.t.sol b/solidity/test/token/HypnativeMovable.t.sol index 47d1ecd167..bb15cdcfcd 100644 --- a/solidity/test/token/HypnativeMovable.t.sol +++ b/solidity/test/token/HypnativeMovable.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity ^0.8.13; -import {ValueTransferBridge, Quote} from "contracts/token/interfaces/ValueTransferBridge.sol"; +import {ITokenBridge, Quote} from "contracts/interfaces/ITokenBridge.sol"; import {HypNative} from "contracts/token/HypNative.sol"; import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; @@ -9,7 +9,7 @@ import {MockMailbox} from "contracts/mock/MockMailbox.sol"; import "forge-std/Test.sol"; -contract MockValueTransferBridgeEth is ValueTransferBridge { +contract MockTokenBridgeEth is ITokenBridge { constructor() {} function transferRemote( @@ -31,7 +31,7 @@ contract MockValueTransferBridgeEth is ValueTransferBridge { contract HypNativeMovableTest is Test { HypNative internal router; - MockValueTransferBridgeEth internal vtb; + MockTokenBridgeEth internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); @@ -45,7 +45,7 @@ contract HypNativeMovableTest is Test { destinationDomain, bytes32(uint256(uint160(0))) ); - vtb = new MockValueTransferBridgeEth(); + vtb = new MockTokenBridgeEth(); } function testMovingCollateral() public { diff --git a/solidity/test/token/MovableCollateralRouter.t.sol b/solidity/test/token/MovableCollateralRouter.t.sol index 0dec4583dd..ebcd6ef359 100644 --- a/solidity/test/token/MovableCollateralRouter.t.sol +++ b/solidity/test/token/MovableCollateralRouter.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.13; import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; import {MovableCollateralRouter} from "contracts/token/libs/MovableCollateralRouter.sol"; -import {ValueTransferBridge, Quote} from "contracts/token/interfaces/ValueTransferBridge.sol"; +import {ITokenBridge, Quote} from "contracts/interfaces/ITokenBridge.sol"; import {MockMailbox} from "contracts/mock/MockMailbox.sol"; import {Router} from "contracts/client/Router.sol"; import {FungibleTokenRouter} from "contracts/token/libs/FungibleTokenRouter.sol"; @@ -38,7 +38,7 @@ contract MockMovableCollateralRouter is MovableCollateralRouter { ) internal override {} } -contract MockValueTransferBridge is ValueTransferBridge { +contract MockTokenBridge is ITokenBridge { ERC20Test token; bytes32 public myRecipient; @@ -69,7 +69,7 @@ contract MovableCollateralRouterTest is Test { using TypeCasts for address; MovableCollateralRouter internal router; - MockValueTransferBridge internal vtb; + MockTokenBridge internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); @@ -80,7 +80,7 @@ contract MovableCollateralRouterTest is Test { mailbox = new MockMailbox(1); router = new MockMovableCollateralRouter(address(mailbox)); token = new ERC20Test("Foo Token", "FT", 1_000_000e18, 18); - vtb = new MockValueTransferBridge(token); + vtb = new MockTokenBridge(token); remote = vm.addr(10); From 826e83741d9ad689e855f198b470949e4e4e73af Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Fri, 27 Jun 2025 14:01:40 -0400 Subject: [PATCH 02/36] fix: CCTP burn message sender patch (#6632) --- .changeset/odd-carrots-whisper.md | 5 +++ solidity/contracts/token/TokenBridgeCctp.sol | 6 +-- solidity/foundry.toml | 1 + solidity/test/token/TokenBridgeCctp.t.sol | 39 +++++++++++++++++++- 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 .changeset/odd-carrots-whisper.md diff --git a/.changeset/odd-carrots-whisper.md b/.changeset/odd-carrots-whisper.md new file mode 100644 index 0000000000..c77d233a72 --- /dev/null +++ b/.changeset/odd-carrots-whisper.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Fix TokenBridgeCCTP.verify burn message sender enforcement diff --git a/solidity/contracts/token/TokenBridgeCctp.sol b/solidity/contracts/token/TokenBridgeCctp.sol index 6d8d2c7e39..bb023049dc 100644 --- a/solidity/contracts/token/TokenBridgeCctp.sol +++ b/solidity/contracts/token/TokenBridgeCctp.sol @@ -155,9 +155,6 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { bytes29 originalMsg = TypedMemView.ref(cctpMessage, 0); - bytes32 sourceSender = originalMsg._sender(); - require(sourceSender == _hyperlaneMessage.sender(), "Invalid sender"); - bytes29 burnMessage = originalMsg._messageBody(); require( TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), @@ -169,6 +166,9 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { "Invalid recipient" ); + bytes32 sourceSender = burnMessage._getMessageSender(); + require(sourceSender == _hyperlaneMessage.sender(), "Invalid sender"); + uint32 sourceDomain = originalMsg._sourceDomain(); require( sourceDomain == diff --git a/solidity/foundry.toml b/solidity/foundry.toml index 8834c229c7..12341fcdef 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -28,6 +28,7 @@ verbosity = 4 mainnet = "https://eth.merkle.io" optimism = "https://mainnet.optimism.io " polygon = "https://rpc-mainnet.matic.quiknode.pro" +base = "https://mainnet.base.org" [fuzz] runs = 50 diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index cf69ebeec1..10fbbc5331 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -18,7 +18,7 @@ import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTrans import {ITokenMessenger} from "../../contracts/interfaces/cctp/ITokenMessenger.sol"; import {ITokenMessengerV2} from "../../contracts/interfaces/cctp/ITokenMessengerV2.sol"; import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol"; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {CctpMessage, BurnMessage} from "../../contracts/libs/CctpMessage.sol"; import {Message} from "../../contracts/libs/Message.sol"; import {CctpService} from "../../contracts/token/TokenBridgeCctp.sol"; @@ -176,7 +176,7 @@ contract TokenBridgeCctpTest is Test { sourceDomain, cctpDestination, nonce, - sender.addressToBytes32(), + address(tokenMessengerOrigin).addressToBytes32(), address(tbDestination).addressToBytes32(), bytes32(0), burnMessage @@ -291,6 +291,41 @@ contract TokenBridgeCctpTest is Test { assertEq(tbDestination.verify(metadata, message), true); } + function testFork_verify() public { + TokenBridgeCctp recipient = TokenBridgeCctp( + 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 + ); + vm.createSelectFork(vm.rpcUrl("base"), 32_126_535); + + bytes + memory metadata = hex"0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000f80000000000000000000000060000000000044df3000000000000000000000000bd3fa81b58ba92a82136038b25adec7066af31550000000000000000000000001682ae6375c4e4a97e4b583bc394c861a46d8962000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001547b13bd71126d92e93092cad07807eedb6fc260000000000000000000000000000000000000000000000000000000000000001000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8000000000000000000000000000000000000000000000000000000000000000000000000000000822828b6af83fc19fc0e46a6dc4470c93e02855175de1fc77e01858eefb8bc5c9140df500f482cbfa384bd1bf6a020cdb078788ff3eff1c7ead090ae93c2088c8b1c2e143054b1656ba072ebf83c30e1ea9929043be7a8fe28c087a32a285bd6a5310e48b26b46595143ed8ee71bbc49e9deceabd69d0802331188fa69309477d80e1c000000000000000000000000000000000000000000000000000000000000"; + bytes + memory message = hex"0300016f5200000001000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8000021050000000000000000000000005c4afb7e23b1dc1b409dc1702f89c64527b259750000000000000000000000001547b13bd71126d92e93092cad07807eedb6fc2600000000000000000000000000000000000000000000000000000000000000010000000000044df3"; + + vm.expectRevert(); + recipient.verify(metadata, message); + + TokenBridgeCctp newImplementation = new TokenBridgeCctp( + address(recipient.wrappedToken()), + recipient.scale(), + address(recipient.mailbox()), + recipient.messageTransmitter(), + recipient.tokenMessenger() + ); + + bytes32 adminBytes = vm.load( + address(recipient), + bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) + ); + address admin = address(uint160(uint256(adminBytes))); + vm.prank(admin); + ITransparentUpgradeableProxy(address(recipient)).upgradeTo( + address(newImplementation) + ); + + assertEq(recipient.verify(metadata, message), true); + } + function test_verify_revertsWhen_invalidNonce() public { ( bytes memory message, From 9a43cdca90744a8b2a50b6c7a17a91d2474b19ad Mon Sep 17 00:00:00 2001 From: larryob Date: Tue, 1 Jul 2025 14:44:17 -0400 Subject: [PATCH 03/36] fix: Fix absolute imports (#6661) --- .changeset/wise-steaks-think.md | 5 +++++ solidity/.gitignore | 1 + solidity/contracts/AttributeCheckpointFraud.sol | 2 +- solidity/contracts/avs/HyperlaneServiceManager.sol | 2 +- .../contracts/hooks/warp-route/RateLimitedHook.sol | 12 ++++++------ solidity/contracts/isms/NoopIsm.sol | 2 +- solidity/contracts/isms/PausableIsm.sol | 2 +- solidity/contracts/isms/TrustedRelayerIsm.sol | 2 +- .../isms/hook/AbstractMessageIdAuthorizedIsm.sol | 2 +- .../contracts/isms/warp-route/RateLimitedIsm.sol | 8 ++++---- solidity/contracts/token/extensions/HypERC4626.sol | 2 +- .../contracts/token/libs/MovableCollateralRouter.sol | 2 +- solidity/contracts/token/libs/TokenRouter.sol | 2 +- solidity/script/xerc20/ApproveLockbox.s.sol | 3 +-- solidity/script/xerc20/GrantLimits.s.sol | 2 +- 15 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 .changeset/wise-steaks-think.md diff --git a/.changeset/wise-steaks-think.md b/.changeset/wise-steaks-think.md new file mode 100644 index 0000000000..33027367e5 --- /dev/null +++ b/.changeset/wise-steaks-think.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Remove absolute imports. Fixes compilation for users who import from files under `solidity/contracts`. diff --git a/solidity/.gitignore b/solidity/.gitignore index 0c6e075cb3..fc60633645 100644 --- a/solidity/.gitignore +++ b/solidity/.gitignore @@ -15,6 +15,7 @@ docs flattened/ buildArtifact.json fixtures/ +broadcast/ # ZKSync artifacts-zk cache-zk diff --git a/solidity/contracts/AttributeCheckpointFraud.sol b/solidity/contracts/AttributeCheckpointFraud.sol index 1e52ba77be..dbfaaadcd2 100644 --- a/solidity/contracts/AttributeCheckpointFraud.sol +++ b/solidity/contracts/AttributeCheckpointFraud.sol @@ -5,7 +5,7 @@ import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "./PackageVersioned.sol"; import {TREE_DEPTH} from "./libs/Merkle.sol"; import {CheckpointLib, Checkpoint} from "./libs/CheckpointLib.sol"; import {CheckpointFraudProofs} from "./CheckpointFraudProofs.sol"; diff --git a/solidity/contracts/avs/HyperlaneServiceManager.sol b/solidity/contracts/avs/HyperlaneServiceManager.sol index 99997d87a7..9a408723b2 100644 --- a/solidity/contracts/avs/HyperlaneServiceManager.sol +++ b/solidity/contracts/avs/HyperlaneServiceManager.sol @@ -19,7 +19,7 @@ import {IAVSDirectory} from "../interfaces/avs/vendored/IAVSDirectory.sol"; import {IRemoteChallenger} from "../interfaces/avs/IRemoteChallenger.sol"; import {ISlasher} from "../interfaces/avs/vendored/ISlasher.sol"; import {ECDSAServiceManagerBase} from "./ECDSAServiceManagerBase.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "../PackageVersioned.sol"; contract HyperlaneServiceManager is ECDSAServiceManagerBase, PackageVersioned { // ============ Libraries ============ diff --git a/solidity/contracts/hooks/warp-route/RateLimitedHook.sol b/solidity/contracts/hooks/warp-route/RateLimitedHook.sol index 22dd9c5b59..b1ab8ea68f 100644 --- a/solidity/contracts/hooks/warp-route/RateLimitedHook.sol +++ b/solidity/contracts/hooks/warp-route/RateLimitedHook.sol @@ -14,12 +14,12 @@ pragma solidity >=0.8.0; @@@@@@@@@ @@@@@@@@*/ // ============ Internal Imports ============ -import {MailboxClient} from "contracts/client/MailboxClient.sol"; -import {IPostDispatchHook} from "contracts/interfaces/hooks/IPostDispatchHook.sol"; -import {Message} from "contracts/libs/Message.sol"; -import {TokenMessage} from "contracts/token/libs/TokenMessage.sol"; -import {RateLimited} from "contracts/libs/RateLimited.sol"; -import {AbstractPostDispatchHook} from "../libs/AbstractPostDispatchHook.sol"; +import {MailboxClient} from "../../client/MailboxClient.sol"; +import {IPostDispatchHook} from "../../interfaces/hooks/IPostDispatchHook.sol"; +import {Message} from "../../libs/Message.sol"; +import {TokenMessage} from "../../token/libs/TokenMessage.sol"; +import {RateLimited} from "../../libs/RateLimited.sol"; +import {AbstractPostDispatchHook} from "../../hooks/libs/AbstractPostDispatchHook.sol"; /* * @title RateLimitedHook diff --git a/solidity/contracts/isms/NoopIsm.sol b/solidity/contracts/isms/NoopIsm.sol index 30ac76864e..2ab27803d4 100644 --- a/solidity/contracts/isms/NoopIsm.sol +++ b/solidity/contracts/isms/NoopIsm.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.0; import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "../PackageVersioned.sol"; contract NoopIsm is IInterchainSecurityModule, PackageVersioned { uint8 public constant override moduleType = uint8(Types.NULL); diff --git a/solidity/contracts/isms/PausableIsm.sol b/solidity/contracts/isms/PausableIsm.sol index 00868285e6..2e1613521b 100644 --- a/solidity/contracts/isms/PausableIsm.sol +++ b/solidity/contracts/isms/PausableIsm.sol @@ -7,7 +7,7 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; // ============ Internal Imports ============ import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "../PackageVersioned.sol"; contract PausableIsm is IInterchainSecurityModule, diff --git a/solidity/contracts/isms/TrustedRelayerIsm.sol b/solidity/contracts/isms/TrustedRelayerIsm.sol index 87da1bb60f..44db65e8c0 100644 --- a/solidity/contracts/isms/TrustedRelayerIsm.sol +++ b/solidity/contracts/isms/TrustedRelayerIsm.sol @@ -6,7 +6,7 @@ import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Message} from "../libs/Message.sol"; import {Mailbox} from "../Mailbox.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "../PackageVersioned.sol"; contract TrustedRelayerIsm is IInterchainSecurityModule, PackageVersioned { using Message for bytes; diff --git a/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol b/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol index f9232ac334..0945c6c603 100644 --- a/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol +++ b/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol @@ -18,7 +18,7 @@ pragma solidity >=0.8.0; import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol"; import {LibBit} from "../../libs/LibBit.sol"; import {Message} from "../../libs/Message.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "../../PackageVersioned.sol"; // ============ External Imports ============ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; diff --git a/solidity/contracts/isms/warp-route/RateLimitedIsm.sol b/solidity/contracts/isms/warp-route/RateLimitedIsm.sol index ef462f6aa7..45db853bce 100644 --- a/solidity/contracts/isms/warp-route/RateLimitedIsm.sol +++ b/solidity/contracts/isms/warp-route/RateLimitedIsm.sol @@ -3,10 +3,10 @@ pragma solidity >=0.8.0; // ============ Internal Imports ============ import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol"; -import {MailboxClient} from "contracts/client/MailboxClient.sol"; -import {RateLimited} from "contracts/libs/RateLimited.sol"; -import {Message} from "contracts/libs/Message.sol"; -import {TokenMessage} from "contracts/token/libs/TokenMessage.sol"; +import {MailboxClient} from "../../client/MailboxClient.sol"; +import {RateLimited} from "../../libs/RateLimited.sol"; +import {Message} from "../../libs/Message.sol"; +import {TokenMessage} from "../../token/libs/TokenMessage.sol"; contract RateLimitedIsm is MailboxClient, diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol index 266e5e61d8..77300f6219 100644 --- a/solidity/contracts/token/extensions/HypERC4626.sol +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -18,7 +18,7 @@ import {HypERC20} from "../HypERC20.sol"; import {Message} from "../../libs/Message.sol"; import {TokenMessage} from "../libs/TokenMessage.sol"; import {TokenRouter} from "../libs/TokenRouter.sol"; -import {Router} from "contracts/client/Router.sol"; +import {Router} from "../../client/Router.sol"; import {FungibleTokenRouter} from "../libs/FungibleTokenRouter.sol"; // ============ External Imports ============ diff --git a/solidity/contracts/token/libs/MovableCollateralRouter.sol b/solidity/contracts/token/libs/MovableCollateralRouter.sol index c71b98e558..df6af5cefc 100644 --- a/solidity/contracts/token/libs/MovableCollateralRouter.sol +++ b/solidity/contracts/token/libs/MovableCollateralRouter.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.0; -import {Router} from "contracts/client/Router.sol"; +import {Router} from "../../client/Router.sol"; import {FungibleTokenRouter} from "./FungibleTokenRouter.sol"; import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index e01102fa98..e9a1653d05 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.0; // ============ Internal Imports ============ -import {TypeCasts} from "contracts/libs/TypeCasts.sol"; +import {TypeCasts} from "../../libs/TypeCasts.sol"; import {GasRouter} from "../../client/GasRouter.sol"; import {TokenMessage} from "./TokenMessage.sol"; import {Quote, ITokenBridge} from "../../interfaces/ITokenBridge.sol"; diff --git a/solidity/script/xerc20/ApproveLockbox.s.sol b/solidity/script/xerc20/ApproveLockbox.s.sol index 8421a3b402..f6fb2004b8 100644 --- a/solidity/script/xerc20/ApproveLockbox.s.sol +++ b/solidity/script/xerc20/ApproveLockbox.s.sol @@ -12,8 +12,7 @@ import {ProxyAdmin} from "contracts/upgrade/ProxyAdmin.sol"; import {HypXERC20Lockbox} from "contracts/token/extensions/HypXERC20Lockbox.sol"; import {IXERC20Lockbox} from "contracts/token/interfaces/IXERC20Lockbox.sol"; -import {IXERC20} from "contracts/token/interfaces/IXERC20.sol"; -import {IERC20} from "contracts/token/interfaces/IXERC20.sol"; +import {IXERC20, IERC20} from "contracts/token/interfaces/IXERC20.sol"; // source .env. // forge script ApproveLockbox.s.sol --broadcast --rpc-url localhost:XXXX diff --git a/solidity/script/xerc20/GrantLimits.s.sol b/solidity/script/xerc20/GrantLimits.s.sol index e2c79bae6e..a7ad45485d 100644 --- a/solidity/script/xerc20/GrantLimits.s.sol +++ b/solidity/script/xerc20/GrantLimits.s.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0; import "forge-std/Script.sol"; -import {AnvilRPC} from "test/AnvilRPC.sol"; +import {AnvilRPC} from "../../test/AnvilRPC.sol"; import {IXERC20Lockbox} from "contracts/token/interfaces/IXERC20Lockbox.sol"; import {IXERC20} from "contracts/token/interfaces/IXERC20.sol"; From 719f513b667ed2bd2f6d47c174fcd686191c879e Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Mon, 7 Jul 2025 13:59:08 -0400 Subject: [PATCH 04/36] Disable registry warp ID check --- typescript/infra/test/warpIds.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/typescript/infra/test/warpIds.test.ts b/typescript/infra/test/warpIds.test.ts index 1adbefbf42..e0763e5c82 100644 --- a/typescript/infra/test/warpIds.test.ts +++ b/typescript/infra/test/warpIds.test.ts @@ -6,7 +6,8 @@ import { rootLogger } from '@hyperlane-xyz/utils'; import { WarpRouteIds } from '../config/environments/mainnet3/warp/warpIds.js'; -describe('Warp IDs', () => { +// TODO: enable when merging audit branch to main +describe.skip('Warp IDs', () => { it('Has all warp IDs in the registry', async () => { const registry = getRegistry({ registryUris: [DEFAULT_GITHUB_REGISTRY], From 737ea2b355f6bfc9f8f47749cfba7b3947a0cffe Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 7 Jul 2025 14:31:16 -0400 Subject: [PATCH 05/36] feat: emit event on protocol fee payment (#6688) --- .changeset/rude-apricots-try.md | 5 +++++ solidity/contracts/hooks/ProtocolFee.sol | 3 +++ solidity/test/hooks/ProtocolFee.t.sol | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changeset/rude-apricots-try.md diff --git a/.changeset/rude-apricots-try.md b/.changeset/rude-apricots-try.md new file mode 100644 index 0000000000..4c49777f54 --- /dev/null +++ b/.changeset/rude-apricots-try.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": minor +--- + +feat: emit event on protocol fee payment diff --git a/solidity/contracts/hooks/ProtocolFee.sol b/solidity/contracts/hooks/ProtocolFee.sol index dbbe5c72dd..4bf086ee18 100644 --- a/solidity/contracts/hooks/ProtocolFee.sol +++ b/solidity/contracts/hooks/ProtocolFee.sol @@ -34,6 +34,7 @@ contract ProtocolFee is AbstractPostDispatchHook, Ownable { event ProtocolFeeSet(uint256 protocolFee); event BeneficiarySet(address beneficiary); + event ProtocolFeePaid(address indexed sender, uint256 fee); // ============ Constants ============ @@ -103,6 +104,8 @@ contract ProtocolFee is AbstractPostDispatchHook, Ownable { "ProtocolFee: insufficient protocol fee" ); + emit ProtocolFeePaid(message.senderAddress(), protocolFee); + _refund(metadata, message, msg.value - protocolFee); } diff --git a/solidity/test/hooks/ProtocolFee.t.sol b/solidity/test/hooks/ProtocolFee.t.sol index d77ba14a0c..e9b09501d8 100644 --- a/solidity/test/hooks/ProtocolFee.t.sol +++ b/solidity/test/hooks/ProtocolFee.t.sol @@ -165,7 +165,7 @@ contract ProtocolFeeTest is Test { for (uint256 i = 0; i < dispatchCalls; i++) { vm.prank(alice); - fees.postDispatch{value: feeRequired}("", ""); + fees.postDispatch{value: feeRequired}("", testMessage); } fees.collectProtocolFees(); @@ -173,6 +173,21 @@ contract ProtocolFeeTest is Test { assertEq(bob.balance, balanceBefore + feeRequired * dispatchCalls); } + function testFuzz_postDispatch_emitsProtocolFeePaid( + uint256 feeRequired, + uint256 feeSent + ) public { + feeRequired = bound(feeRequired, 1, fees.MAX_PROTOCOL_FEE()); + feeSent = bound(feeSent, feeRequired, 10 * feeRequired); + vm.deal(alice, feeSent); + + fees.setProtocolFee(feeRequired); + + vm.expectEmit(true, true, true, true); + emit ProtocolFee.ProtocolFeePaid(alice, feeRequired); + fees.postDispatch{value: feeSent}("", testMessage); + } + // ============ Helper Functions ============ function _encodeTestMessage() internal view returns (bytes memory) { From e0c69e255b4a02fb7976c01e5fb98720d1db9e69 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 7 Jul 2025 16:34:11 -0400 Subject: [PATCH 06/36] feat: token bridge fees (#6562) --- .changeset/red-moose-change.md | 10 + .changeset/short-yaks-laugh.md | 5 + .../contracts/interfaces/ITokenBridge.sol | 22 +- .../mock/MockValueTransferBridge.sol | 4 +- solidity/contracts/token/HypERC20.sol | 12 +- .../contracts/token/HypERC20Collateral.sol | 26 +- solidity/contracts/token/HypERC721.sol | 8 +- .../contracts/token/HypERC721Collateral.sol | 8 +- solidity/contracts/token/HypNative.sol | 66 ++-- solidity/contracts/token/TokenBridgeCctp.sol | 36 +- .../contracts/token/extensions/HypERC4626.sol | 6 +- .../token/extensions/HypERC4626Collateral.sol | 59 ++-- .../extensions/HypERC4626OwnerCollateral.sol | 59 +--- .../extensions/HypERC721URICollateral.sol | 24 +- .../token/extensions/HypERC721URIStorage.sol | 49 ++- .../token/extensions/HypFiatToken.sol | 9 +- .../contracts/token/extensions/HypXERC20.sol | 8 +- .../token/extensions/HypXERC20Lockbox.sol | 8 +- .../extensions/OPL2ToL1TokenBridgeNative.sol | 7 +- solidity/contracts/token/fees/BaseFee.sol | 85 +++++ solidity/contracts/token/fees/LinearFee.sol | 42 +++ .../contracts/token/fees/ProgressiveFee.sol | 46 +++ .../contracts/token/fees/RegressiveFee.sol | 44 +++ solidity/contracts/token/fees/RoutingFee.sol | 58 ++++ .../token/interfaces/ValueTransferBridge.sol | 21 -- .../token/libs/FungibleTokenRouter.sol | 123 ++++++- .../token/libs/MovableCollateralRouter.sol | 8 +- solidity/contracts/token/libs/TokenRouter.sol | 106 +++--- solidity/package.json | 4 +- solidity/test/token/Fees.t.sol | 318 ++++++++++++++++++ solidity/test/token/HypERC20.t.sol | 173 ++++++++-- .../HypERC20CollateralVaultDeposit.t.sol | 4 +- .../token/HypERC20MovableCollateral.t.sol | 7 +- solidity/test/token/HypERC4626Test.t.sol | 7 - solidity/test/token/HypnativeMovable.t.sol | 7 +- .../test/token/MovableCollateralRouter.t.sol | 20 +- .../sdk/src/token/EvmERC20WarpRouteReader.ts | 8 +- .../sdk/src/token/adapters/EvmTokenAdapter.ts | 4 +- 38 files changed, 1106 insertions(+), 405 deletions(-) create mode 100644 .changeset/red-moose-change.md create mode 100644 .changeset/short-yaks-laugh.md create mode 100644 solidity/contracts/token/fees/BaseFee.sol create mode 100644 solidity/contracts/token/fees/LinearFee.sol create mode 100644 solidity/contracts/token/fees/ProgressiveFee.sol create mode 100644 solidity/contracts/token/fees/RegressiveFee.sol create mode 100644 solidity/contracts/token/fees/RoutingFee.sol delete mode 100644 solidity/contracts/token/interfaces/ValueTransferBridge.sol create mode 100644 solidity/test/token/Fees.t.sol diff --git a/.changeset/red-moose-change.md b/.changeset/red-moose-change.md new file mode 100644 index 0000000000..bc25cad234 --- /dev/null +++ b/.changeset/red-moose-change.md @@ -0,0 +1,10 @@ +--- +"@hyperlane-xyz/core": minor +"@hyperlane-xyz/sdk": patch +--- + +Implement token fees on FungibleTokenRouter + +Removes `metadata` from return type of internal `TokenRouter._transferFromSender` hook + +To append `metadata` to `TokenMessage`, override the `TokenRouter._beforeDispatch` hook diff --git a/.changeset/short-yaks-laugh.md b/.changeset/short-yaks-laugh.md new file mode 100644 index 0000000000..945b4e5676 --- /dev/null +++ b/.changeset/short-yaks-laugh.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": minor +--- + +Adds fees to FungibleTokenRouter diff --git a/solidity/contracts/interfaces/ITokenBridge.sol b/solidity/contracts/interfaces/ITokenBridge.sol index 6b5d8a43ce..a76a0292ed 100644 --- a/solidity/contracts/interfaces/ITokenBridge.sol +++ b/solidity/contracts/interfaces/ITokenBridge.sol @@ -6,31 +6,33 @@ struct Quote { uint256 amount; } -interface ITokenBridge { +interface ITokenFee { /** - * @notice Transfer value to another domain + * @notice Provide the value transfer quote * @param _destination The destination domain of the message * @param _recipient The message recipient address on `destination` * @param _amount The amount to send to the recipient - * @return messageId The identifier of the dispatched message. + * @return quotes Indicate how much of each token to approve and/or send. + * @dev Good practice is to use the first entry of the quotes for the native currency (i.e. ETH) */ - function transferRemote( + function quoteTransferRemote( uint32 _destination, bytes32 _recipient, uint256 _amount - ) external payable returns (bytes32); + ) external view returns (Quote[] memory quotes); +} +interface ITokenBridge is ITokenFee { /** - * @notice Provide the value transfer quote + * @notice Transfer value to another domain * @param _destination The destination domain of the message * @param _recipient The message recipient address on `destination` * @param _amount The amount to send to the recipient - * @return quotes Indicate how much of each token to approve and/or send. - * @dev Good practice is to use the first entry of the quotes for the native currency (i.e. ETH) + * @return messageId The identifier of the dispatched message. */ - function quoteTransferRemote( + function transferRemote( uint32 _destination, bytes32 _recipient, uint256 _amount - ) external view returns (Quote[] memory quotes); + ) external payable returns (bytes32); } diff --git a/solidity/contracts/mock/MockValueTransferBridge.sol b/solidity/contracts/mock/MockValueTransferBridge.sol index f048e84454..0186d85eca 100644 --- a/solidity/contracts/mock/MockValueTransferBridge.sol +++ b/solidity/contracts/mock/MockValueTransferBridge.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; -import {ValueTransferBridge, Quote} from "../token/interfaces/ValueTransferBridge.sol"; +import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol"; -contract MockValueTransferBridge is ValueTransferBridge { +contract MockValueTransferBridge is ITokenBridge { event SentTransferRemote( uint32 indexed origin, uint32 indexed destination, diff --git a/solidity/contracts/token/HypERC20.sol b/solidity/contracts/token/HypERC20.sol index 6463193893..ad21a2dea7 100644 --- a/solidity/contracts/token/HypERC20.sol +++ b/solidity/contracts/token/HypERC20.sol @@ -59,15 +59,16 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter { return ERC20Upgradeable.balanceOf(_account); } + function token() public view virtual override returns (address) { + return address(this); + } + /** * @dev Burns `_amount` of token from `msg.sender` balance. * @inheritdoc TokenRouter */ - function _transferFromSender( - uint256 _amount - ) internal virtual override returns (bytes memory) { + function _transferFromSender(uint256 _amount) internal virtual override { _burn(msg.sender, _amount); - return bytes(""); // no metadata } /** @@ -76,8 +77,7 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter { */ function _transferTo( address _recipient, - uint256 _amount, - bytes calldata // no metadata + uint256 _amount ) internal virtual override { _mint(_recipient, _amount); } diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index bde3d3d765..d303e5d47a 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -18,7 +18,7 @@ import {TokenMessage} from "./libs/TokenMessage.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; -import {ITokenBridge} from "../interfaces/ITokenBridge.sol"; +import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol"; // ============ External Imports ============ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -26,7 +26,6 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; -import {Quote} from "../interfaces/ITokenBridge.sol"; /** * @title Hyperlane ERC20 Token Collateral that wraps an existing ERC20 with remote transfer functionality. @@ -60,32 +59,20 @@ contract HypERC20Collateral is MovableCollateralRouter { function balanceOf( address _account - ) external view override returns (uint256) { + ) external view virtual override returns (uint256) { return wrappedToken.balanceOf(_account); } - function quoteTransferRemote( - uint32 _destinationDomain, - bytes32 _recipient, - uint256 _amount - ) external view virtual override returns (Quote[] memory quotes) { - quotes = new Quote[](2); - quotes[0] = Quote({ - token: address(0), - amount: _quoteGasPayment(_destinationDomain, _recipient, _amount) - }); - quotes[1] = Quote({token: address(wrappedToken), amount: _amount}); + function token() public view virtual override returns (address) { + return address(wrappedToken); } /** * @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract. * @inheritdoc TokenRouter */ - function _transferFromSender( - uint256 _amount - ) internal virtual override returns (bytes memory) { + function _transferFromSender(uint256 _amount) internal virtual override { wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); - return bytes(""); // no metadata } /** @@ -94,8 +81,7 @@ contract HypERC20Collateral is MovableCollateralRouter { */ function _transferTo( address _recipient, - uint256 _amount, - bytes calldata // no metadata + uint256 _amount ) internal virtual override { wrappedToken.safeTransfer(_recipient, _amount); } diff --git a/solidity/contracts/token/HypERC721.sol b/solidity/contracts/token/HypERC721.sol index ced9020b82..b5138796a3 100644 --- a/solidity/contracts/token/HypERC721.sol +++ b/solidity/contracts/token/HypERC721.sol @@ -54,12 +54,9 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter { * @dev Asserts `msg.sender` is owner and burns `_tokenId`. * @inheritdoc TokenRouter */ - function _transferFromSender( - uint256 _tokenId - ) internal virtual override returns (bytes memory) { + function _transferFromSender(uint256 _tokenId) internal virtual override { require(ownerOf(_tokenId) == msg.sender, "!owner"); _burn(_tokenId); - return bytes(""); // no metadata } /** @@ -68,8 +65,7 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter { */ function _transferTo( address _recipient, - uint256 _tokenId, - bytes calldata // no metadata + uint256 _tokenId ) internal virtual override { _safeMint(_recipient, _tokenId); } diff --git a/solidity/contracts/token/HypERC721Collateral.sol b/solidity/contracts/token/HypERC721Collateral.sol index 37f79367fa..946bcf9da3 100644 --- a/solidity/contracts/token/HypERC721Collateral.sol +++ b/solidity/contracts/token/HypERC721Collateral.sol @@ -52,12 +52,9 @@ contract HypERC721Collateral is TokenRouter { * @dev Transfers `_tokenId` of `wrappedToken` from `msg.sender` to this contract. * @inheritdoc TokenRouter */ - function _transferFromSender( - uint256 _tokenId - ) internal virtual override returns (bytes memory) { + function _transferFromSender(uint256 _tokenId) internal virtual override { // safeTransferFrom not used here because recipient is this contract wrappedToken.transferFrom(msg.sender, address(this), _tokenId); - return bytes(""); // no metadata } /** @@ -66,8 +63,7 @@ contract HypERC721Collateral is TokenRouter { */ function _transferTo( address _recipient, - uint256 _tokenId, - bytes calldata // no metadata + uint256 _tokenId ) internal override { wrappedToken.safeTransferFrom(address(this), _recipient, _tokenId); } diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index cef8e387bb..34b799558c 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -4,8 +4,7 @@ pragma solidity >=0.8.0; import {TokenRouter} from "./libs/TokenRouter.sol"; import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; -import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; -import {Quote} from "contracts/interfaces/ITokenBridge.sol"; +import {Quote, ITokenBridge} from "../interfaces/ITokenBridge.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; @@ -44,6 +43,13 @@ contract HypNative is MovableCollateralRouter { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } + function balanceOf( + address _account + ) external view override returns (uint256) { + return _account.balance; + } + + // override for single unified quote function quoteTransferRemote( uint32 _destination, bytes32 _recipient, @@ -53,46 +59,20 @@ contract HypNative is MovableCollateralRouter { quotes[0] = Quote({ token: address(0), amount: _quoteGasPayment(_destination, _recipient, _amount) + + _feeAmount(_destination, _recipient, _amount) + _amount }); } - function _transferRemote( - uint32 _destination, - bytes32 _recipient, - uint256 _amount, - uint256 _value, - bytes memory _hookMetadata, - address _hook - ) internal virtual override returns (bytes32 messageId) { - // include for legible error instead of underflow - _transferFromSender(_amount); - - return - super._transferRemote( - _destination, - _recipient, - _amount, - msg.value - _amount, - _hookMetadata, - _hook - ); - } - - function balanceOf( - address _account - ) external view override returns (uint256) { - return _account.balance; + function token() public view virtual override returns (address) { + return address(0); } /** * @inheritdoc TokenRouter */ - function _transferFromSender( - uint256 _amount - ) internal virtual override returns (bytes memory) { + function _transferFromSender(uint256 _amount) internal virtual override { require(msg.value >= _amount, "Native: amount exceeds msg.value"); - return bytes(""); // no metadata } /** @@ -101,12 +81,24 @@ contract HypNative is MovableCollateralRouter { */ function _transferTo( address _recipient, - uint256 _amount, - bytes calldata // no metadata + uint256 _amount ) internal virtual override { Address.sendValue(payable(_recipient), _amount); } + function _chargeSender( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal virtual override returns (uint256 dispatchValue) { + uint256 fee = _feeAmount(_destination, _recipient, _amount); + _transferFromSender(_amount + fee); + dispatchValue = msg.value - (_amount + fee); + if (fee > 0) { + _transferTo(feeRecipient(), fee); + } + } + receive() external payable { emit Donation(msg.sender, msg.value); } @@ -127,10 +119,6 @@ contract HypNative is MovableCollateralRouter { address(this).balance >= fee, "Native: rebalance amount exceeds balance" ); - bridge.transferRemote{value: fee}({ - _destination: domain, - _recipient: recipient, - _amount: amount - }); + bridge.transferRemote{value: fee}(domain, recipient, amount); } } diff --git a/solidity/contracts/token/TokenBridgeCctp.sol b/solidity/contracts/token/TokenBridgeCctp.sol index bb023049dc..c1532240f7 100644 --- a/solidity/contracts/token/TokenBridgeCctp.sol +++ b/solidity/contracts/token/TokenBridgeCctp.sol @@ -193,15 +193,17 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { return true; } - function _transferRemote( + function _beforeDispatch( uint32 _destination, bytes32 _recipient, - uint256 _amount, - uint256 _value, - bytes memory _hookMetadata, - address _hook - ) internal virtual override returns (bytes32 messageId) { - HypERC20Collateral._transferFromSender(_amount); + uint256 _amount + ) + internal + virtual + override + returns (uint256 dispatchValue, bytes memory message) + { + dispatchValue = _chargeSender(_destination, _recipient, _amount); uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination); uint64 nonce = tokenMessenger.depositForBurn( @@ -211,23 +213,12 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { address(wrappedToken) ); - uint256 outboundAmount = _outboundAmount(_amount); - bytes memory _tokenMessage = TokenMessage.format( + message = TokenMessage.format( _recipient, - outboundAmount, + _outboundAmount(_amount), abi.encodePacked(nonce) ); - _validateMessageLength(_tokenMessage); - - messageId = _Router_dispatch( - _destination, - _value, - _tokenMessage, - _hookMetadata, - _hook - ); - - emit SentTransferRemote(_destination, _recipient, outboundAmount); + _validateMessageLength(message); } function _offchainLookupCalldata( @@ -238,8 +229,7 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { function _transferTo( address _recipient, - uint256 _amount, - bytes calldata metadata + uint256 _amount ) internal override { // do not transfer to recipient as the CCTP transfer will do it } diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol index 77300f6219..7e922ff92c 100644 --- a/solidity/contracts/token/extensions/HypERC4626.sol +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -93,10 +93,8 @@ contract HypERC4626 is HypERC20 { // @inheritdoc HypERC20 // @dev Amount specified by the user is in assets, but the internal accounting is in shares - function _transferFromSender( - uint256 _amount - ) internal virtual override returns (bytes memory) { - return HypERC20._transferFromSender(assetsToShares(_amount)); + function _transferFromSender(uint256 _amount) internal virtual override { + HypERC20._transferFromSender(assetsToShares(_amount)); } // @inheritdoc FungibleTokenRouter diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index 8dbe737d78..edde5baf15 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -54,25 +54,35 @@ contract HypERC4626Collateral is HypERC20Collateral { address _interchainSecurityModule, address _owner ) public override initializer { + wrappedToken.approve(address(vault), type(uint256).max); _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } - /** - * @inheritdoc TokenRouter - * @dev Override `_transferRemote` to send shares as amount and append {exchange rate, nonce} in the message. - * This is preferred for readability and to avoid confusion with the amount of shares. The scaling factor - * is applied to the shares returned by the deposit before sending the message. - */ - function _transferRemote( + function _chargeSender( uint32 _destination, bytes32 _recipient, - uint256 _amount, - uint256 _value, - bytes memory _hookMetadata, - address _hook - ) internal virtual override returns (bytes32 messageId) { - // Can't override _transferFromSender only because we need to pass shares in the token message - _transferFromSender(_amount); + uint256 _amount + ) internal virtual override returns (uint256 dispatchValue) { + uint256 fee = _feeAmount(_destination, _recipient, _amount); + HypERC20Collateral._transferFromSender(_amount + fee); + if (fee > 0) { + HypERC20Collateral._transferTo(feeRecipient(), fee); + } + return msg.value; + } + + function _beforeDispatch( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) + internal + virtual + override + returns (uint256 dispatchValue, bytes memory message) + { + dispatchValue = _chargeSender(_destination, _recipient, _amount); + uint256 _shares = _depositIntoVault(_amount); uint256 _exchangeRate = vault.convertToAssets(PRECISION); @@ -84,29 +94,20 @@ contract HypERC4626Collateral is HypERC20Collateral { ); uint256 _outboundAmount = _outboundAmount(_shares); - bytes memory _tokenMessage = TokenMessage.format( + message = TokenMessage.format( _recipient, _outboundAmount, _tokenMetadata ); - - messageId = _Router_dispatch( - _destination, - _value, - _tokenMessage, - _hookMetadata, - _hook - ); - - emit SentTransferRemote(_destination, _recipient, _outboundAmount); } /** * @dev Deposits into the vault and increment assetDeposited * @param _amount amount to deposit into vault */ - function _depositIntoVault(uint256 _amount) internal returns (uint256) { - wrappedToken.approve(address(vault), _amount); + function _depositIntoVault( + uint256 _amount + ) internal virtual returns (uint256) { return vault.deposit(_amount, address(this)); } @@ -116,8 +117,7 @@ contract HypERC4626Collateral is HypERC20Collateral { */ function _transferTo( address _recipient, - uint256 _shares, - bytes calldata + uint256 _shares ) internal virtual override { vault.redeem(_shares, _recipient, address(this)); } @@ -136,7 +136,6 @@ contract HypERC4626Collateral is HypERC20Collateral { _destinationDomain, NULL_RECIPIENT, 0, - msg.value, _hookMetadata, _hook ); diff --git a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol index 9b4f94705c..fb25411f5d 100644 --- a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol @@ -18,20 +18,15 @@ import {HypERC20Collateral} from "../HypERC20Collateral.sol"; // ============ External Imports ============ import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {HypERC4626Collateral} from "./HypERC4626Collateral.sol"; /** - * @title Hyperlane ERC20 Token Collateral with deposits collateral to a vault, the yield goes to the owner - * @author ltyu + * @title Hyperlane ERC4626 Token Collateral with deposits collateral to a vault, the yield goes to the owner + * @author Abacus Works */ -contract HypERC4626OwnerCollateral is HypERC20Collateral { - // Address of the ERC4626 compatible vault - ERC4626 public immutable vault; - // standby precision for exchange rate - uint256 public constant PRECISION = 1e10; +contract HypERC4626OwnerCollateral is HypERC4626Collateral { // Internal balance of total asset deposited uint256 public assetDeposited; - // Nonce for the rate update, to ensure sequential updates (not necessary for Owner variant but for compatibility with HypERC4626) - uint32 public rateUpdateNonce; event ExcessSharesSwept(uint256 amount, uint256 assetsRedeemed); @@ -39,40 +34,14 @@ contract HypERC4626OwnerCollateral is HypERC20Collateral { ERC4626 _vault, uint256 _scale, address _mailbox - ) HypERC20Collateral(_vault.asset(), _scale, _mailbox) { - vault = _vault; - } - - function initialize( - address _hook, - address _interchainSecurityModule, - address _owner - ) public override initializer { - wrappedToken.approve(address(vault), type(uint256).max); - _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); - } + ) HypERC4626Collateral(_vault, _scale, _mailbox) {} - /** - * @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract, and deposit into vault - * @inheritdoc HypERC20Collateral - */ - function _transferFromSender( + function _depositIntoVault( uint256 _amount - ) internal override returns (bytes memory metadata) { - super._transferFromSender(_amount); - _depositIntoVault(_amount); - rateUpdateNonce++; - - return abi.encode(PRECISION, rateUpdateNonce); - } - - /** - * @dev Deposits into the vault and increment assetDeposited - * @param _amount amount to deposit into vault - */ - function _depositIntoVault(uint256 _amount) internal { + ) internal virtual override returns (uint256) { assetDeposited += _amount; vault.deposit(_amount, address(this)); + return _amount; } /** @@ -81,18 +50,8 @@ contract HypERC4626OwnerCollateral is HypERC20Collateral { */ function _transferTo( address _recipient, - uint256 _amount, - bytes calldata + uint256 _amount ) internal virtual override { - _withdrawFromVault(_amount, _recipient); - } - - /** - * @dev Withdraws from the vault and decrement assetDeposited - * @param _amount amount to withdraw from vault - * @param _recipient address to deposit withdrawn underlying to - */ - function _withdrawFromVault(uint256 _amount, address _recipient) internal { assetDeposited -= _amount; vault.withdraw(_amount, _recipient, address(this)); } diff --git a/solidity/contracts/token/extensions/HypERC721URICollateral.sol b/solidity/contracts/token/extensions/HypERC721URICollateral.sol index 780f1a2232..384d1e9775 100644 --- a/solidity/contracts/token/extensions/HypERC721URICollateral.sol +++ b/solidity/contracts/token/extensions/HypERC721URICollateral.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.0; import {HypERC721Collateral} from "../HypERC721Collateral.sol"; +import {TokenMessage} from "../libs/TokenMessage.sol"; import {IERC721MetadataUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721MetadataUpgradeable.sol"; @@ -16,20 +17,17 @@ contract HypERC721URICollateral is HypERC721Collateral { address _mailbox ) HypERC721Collateral(erc721, _mailbox) {} - /** - * @dev Transfers `_tokenId` of `wrappedToken` from `msg.sender` to this contract. - * @return The URI of `_tokenId` on `wrappedToken`. - * @inheritdoc HypERC721Collateral - */ - function _transferFromSender( + function _beforeDispatch( + uint32 _destination, + bytes32 _recipient, uint256 _tokenId - ) internal override returns (bytes memory) { + ) internal override returns (uint256 dispatchValue, bytes memory message) { HypERC721Collateral._transferFromSender(_tokenId); - return - bytes( - IERC721MetadataUpgradeable(address(wrappedToken)).tokenURI( - _tokenId - ) - ); + dispatchValue = msg.value; + + string memory _tokenURI = IERC721MetadataUpgradeable( + address(wrappedToken) + ).tokenURI(_tokenId); + message = TokenMessage.format(_recipient, _tokenId, bytes(_tokenURI)); } } diff --git a/solidity/contracts/token/extensions/HypERC721URIStorage.sol b/solidity/contracts/token/extensions/HypERC721URIStorage.sol index 73b274558d..88aa16cfdd 100644 --- a/solidity/contracts/token/extensions/HypERC721URIStorage.sol +++ b/solidity/contracts/token/extensions/HypERC721URIStorage.sol @@ -2,6 +2,8 @@ pragma solidity >=0.8.0; import {HypERC721} from "../HypERC721.sol"; +import {TokenMessage} from "../libs/TokenMessage.sol"; +import {TypeCasts} from "../../libs/TypeCasts.sol"; import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; @@ -13,6 +15,9 @@ import {IERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC7 * @author Abacus Works */ contract HypERC721URIStorage is HypERC721, ERC721URIStorageUpgradeable { + using TokenMessage for bytes; + using TypeCasts for bytes32; + constructor(address _mailbox) HypERC721(_mailbox) {} function balanceOf( @@ -26,28 +31,36 @@ contract HypERC721URIStorage is HypERC721, ERC721URIStorageUpgradeable { return HypERC721.balanceOf(account); } - /** - * @return _tokenURI The URI of `_tokenId`. - * @inheritdoc HypERC721 - */ - function _transferFromSender( + function _beforeDispatch( + uint32 _destination, + bytes32 _recipient, uint256 _tokenId - ) internal override returns (bytes memory _tokenURI) { - _tokenURI = bytes(tokenURI(_tokenId)); // requires minted + ) internal override returns (uint256 dispatchValue, bytes memory message) { + string memory _tokenURI = tokenURI(_tokenId); // requires minted + HypERC721._transferFromSender(_tokenId); + + dispatchValue = msg.value; + + message = TokenMessage.format( + _recipient, + _tokenId, + abi.encodePacked(_tokenURI) + ); } - /** - * @dev Sets the URI for `_tokenId` to `_tokenURI`. - * @inheritdoc HypERC721 - */ - function _transferTo( - address _recipient, - uint256 _tokenId, - bytes calldata _tokenURI - ) internal override { - HypERC721._transferTo(_recipient, _tokenId, _tokenURI); - _setTokenURI(_tokenId, string(_tokenURI)); // requires minted + function _handle( + uint32 _origin, + bytes32, + bytes calldata _message + ) internal virtual override { + bytes32 recipient = _message.recipient(); + uint256 tokenId = _message.tokenId(); + + emit ReceivedTransferRemote(_origin, recipient, tokenId); + + HypERC721._transferTo(recipient.bytes32ToAddress(), tokenId); + _setTokenURI(tokenId, string(_message.metadata())); } function tokenURI( diff --git a/solidity/contracts/token/extensions/HypFiatToken.sol b/solidity/contracts/token/extensions/HypFiatToken.sol index 108020e6bf..1f693cef82 100644 --- a/solidity/contracts/token/extensions/HypFiatToken.sol +++ b/solidity/contracts/token/extensions/HypFiatToken.sol @@ -12,19 +12,16 @@ contract HypFiatToken is HypERC20Collateral { address _mailbox ) HypERC20Collateral(_fiatToken, _scale, _mailbox) {} - function _transferFromSender( - uint256 _amount - ) internal override returns (bytes memory metadata) { + function _transferFromSender(uint256 _amount) internal override { // transfer amount to address(this) - metadata = super._transferFromSender(_amount); + HypERC20Collateral._transferFromSender(_amount); // burn amount of address(this) balance IFiatToken(address(wrappedToken)).burn(_amount); } function _transferTo( address _recipient, - uint256 _amount, - bytes calldata /*metadata*/ + uint256 _amount ) internal override { require( IFiatToken(address(wrappedToken)).mint(_recipient, _amount), diff --git a/solidity/contracts/token/extensions/HypXERC20.sol b/solidity/contracts/token/extensions/HypXERC20.sol index 422279d333..03f5377115 100644 --- a/solidity/contracts/token/extensions/HypXERC20.sol +++ b/solidity/contracts/token/extensions/HypXERC20.sol @@ -13,17 +13,13 @@ contract HypXERC20 is HypERC20Collateral { _disableInitializers(); } - function _transferFromSender( - uint256 _amountOrId - ) internal override returns (bytes memory metadata) { + function _transferFromSender(uint256 _amountOrId) internal override { IXERC20(address(wrappedToken)).burn(msg.sender, _amountOrId); - return ""; } function _transferTo( address _recipient, - uint256 _amountOrId, - bytes calldata /*metadata*/ + uint256 _amountOrId ) internal override { IXERC20(address(wrappedToken)).mint(_recipient, _amountOrId); } diff --git a/solidity/contracts/token/extensions/HypXERC20Lockbox.sol b/solidity/contracts/token/extensions/HypXERC20Lockbox.sol index 8f6ed5e0d1..6f0a18a77d 100644 --- a/solidity/contracts/token/extensions/HypXERC20Lockbox.sol +++ b/solidity/contracts/token/extensions/HypXERC20Lockbox.sol @@ -55,22 +55,18 @@ contract HypXERC20Lockbox is HypERC20Collateral { _MailboxClient_initialize(_hook, _ism, _owner); } - function _transferFromSender( - uint256 _amount - ) internal override returns (bytes memory) { + function _transferFromSender(uint256 _amount) internal override { // transfer erc20 from sender super._transferFromSender(_amount); // convert erc20 to xERC20 lockbox.deposit(_amount); // burn xERC20 xERC20.burn(address(this), _amount); - return bytes(""); } function _transferTo( address _recipient, - uint256 _amount, - bytes calldata /*metadata*/ + uint256 _amount ) internal override { // mint xERC20 xERC20.mint(address(this), _amount); diff --git a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol index c62d1549b4..f1756bfc55 100644 --- a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol +++ b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol @@ -97,7 +97,6 @@ contract OpL2NativeTokenBridge is HypNative { uint32 _destination, bytes32 _recipient, uint256 _amount, - uint256 _value, bytes memory _hookMetadata, address _hook ) internal virtual override returns (bytes32) { @@ -111,7 +110,6 @@ contract OpL2NativeTokenBridge is HypNative { _destination, _recipient, 0, - _value, _proveHookMetadata(), _hook ); @@ -120,7 +118,6 @@ contract OpL2NativeTokenBridge is HypNative { _destination, _recipient, _amount, - address(this).balance, _finalizeHookMetadata(), _hook ); @@ -175,7 +172,6 @@ abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm { uint32, bytes32, uint256, - uint256, bytes memory, address ) internal override returns (bytes32) { @@ -191,8 +187,7 @@ abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm { function _transferTo( address _recipient, - uint256 _amount, - bytes calldata metadata + uint256 _amount ) internal override { // do not transfer to recipient as the OP L1 bridge will do it } diff --git a/solidity/contracts/token/fees/BaseFee.sol b/solidity/contracts/token/fees/BaseFee.sol new file mode 100644 index 0000000000..33e853f029 --- /dev/null +++ b/solidity/contracts/token/fees/BaseFee.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {ITokenFee, Quote} from "../../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"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {PackageVersioned} from "../../PackageVersioned.sol"; + +enum FeeType { + ZERO, + LINEAR, + REGRESSIVE, + PROGRESSIVE, + ROUTING +} + +abstract contract BaseFee is Ownable, ITokenFee, PackageVersioned { + using Address for address payable; + using SafeERC20 for IERC20; + + /** + * @notice The ERC20 token for which this fee contract applies. + */ + IERC20 public immutable token; + + /** + * @notice The maximum fee (in token units) that can be charged for a transfer. + * @dev Used as the cap or asymptote in fee calculations for derived contracts. + */ + uint256 public immutable maxFee; + + /** + * @notice The reference amount at which the fee equals half of maxFee. + * @dev Used as a scaling parameter in fee formulas; its interpretation depends on the fee model. + */ + uint256 public immutable halfAmount; + + constructor( + address _token, + uint256 _maxFee, + uint256 _halfAmount, + address _owner + ) Ownable() { + require(_maxFee > 0, "maxFee must be greater than zero"); + require(_halfAmount > 0, "halfAmount must be greater than zero"); + require(_owner != address(0), "owner cannot be zero address"); + + token = IERC20(_token); + maxFee = _maxFee; + halfAmount = _halfAmount; + _transferOwnership(_owner); + } + + function claim(address beneficiary) external onlyOwner { + if (address(token) == address(0)) { + payable(beneficiary).sendValue(address(this).balance); + } else { + uint256 balance = token.balanceOf(address(this)); + token.safeTransfer(beneficiary, balance); + } + } + + function quoteTransferRemote( + uint32 /*_destination*/, + bytes32 /*_recipient*/, + uint256 _amount + ) external view virtual override returns (Quote[] memory quotes) { + quotes = new Quote[](1); + quotes[0] = Quote(address(token), _quoteTransfer(_amount)); + } + + function _quoteTransfer( + uint256 /*_amount*/ + ) internal view virtual returns (uint256 fee) { + return 0; + } + + function feeType() external view virtual returns (FeeType); + + receive() external payable { + require(address(token) == address(0), "Not native token"); + } +} diff --git a/solidity/contracts/token/fees/LinearFee.sol b/solidity/contracts/token/fees/LinearFee.sol new file mode 100644 index 0000000000..50b39f8e8b --- /dev/null +++ b/solidity/contracts/token/fees/LinearFee.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {BaseFee, FeeType} from "./BaseFee.sol"; + +/** + * @title Linear Fee Structure + * @dev Implements a linear fee model where the fee increases linearly with the transfer amount, up to a maximum cap. + * + * The fee calculation follows the formula: + * fee = min(maxFee, (amount * maxFee) / (2 * halfAmount)) + * + * For example: + * - If maxFee = 10 and halfAmount = 1000, then: + * - For amount = 1000, fee = 5 (half of maxFee) + * - For amount = 2000, fee = 10 (maxFee) + * - For amount = 500, fee = 2 (rounded down) + * - For amounts above 2 * halfAmount, the fee is capped at maxFee. + * + * This creates a simple, predictable fee structure where the fee scales linearly with the transfer amount until it reaches the cap. + * + * @dev The fee is always rounded down due to integer division + */ +contract LinearFee is BaseFee { + constructor( + address _token, + uint256 _maxFee, + uint256 _halfAmount, + address beneficiary + ) BaseFee(_token, _maxFee, _halfAmount, beneficiary) {} + + function _quoteTransfer( + uint256 amount + ) internal view override returns (uint256 fee) { + uint256 uncapped = (amount * maxFee) / (2 * halfAmount); + return uncapped > maxFee ? maxFee : uncapped; + } + + function feeType() external pure override returns (FeeType) { + return FeeType.LINEAR; + } +} diff --git a/solidity/contracts/token/fees/ProgressiveFee.sol b/solidity/contracts/token/fees/ProgressiveFee.sol new file mode 100644 index 0000000000..9f7302e6fa --- /dev/null +++ b/solidity/contracts/token/fees/ProgressiveFee.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {BaseFee, FeeType} from "./BaseFee.sol"; + +/** + * @title Progressive Fee Structure + * @dev Implements a progressive fee model where the fee percentage increases as the transfer amount increases. + * + * The fee calculation uses a rational function: fee = (maxFee * amount^2) / (halfAmount^2 + amount^2) + * + * Key characteristics: + * - Higher fee percentage for larger transfers + * - Lower fee percentage for smaller transfers + * - Fee approaches but never reaches maxFee as amount increases + * - Fee approaches 0 as amount approaches 0 + + * + * Example: + * - If maxFee = 1000 and halfAmount = 1000: + * - Transfer of 100 wei: fee = (1000 * 100^2) / (1000^2 + 100^2) = 9.9 wei (9.9%) + * - Transfer of 1000 wei: fee = (1000 * 1000^2) / (1000^2 + 1000^2) = 500 wei (50%) + * - Transfer of 10000 wei: fee = (1000 * 10000^2) / (1000^2 + 10000^2) = 990 wei (99%) + * + * This structure encourages smaller transactions while applying higher fees to larger transfers. + */ +contract ProgressiveFee is BaseFee { + constructor( + address _token, + uint256 _maxFee, + uint256 _halfAmount, + address beneficiary + ) BaseFee(_token, _maxFee, _halfAmount, beneficiary) {} + + function _quoteTransfer( + uint256 amount + ) internal view override returns (uint256 fee) { + uint256 amountSquared = amount ** 2; + uint256 denominator = halfAmount ** 2 + amountSquared; + return denominator == 0 ? 0 : (maxFee * amountSquared) / denominator; + } + + function feeType() external pure override returns (FeeType) { + return FeeType.PROGRESSIVE; + } +} diff --git a/solidity/contracts/token/fees/RegressiveFee.sol b/solidity/contracts/token/fees/RegressiveFee.sol new file mode 100644 index 0000000000..5d51f98473 --- /dev/null +++ b/solidity/contracts/token/fees/RegressiveFee.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {BaseFee, FeeType} from "./BaseFee.sol"; + +/** + * @title Regressive Fee Structure + * @dev Implements a regressive fee model where the fee percentage decreases as the transfer amount increases. + * + * The fee calculation uses a rational function: fee = (maxFee * amount) / (halfAmount + amount) + * + * Key characteristics: + * - Higher fee percentage for smaller transfers + * - Lower fee percentage for larger transfers + * - Fee approaches maxFee as amount approaches infinity + * - Fee approaches 0 as amount approaches 0 + * + * Example: + * - If maxFee = 1000 and halfAmount = 1000: + * - Transfer of 100 wei: fee = (1000 * 100) / (1000 + 100) = 90.9 wei (90.9%) + * - Transfer of 1000 wei: fee = (1000 * 1000) / (1000 + 1000) = 500 wei (50%) + * - Transfer of 10000 wei: fee = (1000 * 10000) / (1000 + 10000) = 909 wei (9.09%) + * + * This structure encourages larger transfers while applying higher fees to smaller transactions. + */ +contract RegressiveFee is BaseFee { + constructor( + address _token, + uint256 _maxFee, + uint256 _halfAmount, + address beneficiary + ) BaseFee(_token, _maxFee, _halfAmount, beneficiary) {} + + function _quoteTransfer( + uint256 amount + ) internal view override returns (uint256 fee) { + uint256 denominator = halfAmount + amount; + return denominator == 0 ? 0 : (maxFee * amount) / denominator; + } + + function feeType() external pure override returns (FeeType) { + return FeeType.REGRESSIVE; + } +} diff --git a/solidity/contracts/token/fees/RoutingFee.sol b/solidity/contracts/token/fees/RoutingFee.sol new file mode 100644 index 0000000000..37ae830837 --- /dev/null +++ b/solidity/contracts/token/fees/RoutingFee.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; + +import {ITokenFee, Quote} from "../../interfaces/ITokenBridge.sol"; +import {BaseFee, FeeType} from "./BaseFee.sol"; + +/** + * @title RoutingFee + * @notice Implements ITokenFee, allowing per-destination fee contracts. Returns 0 fee for destinations not configured. + */ +contract RoutingFee is BaseFee { + constructor( + address _token, + address _owner + ) BaseFee(_token, type(uint256).max, type(uint256).max, _owner) {} + + mapping(uint32 destination => address feeContract) public feeContracts; + + event FeeContractSet(uint32 destination, address feeContract); + + /** + * @notice Sets the fee contract for a specific destination chain. + * @param destination The destination chain ID. + * @param feeContract The address of the ITokenFee contract for this destination. + */ + function setFeeContract( + uint32 destination, + address feeContract + ) external onlyOwner { + feeContracts[destination] = feeContract; + emit FeeContractSet(destination, feeContract); + } + + /** + * @inheritdoc ITokenFee + * @dev Returns a zero-amount Quote if no fee contract is set for the destination. + */ + function quoteTransferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) external view override returns (Quote[] memory quotes) { + address feeContract = feeContracts[_destination]; + if (feeContract != address(0)) { + return + ITokenFee(feeContract).quoteTransferRemote( + _destination, + _recipient, + _amount + ); + } + quotes = new Quote[](0); + } + + function feeType() external pure override returns (FeeType) { + return FeeType.ROUTING; + } +} diff --git a/solidity/contracts/token/interfaces/ValueTransferBridge.sol b/solidity/contracts/token/interfaces/ValueTransferBridge.sol deleted file mode 100644 index 8d573e5df4..0000000000 --- a/solidity/contracts/token/interfaces/ValueTransferBridge.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity >=0.8.0; - -struct Quote { - address token; - uint256 amount; -} - -interface ValueTransferBridge { - function quoteTransferRemote( - uint32 destinationDomain, - bytes32 recipient, - uint amountOut - ) external view returns (Quote[] memory); - - function transferRemote( - uint32 destinationDomain, - bytes32 recipient, - uint256 amountOut - ) external payable returns (bytes32 transferId); -} diff --git a/solidity/contracts/token/libs/FungibleTokenRouter.sol b/solidity/contracts/token/libs/FungibleTokenRouter.sol index 7579f3287e..17f98ca2b0 100644 --- a/solidity/contracts/token/libs/FungibleTokenRouter.sol +++ b/solidity/contracts/token/libs/FungibleTokenRouter.sol @@ -2,35 +2,150 @@ pragma solidity >=0.8.0; import {TokenRouter} from "./TokenRouter.sol"; +import {Quote, ITokenFee} from "../../interfaces/ITokenBridge.sol"; +import {TokenMessage} from "./TokenMessage.sol"; +import {TypeCasts} from "../../libs/TypeCasts.sol"; +import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; /** * @title Hyperlane Fungible Token Router that extends TokenRouter with scaling logic for fungible tokens with different decimals. * @author Abacus Works */ abstract contract FungibleTokenRouter is TokenRouter { + using TokenMessage for bytes; + using TypeCasts for bytes32; + using StorageSlot for bytes32; + uint256 public immutable scale; + bytes32 private constant FEE_RECIPIENT_SLOT = + keccak256("FungibleTokenRouter.feeRecipient"); + + event FeeRecipientSet(address feeRecipient); + constructor(uint256 _scale, address _mailbox) TokenRouter(_mailbox) { scale = _scale; } + /** + * @notice Sets the fee recipient for the router. + * @dev Allows for address(0) to be set, which disables fees. + * @param _feeRecipient The address of the fee recipient. + */ + function setFeeRecipient(address _feeRecipient) public onlyOwner { + FEE_RECIPIENT_SLOT.getAddressSlot().value = _feeRecipient; + emit FeeRecipientSet(_feeRecipient); + } + + function feeRecipient() public view virtual returns (address) { + return FEE_RECIPIENT_SLOT.getAddressSlot().value; + } + + /** + * @inheritdoc ITokenFee + * @dev Returns fungible fee and bridge amounts separately for client to easily distinguish. + */ + function quoteTransferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) external view virtual override returns (Quote[] memory quotes) { + quotes = new Quote[](2); + quotes[0] = Quote({ + token: address(0), + amount: _quoteGasPayment(_destination, _recipient, _amount) + }); + quotes[1] = Quote({ + token: token(), + amount: _feeAmount(_destination, _recipient, _amount) + _amount + }); + return quotes; + } + + function token() public view virtual returns (address); + + function _feeAmount( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal view virtual returns (uint256 feeAmount) { + if (feeRecipient() == address(0)) { + return 0; + } + + Quote[] memory quotes = ITokenFee(feeRecipient()).quoteTransferRemote( + _destination, + _recipient, + _amount + ); + if (quotes.length == 0) { + return 0; + } + + require( + quotes.length == 1 && quotes[0].token == token(), + "FungibleTokenRouter: fee must match token" + ); + return quotes[0].amount; + } + /** * @dev Scales local amount to message amount (up by scale factor). - * @inheritdoc TokenRouter */ function _outboundAmount( uint256 _localAmount - ) internal view virtual override returns (uint256 _messageAmount) { + ) internal view virtual returns (uint256 _messageAmount) { _messageAmount = _localAmount * scale; } /** * @dev Scales message amount to local amount (down by scale factor). - * @inheritdoc TokenRouter */ function _inboundAmount( uint256 _messageAmount - ) internal view virtual override returns (uint256 _localAmount) { + ) internal view virtual returns (uint256 _localAmount) { _localAmount = _messageAmount / scale; } + + function _chargeSender( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal virtual returns (uint256 dispatchValue) { + uint256 fee = _feeAmount(_destination, _recipient, _amount); + _transferFromSender(_amount + fee); + if (fee > 0) { + _transferTo(feeRecipient(), fee); + } + return msg.value; + } + + function _beforeDispatch( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) + internal + virtual + override + returns (uint256 dispatchValue, bytes memory message) + { + dispatchValue = _chargeSender(_destination, _recipient, _amount); + message = TokenMessage.format(_recipient, _outboundAmount(_amount)); + } + + function _handle( + uint32 _origin, + bytes32, + bytes calldata _message + ) internal virtual override { + bytes32 recipient = _message.recipient(); + uint256 amount = _message.amount(); + + // effects + emit ReceivedTransferRemote(_origin, recipient, amount); + + // interactions + _transferTo(recipient.bytes32ToAddress(), _inboundAmount(amount)); + } } diff --git a/solidity/contracts/token/libs/MovableCollateralRouter.sol b/solidity/contracts/token/libs/MovableCollateralRouter.sol index df6af5cefc..37933e95a7 100644 --- a/solidity/contracts/token/libs/MovableCollateralRouter.sol +++ b/solidity/contracts/token/libs/MovableCollateralRouter.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0; import {Router} from "../../client/Router.sol"; import {FungibleTokenRouter} from "./FungibleTokenRouter.sol"; -import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; +import {ITokenBridge} from "../../interfaces/ITokenBridge.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -158,10 +158,6 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { uint256 amount, ITokenBridge bridge ) internal virtual { - bridge.transferRemote{value: msg.value}({ - _destination: domain, - _recipient: recipient, - _amount: amount - }); + bridge.transferRemote{value: msg.value}(domain, recipient, amount); } } diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index e9a1653d05..86cdb6c688 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -20,24 +20,24 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { * @dev Emitted on `transferRemote` when a transfer message is dispatched. * @param destination The identifier of the destination chain. * @param recipient The address of the recipient on the destination chain. - * @param amount The amount of tokens sent in to the remote recipient. + * @param amountOrId The amount or ID of tokens sent in to the remote recipient. */ event SentTransferRemote( uint32 indexed destination, bytes32 indexed recipient, - uint256 amount + uint256 amountOrId ); /** * @dev Emitted on `_handle` when a transfer message is processed. * @param origin The identifier of the origin chain. * @param recipient The address of the recipient on the destination chain. - * @param amount The amount of tokens received from the remote sender. + * @param amountOrId The amount or ID of tokens received from the remote sender. */ event ReceivedTransferRemote( uint32 indexed origin, bytes32 indexed recipient, - uint256 amount + uint256 amountOrId ); constructor(address _mailbox) GasRouter(_mailbox) {} @@ -57,7 +57,13 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { uint256 _amountOrId ) external payable virtual returns (bytes32 messageId) { return - _transferRemote(_destination, _recipient, _amountOrId, msg.value); + _transferRemote( + _destination, + _recipient, + _amountOrId, + _GasRouter_hookMetadata(_destination), + address(hook) + ); } /** @@ -84,7 +90,6 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { _destination, _recipient, _amountOrId, - msg.value, _hookMetadata, _hook ); @@ -94,77 +99,55 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { uint32 _destination, bytes32 _recipient, uint256 _amountOrId, - uint256 _value - ) internal returns (bytes32 messageId) { - return - _transferRemote( - _destination, - _recipient, - _amountOrId, - _value, - _GasRouter_hookMetadata(_destination), - address(hook) - ); - } - - function _transferRemote( - uint32 _destination, - bytes32 _recipient, - uint256 _amountOrId, - uint256 _value, bytes memory _hookMetadata, address _hook ) internal virtual returns (bytes32 messageId) { - bytes memory _tokenMetadata = _transferFromSender(_amountOrId); - - uint256 outboundAmount = _outboundAmount(_amountOrId); - bytes memory _tokenMessage = TokenMessage.format( + // checks + (uint256 _dispatchValue, bytes memory _tokenMessage) = _beforeDispatch( + _destination, _recipient, - outboundAmount, - _tokenMetadata + _amountOrId ); + // effects + emit SentTransferRemote(_destination, _recipient, _amountOrId); + + // interactions messageId = _Router_dispatch( _destination, - _value, + _dispatchValue, _tokenMessage, _hookMetadata, _hook ); - - emit SentTransferRemote(_destination, _recipient, outboundAmount); } /** - * @dev Should return the amount of tokens to be encoded in the message amount (eg for scaling `_localAmount`). - * @param _localAmount The amount of tokens transferred on this chain in local denomination. - * @return _messageAmount The amount of tokens to be encoded in the message body. + * @dev Called by `transferRemote` before message dispatch. + * @dev Can be overriden to add metadata to the message. + * @dev Can be overriden to change the value forwarded to the mailbox. + * @param _destination The identifier of the destination chain. + * @param _recipient The address of the recipient on the destination chain. + * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient. + * @return dispatchValue The value to be forwarded to the mailbox. + * @return message The message to the router on the destination chain. */ - function _outboundAmount( - uint256 _localAmount - ) internal view virtual returns (uint256 _messageAmount) { - _messageAmount = _localAmount; - } + function _beforeDispatch( + uint32 _destination, + bytes32 _recipient, + uint256 _amountOrId + ) internal virtual returns (uint256 dispatchValue, bytes memory message) { + _transferFromSender(_amountOrId); - /** - * @dev Should return the amount of tokens to be decoded from the message amount. - * @param _messageAmount The amount of tokens received in the message body. - * @return _localAmount The amount of tokens to be transferred on this chain in local denomination. - */ - function _inboundAmount( - uint256 _messageAmount - ) internal view virtual returns (uint256 _localAmount) { - _localAmount = _messageAmount; + dispatchValue = msg.value; + message = TokenMessage.format(_recipient, _amountOrId); } /** * @dev Should transfer `_amountOrId` of tokens from `msg.sender` to this token router. * @dev Called by `transferRemote` before message dispatch. - * @dev Optionally returns `metadata` associated with the transfer to be passed in message. */ - function _transferFromSender( - uint256 _amountOrId - ) internal virtual returns (bytes memory metadata); + function _transferFromSender(uint256 _amountOrId) internal virtual; /** * @notice Returns the balance of `account` on this token router. @@ -233,23 +216,20 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { ) internal virtual override { bytes32 recipient = _message.recipient(); uint256 amount = _message.amount(); - bytes calldata metadata = _message.metadata(); - _transferTo( - recipient.bytes32ToAddress(), - _inboundAmount(amount), - metadata - ); + + // effects emit ReceivedTransferRemote(_origin, recipient, amount); + + // interactions + _transferTo(recipient.bytes32ToAddress(), amount); } /** * @dev Should transfer `_amountOrId` of tokens from this token router to `_recipient`. * @dev Called by `handle` after message decoding. - * @dev Optionally handles `metadata` associated with transfer passed in message. */ function _transferTo( address _recipient, - uint256 _amountOrId, - bytes calldata metadata + uint256 _amountOrId ) internal virtual; } diff --git a/solidity/package.json b/solidity/package.json index d59f706d8a..a18863774b 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -79,8 +79,8 @@ "coverage": "yarn fixtures && ./coverage.sh", "docs": "forge doc", "fixtures": "mkdir -p ./fixtures/aggregation ./fixtures/multisig", - "hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config hardhat.config.cts", - "hardhat-zk": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config zk-hardhat.config.cts", + "hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' npx hardhat --config hardhat.config.cts", + "hardhat-zk": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' npx hardhat --config zk-hardhat.config.cts", "prettier": "prettier --write ./contracts ./test", "test": "yarn version:exhaustive && yarn hardhat-esm test && yarn test:forge", "test:hardhat": "yarn hardhat-esm test", diff --git a/solidity/test/token/Fees.t.sol b/solidity/test/token/Fees.t.sol new file mode 100644 index 0000000000..0d40bed31a --- /dev/null +++ b/solidity/test/token/Fees.t.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; + +import {BaseFee, FeeType} from "../../contracts/token/fees/BaseFee.sol"; +import {LinearFee} from "../../contracts/token/fees/LinearFee.sol"; +import {ProgressiveFee} from "../../contracts/token/fees/ProgressiveFee.sol"; +import {RegressiveFee} from "../../contracts/token/fees/RegressiveFee.sol"; +import {RoutingFee} from "../../contracts/token/fees/RoutingFee.sol"; +import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; + +// --- Base Test --- + +abstract contract BaseFeeTest is Test { + BaseFee public feeContract; + address internal constant OWNER = address(0x123); + address internal constant BENEFICIARY = address(0x456); + + ERC20Test token = new ERC20Test("Test Token", "TST", 0, 18); + + uint32 internal constant destination = 1; + bytes32 internal constant recipient = + bytes32(uint256(uint160(address(0x789)))); + + function setUp() public virtual { + vm.label(OWNER, "Owner"); + vm.label(BENEFICIARY, "Beneficiary"); + } + + function test_Claim() public virtual { + // Test claiming ERC20 tokens + uint256 erc20Amount = 100 * 10 ** 18; + token.mintTo(address(feeContract), erc20Amount); + + uint256 beneficiaryErc20BalanceBefore = token.balanceOf(BENEFICIARY); + vm.prank(OWNER); + feeContract.claim(BENEFICIARY); + uint256 beneficiaryErc20BalanceAfter = token.balanceOf(BENEFICIARY); + + assertEq( + beneficiaryErc20BalanceAfter - beneficiaryErc20BalanceBefore, + erc20Amount, + "ERC20 claim failed" + ); + assertEq( + token.balanceOf(address(feeContract)), + 0, + "ERC20 balance not zero after claim" + ); + } +} + +// --- LinearFee Tests --- + +contract LinearFeeTest is BaseFeeTest { + uint256 internal constant DEFAULT_MAX_FEE = 1000; + uint256 internal constant DEFAULT_HALF_AMOUNT = 10000; + + function setUp() public override { + super.setUp(); + feeContract = new LinearFee( + address(token), + DEFAULT_MAX_FEE, + DEFAULT_HALF_AMOUNT, + OWNER + ); + } + + function test_LinearFee_Type() public { + assertEq(uint(feeContract.feeType()), uint(FeeType.LINEAR)); + } + + function test_LinearFee_Quote( + uint96 maxFee, + uint96 halfAmount, + uint96 amount + ) public { + vm.assume(maxFee > 0); + vm.assume(halfAmount > 0); + + LinearFee localLinearFee = new LinearFee( + address(token), + maxFee, + halfAmount, + OWNER + ); + + uint256 uncapped = (uint256(amount) * maxFee) / + (2 * uint256(halfAmount)); + uint256 expectedFee = uncapped > maxFee ? maxFee : uncapped; + + assertEq( + localLinearFee + .quoteTransferRemote(destination, recipient, amount)[0].amount, + expectedFee, + "Linear fee mismatch" + ); + } + + function test_RevertIf_ZeroHalfAmount() public { + vm.expectRevert(bytes("halfAmount must be greater than zero")); + LinearFee fee = new LinearFee( + address(token), + DEFAULT_MAX_FEE, + 0, + BENEFICIARY + ); + } + + function test_RevertIf_ZeroMaxFee() public { + vm.expectRevert(bytes("maxFee must be greater than zero")); + new LinearFee(address(token), 0, DEFAULT_HALF_AMOUNT, OWNER); + } + + function test_RevertIf_ZeroOwner() public { + vm.expectRevert(bytes("owner cannot be zero address")); + new LinearFee( + address(token), + DEFAULT_MAX_FEE, + DEFAULT_HALF_AMOUNT, + address(0) + ); + } +} + +// --- ProgressiveFee Tests --- + +contract ProgressiveFeeTest is BaseFeeTest { + uint256 internal constant DEFAULT_MAX_FEE = 1000; + uint256 internal constant DEFAULT_HALF_AMOUNT = 10000; + + function setUp() public override { + super.setUp(); + feeContract = new ProgressiveFee( + address(token), + DEFAULT_MAX_FEE, + DEFAULT_HALF_AMOUNT, + OWNER + ); + } + + function test_ProgressiveFee_Type() public { + assertEq(uint(feeContract.feeType()), uint(FeeType.PROGRESSIVE)); + } + + function test_ProgressiveFee_Quote( + uint96 maxFee, + uint96 halfAmount, + uint96 amount + ) public { + vm.assume(maxFee > 0); + vm.assume(halfAmount > 0); + vm.assume(amount != 0); + + uint256 amountSq = uint256(amount) * amount; + vm.assume(type(uint256).max / maxFee >= amountSq); + + uint256 halfSq = uint256(halfAmount) * halfAmount; + vm.assume(type(uint256).max - halfSq >= amountSq); + + ProgressiveFee localProgressiveFee = new ProgressiveFee( + address(token), + maxFee, + halfAmount, + OWNER + ); + + uint256 expectedFee = (uint256(maxFee) * amountSq) / + (halfSq + amountSq); + + assertEq( + localProgressiveFee + .quoteTransferRemote(destination, recipient, amount)[0].amount, + expectedFee, + "Progressive fee mismatch" + ); + } +} + +// --- RegressiveFee Tests --- + +contract RegressiveFeeTest is BaseFeeTest { + uint256 internal constant DEFAULT_MAX_FEE = 1000; + uint256 internal constant DEFAULT_HALF_AMOUNT = 10000; + + function setUp() public override { + super.setUp(); + feeContract = new RegressiveFee( + address(token), + DEFAULT_MAX_FEE, + DEFAULT_HALF_AMOUNT, + OWNER + ); + } + + function test_RegressiveFee_Type() public { + assertEq(uint(feeContract.feeType()), uint(FeeType.REGRESSIVE)); + } + + function test_RegressiveFee_Quote( + uint96 maxFee, + uint96 halfAmount, + uint96 amount + ) public { + vm.assume(maxFee > 0); + vm.assume(halfAmount > 0); + vm.assume(type(uint256).max - halfAmount >= amount); + + RegressiveFee localRegressiveFee = new RegressiveFee( + address(token), + maxFee, + halfAmount, + OWNER + ); + + uint256 expectedFee = (uint256(maxFee) * amount) / + (uint256(halfAmount) + amount); + + assertEq( + localRegressiveFee + .quoteTransferRemote(destination, recipient, amount)[0].amount, + expectedFee, + "Regressive fee mismatch" + ); + } +} + +// --- RoutingFee Tests --- + +contract RoutingFeeTest is BaseFeeTest { + RoutingFee public routingFee; + LinearFee public linearFee1; + uint32 internal constant DEST1 = 100; + uint256 internal constant MAX_FEE1 = 500; + uint256 internal constant HALF_AMOUNT1 = 1000; + + function setUp() public override { + super.setUp(); + routingFee = new RoutingFee(address(token), OWNER); + feeContract = routingFee; // for claim test + linearFee1 = new LinearFee( + address(token), + MAX_FEE1, + HALF_AMOUNT1, + OWNER + ); + } + + function test_RoutingFee_Type() public { + assertEq(uint(routingFee.feeType()), uint(FeeType.ROUTING)); + } + + function test_Quote_NoFeeContract() public { + // Use a destination that is not configured + Quote[] memory quotes = routingFee.quoteTransferRemote( + DEST1 + 1, + recipient, + 1000 + ); + assertEq( + quotes.length, + 0, + "Should return empty if no fee contract set" + ); + } + + function test_Quote_DelegatesToFeeContract() public { + vm.prank(OWNER); + routingFee.setFeeContract(DEST1, address(linearFee1)); + uint256 amount = 2000; + Quote[] memory quotes = routingFee.quoteTransferRemote( + DEST1, + recipient, + amount + ); + uint256 expected = (amount * MAX_FEE1) / (2 * HALF_AMOUNT1); + if (expected > MAX_FEE1) expected = MAX_FEE1; + assertEq(quotes.length, 1, "Should return one quote"); + assertEq(quotes[0].token, address(token), "Token address mismatch"); + assertEq(quotes[0].amount, expected, "Fee mismatch"); + } + + function test_SetFeeContract_EmitsEvent() public { + vm.prank(OWNER); + vm.expectEmit(true, true, false, true, address(routingFee)); + emit RoutingFee.FeeContractSet(DEST1, address(linearFee1)); + routingFee.setFeeContract(DEST1, address(linearFee1)); + assertEq(routingFee.feeContracts(DEST1), address(linearFee1)); + } + + function test_RevertIf_NonOwnerSetsFeeContract() public { + vm.prank(address(0x999)); + vm.expectRevert("Ownable: caller is not the owner"); + routingFee.setFeeContract(DEST1, address(linearFee1)); + } + + function test_Claim() public override { + // Test claiming ERC20 tokens from RoutingFee + uint256 erc20Amount = 100 * 10 ** 18; + token.mintTo(address(routingFee), erc20Amount); + uint256 beneficiaryErc20BalanceBefore = token.balanceOf(BENEFICIARY); + vm.prank(OWNER); + routingFee.claim(BENEFICIARY); + uint256 beneficiaryErc20BalanceAfter = token.balanceOf(BENEFICIARY); + assertEq( + beneficiaryErc20BalanceAfter - beneficiaryErc20BalanceBefore, + erc20Amount, + "ERC20 claim failed" + ); + assertEq( + token.balanceOf(address(routingFee)), + 0, + "ERC20 balance not zero after claim" + ); + } +} diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol index b24de6aecb..5597524f40 100644 --- a/solidity/test/token/HypERC20.t.sol +++ b/solidity/test/token/HypERC20.t.sol @@ -24,6 +24,8 @@ import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.so import {TestInterchainGasPaymaster} from "../../contracts/test/TestInterchainGasPaymaster.sol"; import {GasRouter} from "../../contracts/client/GasRouter.sol"; import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol"; +import {LinearFee} from "../../contracts/token/fees/LinearFee.sol"; +import {FungibleTokenRouter} from "../../contracts/token/libs/FungibleTokenRouter.sol"; import {Router} from "../../contracts/client/Router.sol"; import {HypERC20} from "../../contracts/token/HypERC20.sol"; @@ -37,6 +39,7 @@ import {HypNative} from "../../contracts/token/HypNative.sol"; import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol"; import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; import {Message} from "../../contracts/libs/Message.sol"; +import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; abstract contract HypTokenTest is Test { using TypeCasts for address; @@ -60,7 +63,7 @@ abstract contract HypTokenTest is Test { address internal constant PROXY_ADMIN = address(0x37); ERC20Test internal primaryToken; - TokenRouter internal localToken; + FungibleTokenRouter internal localToken; HypERC20 internal remoteToken; MockMailbox internal localMailbox; MockMailbox internal remoteMailbox; @@ -81,6 +84,8 @@ abstract contract HypTokenTest is Test { uint256 amount ); + LinearFee internal feeContract; + function setUp() public virtual { localMailbox = new MockMailbox(ORIGIN); remoteMailbox = new MockMailbox(DESTINATION); @@ -169,13 +174,8 @@ abstract contract HypTokenTest is Test { assertEq(remoteToken.balanceOf(_user), _balance); } - function _processTransfers(address _recipient, uint256 _amount) internal { - vm.prank(address(remoteMailbox)); - remoteToken.handle( - ORIGIN, - address(localToken).addressToBytes32(), - abi.encodePacked(_recipient.addressToBytes32(), _amount) - ); + function _processTransfers() internal { + remoteMailbox.processNextInboundMessage(); } function _handleLocalTransfer(uint256 _transferAmount) internal { @@ -217,7 +217,7 @@ abstract contract HypTokenTest is Test { vm.expectEmit(true, true, false, true); emit ReceivedTransferRemote(ORIGIN, BOB.addressToBytes32(), _amount); - _processTransfers(BOB, _amount); + _processTransfers(); assertEq(remoteToken.balanceOf(BOB), _amount); } @@ -256,7 +256,7 @@ abstract contract HypTokenTest is Test { _hookMetadata, address(_hook) ); - _processTransfers(BOB, _amount); + _processTransfers(); assertEq(remoteToken.balanceOf(BOB), _amount); } @@ -290,6 +290,72 @@ abstract contract HypTokenTest is Test { uint256 gasAfter = gasleft(); console.log("Overhead gas usage: %d", gasBefore - gasAfter); } + + function testRemoteTransfer_withFee() public virtual { + feeContract = new LinearFee( + address(primaryToken), + 1e18, + 100e18, + address(this) + ); + localToken.setFeeRecipient(address(feeContract)); + uint256 fee = feeContract + .quoteTransferRemote(DESTINATION, BOB.addressToBytes32(), TRANSFER_AMT)[ + 0 + ].amount; + uint256 total = TRANSFER_AMT + fee; + + uint256 nativeValue = REQUIRED_VALUE; + if (address(primaryToken) != address(0)) { + deal(address(primaryToken), ALICE, total); + vm.prank(ALICE); + primaryToken.approve(address(localToken), total); + } else { + vm.deal(ALICE, total); + nativeValue += total; + } + + ( + uint256 senderBefore, + uint256 beneficiaryBefore, + uint256 recipientBefore + ) = _getBalances(ALICE, BOB); + + vm.prank(ALICE); + localToken.transferRemote{value: nativeValue}( + DESTINATION, + BOB.addressToBytes32(), + TRANSFER_AMT + ); + + _processTransfers(); + ( + uint256 senderAfter, + uint256 beneficiaryAfter, + uint256 recipientAfter + ) = _getBalances(ALICE, BOB); + + assertEq(senderAfter, senderBefore - (TRANSFER_AMT + fee)); + assertEq(beneficiaryAfter, beneficiaryBefore + fee); + assertEq(recipientAfter, recipientBefore + TRANSFER_AMT); + } + + function _getBalances( + address sender, + address recipient + ) + internal + virtual + returns ( + uint256 senderBalance, + uint256 beneficiaryBalance, + uint256 recipientBalance + ) + { + senderBalance = localToken.balanceOf(sender); + beneficiaryBalance = localToken.balanceOf(address(feeContract)); + recipientBalance = remoteToken.balanceOf(recipient); + } } contract HypERC20Test is HypTokenTest { @@ -320,6 +386,7 @@ contract HypERC20Test is HypTokenTest { ); localToken = HypERC20(address(proxy)); erc20Token = HypERC20(address(proxy)); + primaryToken = ERC20Test(address(erc20Token)); erc20Token.enrollRemoteRouter( DESTINATION, @@ -372,7 +439,12 @@ contract HypERC20Test is HypTokenTest { function testRemoteTransfer_invalidAmount() public { vm.expectRevert("ERC20: burn amount exceeds balance"); - _performRemoteTransfer(REQUIRED_VALUE, TRANSFER_AMT * 11); + vm.prank(ALICE); + localToken.transferRemote{value: REQUIRED_VALUE}( + DESTINATION, + BOB.addressToBytes32(), + TRANSFER_AMT * 11 + ); assertEq(erc20Token.balanceOf(ALICE), 1000e18); } @@ -433,7 +505,12 @@ contract HypERC20CollateralTest is HypTokenTest { function testRemoteTransfer_invalidAllowance() public { vm.expectRevert("ERC20: insufficient allowance"); - _performRemoteTransfer(REQUIRED_VALUE, TRANSFER_AMT); + vm.prank(ALICE); + localToken.transferRemote{value: REQUIRED_VALUE}( + DESTINATION, + BOB.addressToBytes32(), + TRANSFER_AMT + ); assertEq(localToken.balanceOf(ALICE), 1000e18); } @@ -645,6 +722,7 @@ contract HypNativeTest is HypTokenTest { localToken = new HypNative(SCALE, address(localMailbox)); nativeToken = HypNative(payable(address(localToken))); + primaryToken = ERC20Test(address(0)); nativeToken.enrollRemoteRouter( DESTINATION, @@ -666,8 +744,6 @@ contract HypNativeTest is HypTokenTest { uint256 value = REQUIRED_VALUE + TRANSFER_AMT; - vm.prank(ALICE); - primaryToken.approve(address(localToken), TRANSFER_AMT); bytes32 messageId = _performRemoteTransferWithHook( value, TRANSFER_AMT, @@ -687,9 +763,11 @@ contract HypNativeTest is HypTokenTest { function testRemoteTransfer_invalidAmount() public { vm.expectRevert("Native: amount exceeds msg.value"); - _performRemoteTransfer( - REQUIRED_VALUE + TRANSFER_AMT, - TRANSFER_AMT * 10 + vm.prank(ALICE); + localToken.transferRemote{value: REQUIRED_VALUE + TRANSFER_AMT}( + DESTINATION, + BOB.addressToBytes32(), + REQUIRED_VALUE + TRANSFER_AMT + 1 ); assertEq(localToken.balanceOf(ALICE), 1000e18); } @@ -697,10 +775,12 @@ contract HypNativeTest is HypTokenTest { function testRemoteTransfer_withCustomGasConfig() public { _setCustomGasConfig(); - _performRemoteTransferAndGas( - REQUIRED_VALUE, - TRANSFER_AMT, - TRANSFER_AMT + GAS_LIMIT * igp.gasPrice() + uint256 balanceBefore = ALICE.balance; + uint256 gasOverhead = GAS_LIMIT * igp.gasPrice(); + _performRemoteTransfer(TRANSFER_AMT + gasOverhead, TRANSFER_AMT); + assertEq( + ALICE.balance, + balanceBefore - TRANSFER_AMT - REQUIRED_VALUE - gasOverhead ); } @@ -761,6 +841,7 @@ contract HypERC20ScaledTest is HypTokenTest { localToken = HypERC20(address(proxy)); erc20Token = HypERC20(address(proxy)); erc20Token.transfer(ALICE, TRANSFER_AMT); + primaryToken = ERC20Test(address(erc20Token)); _enrollLocalTokenRouter(); _enrollRemoteTokenRouter(); @@ -774,16 +855,18 @@ contract HypERC20ScaledTest is HypTokenTest { emit SentTransferRemote( DESTINATION, BOB.addressToBytes32(), - TRANSFER_AMT * EFFECTIVE_SCALE + TRANSFER_AMT ); - _performRemoteTransferAndGas(REQUIRED_VALUE, TRANSFER_AMT, 0); + vm.prank(ALICE); + localToken.transferRemote{value: REQUIRED_VALUE}( + DESTINATION, + BOB.addressToBytes32(), + TRANSFER_AMT + ); } function testHandle() public { - vm.expectEmit(true, true, false, true); - emit Transfer(address(0x0), ALICE, TRANSFER_AMT / EFFECTIVE_SCALE); - vm.expectEmit(true, true, false, true); emit ReceivedTransferRemote( DESTINATION, @@ -791,6 +874,44 @@ contract HypERC20ScaledTest is HypTokenTest { TRANSFER_AMT ); + vm.expectEmit(true, true, false, true); + emit Transfer(address(0x0), ALICE, TRANSFER_AMT / EFFECTIVE_SCALE); + _handleLocalTransfer(TRANSFER_AMT); } + + function testTransfer_withHookSpecified( + uint256 fee, + bytes calldata metadata + ) public override { + TestPostDispatchHook hook = new TestPostDispatchHook(); + hook.setFee(fee); + + vm.prank(ALICE); + bytes32 messageId = localToken.transferRemote{value: REQUIRED_VALUE}( + DESTINATION, + BOB.addressToBytes32(), + TRANSFER_AMT, + metadata, + address(hook) + ); + assertTrue(hook.messageDispatched(messageId)); + } + + function _getBalances( + address sender, + address recipient + ) + internal + override + returns ( + uint256 senderBalance, + uint256 beneficiaryBalance, + uint256 recipientBalance + ) + { + (senderBalance, beneficiaryBalance, recipientBalance) = super + ._getBalances(sender, recipient); + recipientBalance = recipientBalance / EFFECTIVE_SCALE; + } } diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index 80658cf338..bf7f94dbbb 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -23,7 +23,7 @@ import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; import {HypTokenTest} from "./HypERC20.t.sol"; -import {HypERC4626OwnerCollateral} from "../../contracts/token/extensions/HypERC4626OwnerCollateral.sol"; +import {HypERC4626OwnerCollateral, HypERC4626Collateral} from "../../contracts/token/extensions/HypERC4626OwnerCollateral.sol"; import "../../contracts/test/ERC4626/ERC4626Test.sol"; contract HypERC4626OwnerCollateralTest is HypTokenTest { @@ -46,7 +46,7 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { address(implementation), PROXY_ADMIN, abi.encodeWithSelector( - HypERC4626OwnerCollateral.initialize.selector, + HypERC4626Collateral.initialize.selector, address(address(noopHook)), address(igp), address(this) diff --git a/solidity/test/token/HypERC20MovableCollateral.t.sol b/solidity/test/token/HypERC20MovableCollateral.t.sol index 90f13e41f1..00623d2f2b 100644 --- a/solidity/test/token/HypERC20MovableCollateral.t.sol +++ b/solidity/test/token/HypERC20MovableCollateral.t.sol @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity ^0.8.13; -import {MockTokenBridge} from "./MovableCollateralRouter.t.sol"; +import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; +import {MockITokenBridge} from "./MovableCollateralRouter.t.sol"; import {HypERC20Collateral} from "contracts/token/HypERC20Collateral.sol"; // import {HypERC20MovableCollateral} from "contracts/token/HypERC20MovableCollateral.sol"; @@ -12,7 +13,7 @@ import "forge-std/Test.sol"; contract HypERC20MovableCollateralRouterTest is Test { HypERC20Collateral internal router; - MockTokenBridge internal vtb; + MockITokenBridge internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); @@ -27,7 +28,7 @@ contract HypERC20MovableCollateralRouterTest is Test { // Initialize the router -> we are the admin router.initialize(address(0), address(0), address(this)); - vtb = new MockTokenBridge(token); + vtb = new MockITokenBridge(token); } function _configure(bytes32 _recipient) internal { diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol index 60a202f262..bb348ec6d8 100644 --- a/solidity/test/token/HypERC4626Test.t.sol +++ b/solidity/test/token/HypERC4626Test.t.sol @@ -641,13 +641,6 @@ contract HypERC4626CollateralTest is HypTokenTest { ); } - function testTransfer_withHookSpecified( - uint256, - bytes calldata - ) public override { - // skip - } - function testBenchmark_overheadGasUsage() public override { _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); diff --git a/solidity/test/token/HypnativeMovable.t.sol b/solidity/test/token/HypnativeMovable.t.sol index bb15cdcfcd..84f7d26a9f 100644 --- a/solidity/test/token/HypnativeMovable.t.sol +++ b/solidity/test/token/HypnativeMovable.t.sol @@ -3,13 +3,14 @@ pragma solidity ^0.8.13; import {ITokenBridge, Quote} from "contracts/interfaces/ITokenBridge.sol"; import {HypNative} from "contracts/token/HypNative.sol"; +import {MockITokenBridge} from "./MovableCollateralRouter.t.sol"; import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; import {MockMailbox} from "contracts/mock/MockMailbox.sol"; import "forge-std/Test.sol"; -contract MockTokenBridgeEth is ITokenBridge { +contract MockITokenBridgeEth is ITokenBridge { constructor() {} function transferRemote( @@ -31,7 +32,7 @@ contract MockTokenBridgeEth is ITokenBridge { contract HypNativeMovableTest is Test { HypNative internal router; - MockTokenBridgeEth internal vtb; + MockITokenBridgeEth internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); @@ -45,7 +46,7 @@ contract HypNativeMovableTest is Test { destinationDomain, bytes32(uint256(uint160(0))) ); - vtb = new MockTokenBridgeEth(); + vtb = new MockITokenBridgeEth(); } function testMovingCollateral() public { diff --git a/solidity/test/token/MovableCollateralRouter.t.sol b/solidity/test/token/MovableCollateralRouter.t.sol index ebcd6ef359..aa7b9e1d01 100644 --- a/solidity/test/token/MovableCollateralRouter.t.sol +++ b/solidity/test/token/MovableCollateralRouter.t.sol @@ -15,21 +15,19 @@ import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; contract MockMovableCollateralRouter is MovableCollateralRouter { constructor(address _mailbox) FungibleTokenRouter(1, _mailbox) {} + function token() public view override returns (address) { + return address(0); + } + function balanceOf( address _account ) external view override returns (uint256) { return 0; } - function _transferFromSender( - uint256 _amount - ) internal override returns (bytes memory) {} + function _transferFromSender(uint256 _amount) internal override {} - function _transferTo( - address _to, - uint256 _amount, - bytes calldata _metadata - ) internal override {} + function _transferTo(address _to, uint256 _amount) internal override {} function _handle( uint32 _origin, @@ -38,7 +36,7 @@ contract MockMovableCollateralRouter is MovableCollateralRouter { ) internal override {} } -contract MockTokenBridge is ITokenBridge { +contract MockITokenBridge is ITokenBridge { ERC20Test token; bytes32 public myRecipient; @@ -69,7 +67,7 @@ contract MovableCollateralRouterTest is Test { using TypeCasts for address; MovableCollateralRouter internal router; - MockTokenBridge internal vtb; + MockITokenBridge internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); @@ -80,7 +78,7 @@ contract MovableCollateralRouterTest is Test { mailbox = new MockMailbox(1); router = new MockMovableCollateralRouter(address(mailbox)); token = new ERC20Test("Foo Token", "FT", 1_000_000e18, 18); - vtb = new MockTokenBridge(token); + vtb = new MockITokenBridge(token); remote = vm.addr(10); diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts index c70abd3081..31ce7622db 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts @@ -247,14 +247,14 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader { const contractTypes: Partial< Record > = { + [TokenType.collateralVault]: { + factory: HypERC4626OwnerCollateral__factory, + method: 'assetDeposited', + }, [TokenType.collateralVaultRebase]: { factory: HypERC4626Collateral__factory, method: 'NULL_RECIPIENT', }, - [TokenType.collateralVault]: { - factory: HypERC4626OwnerCollateral__factory, - method: 'vault', - }, [TokenType.XERC20Lockbox]: { factory: HypXERC20Lockbox__factory, method: 'lockbox', diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts index bdd38df308..cf597100bb 100644 --- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts @@ -21,11 +21,11 @@ import { HypXERC20Lockbox, HypXERC20Lockbox__factory, HypXERC20__factory, + ITokenBridge__factory, IXERC20, IXERC20VS, IXERC20VS__factory, IXERC20__factory, - ValueTransferBridge__factory, } from '@hyperlane-xyz/core'; import { Address, @@ -404,7 +404,7 @@ export class EvmHypCollateralAdapter ]; } - const bridgeContract = ValueTransferBridge__factory.connect( + const bridgeContract = ITokenBridge__factory.connect( bridge, this.getProvider(), ); From 205bcae75c8582a52d982ff70257763b322646cd Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 7 Jul 2025 18:29:59 -0400 Subject: [PATCH 07/36] fix: rebalance undercollateralization (#6588) --- .changeset/brown-scissors-crash.md | 5 ++ .../contracts/interfaces/ITokenBridge.sol | 4 +- .../contracts/token/HypERC20Collateral.sol | 28 ++++---- solidity/contracts/token/HypNative.sol | 29 +++----- solidity/contracts/token/fees/LinearFee.sol | 4 +- .../token/libs/MovableCollateralRouter.sol | 68 +++++++++++++------ solidity/test/token/HypnativeMovable.t.sol | 41 ++++++++++- .../token/EvmERC20WarpModule.hardhat-test.ts | 17 +++-- 8 files changed, 131 insertions(+), 65 deletions(-) create mode 100644 .changeset/brown-scissors-crash.md diff --git a/.changeset/brown-scissors-crash.md b/.changeset/brown-scissors-crash.md new file mode 100644 index 0000000000..fd06b7bad2 --- /dev/null +++ b/.changeset/brown-scissors-crash.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Rebalancer covers all fees associated with rebalancing diff --git a/solidity/contracts/interfaces/ITokenBridge.sol b/solidity/contracts/interfaces/ITokenBridge.sol index a76a0292ed..34075dd267 100644 --- a/solidity/contracts/interfaces/ITokenBridge.sol +++ b/solidity/contracts/interfaces/ITokenBridge.sol @@ -13,7 +13,9 @@ interface ITokenFee { * @param _recipient The message recipient address on `destination` * @param _amount The amount to send to the recipient * @return quotes Indicate how much of each token to approve and/or send. - * @dev Good practice is to use the first entry of the quotes for the native currency (i.e. ETH) + * @dev Good practice is to use the first entry of the quotes for the native currency (i.e. ETH). + * @dev Good practice is to use the last entry of the quotes for the token to be transferred. + * @dev There should not be duplicate `token` addresses in the returned quotes. */ function quoteTransferRemote( uint32 _destination, diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index d303e5d47a..eb068d4155 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -67,6 +67,19 @@ contract HypERC20Collateral is MovableCollateralRouter { return address(wrappedToken); } + function _addBridge(uint32 domain, ITokenBridge bridge) internal override { + MovableCollateralRouter._addBridge(domain, bridge); + IERC20(wrappedToken).safeApprove(address(bridge), type(uint256).max); + } + + function _removeBridge( + uint32 domain, + ITokenBridge bridge + ) internal override { + MovableCollateralRouter._removeBridge(domain, bridge); + IERC20(wrappedToken).safeApprove(address(bridge), 0); + } + /** * @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract. * @inheritdoc TokenRouter @@ -85,19 +98,4 @@ contract HypERC20Collateral is MovableCollateralRouter { ) internal virtual override { wrappedToken.safeTransfer(_recipient, _amount); } - - function _rebalance( - uint32 domain, - bytes32 recipient, - uint256 amount, - ITokenBridge bridge - ) internal override { - wrappedToken.safeApprove({spender: address(bridge), value: amount}); - MovableCollateralRouter._rebalance({ - domain: domain, - recipient: recipient, - amount: amount, - bridge: bridge - }); - } } diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 34b799558c..5540ca7728 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -75,6 +75,16 @@ contract HypNative is MovableCollateralRouter { require(msg.value >= _amount, "Native: amount exceeds msg.value"); } + function _nativeRebalanceValue( + uint256 collateralAmount + ) internal override returns (uint256 nativeValue) { + nativeValue = msg.value + collateralAmount; + require( + address(this).balance >= nativeValue, + "Native: rebalance amount exceeds balance" + ); + } + /** * @dev Sends `_amount` of native token to `_recipient` balance. * @inheritdoc TokenRouter @@ -102,23 +112,4 @@ contract HypNative is MovableCollateralRouter { receive() external payable { emit Donation(msg.sender, msg.value); } - - /** - * @dev This function uses `msg.value` as payment for the bridge. - * User collateral is never used to make bridge payments! - * The rebalancer is to pay all fees for the bridge. - */ - function _rebalance( - uint32 domain, - bytes32 recipient, - uint256 amount, - ITokenBridge bridge - ) internal override { - uint fee = msg.value + amount; - require( - address(this).balance >= fee, - "Native: rebalance amount exceeds balance" - ); - bridge.transferRemote{value: fee}(domain, recipient, amount); - } } diff --git a/solidity/contracts/token/fees/LinearFee.sol b/solidity/contracts/token/fees/LinearFee.sol index 50b39f8e8b..20e8100716 100644 --- a/solidity/contracts/token/fees/LinearFee.sol +++ b/solidity/contracts/token/fees/LinearFee.sol @@ -26,8 +26,8 @@ contract LinearFee is BaseFee { address _token, uint256 _maxFee, uint256 _halfAmount, - address beneficiary - ) BaseFee(_token, _maxFee, _halfAmount, beneficiary) {} + address _owner + ) BaseFee(_token, _maxFee, _halfAmount, _owner) {} function _quoteTransfer( uint256 amount diff --git a/solidity/contracts/token/libs/MovableCollateralRouter.sol b/solidity/contracts/token/libs/MovableCollateralRouter.sol index 37933e95a7..374e1b1534 100644 --- a/solidity/contracts/token/libs/MovableCollateralRouter.sol +++ b/solidity/contracts/token/libs/MovableCollateralRouter.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0; import {Router} from "../../client/Router.sol"; import {FungibleTokenRouter} from "./FungibleTokenRouter.sol"; -import {ITokenBridge} from "../../interfaces/ITokenBridge.sol"; +import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -69,6 +69,10 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { function addBridge(uint32 domain, ITokenBridge bridge) external onlyOwner { // constrain to a subset of Router.domains() _mustHaveRemoteRouter(domain); + _addBridge(domain, bridge); + } + + function _addBridge(uint32 domain, ITokenBridge bridge) internal virtual { _allowedBridges[domain].add(address(bridge)); } @@ -76,6 +80,13 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { uint32 domain, ITokenBridge bridge ) external onlyOwner { + _removeBridge(domain, bridge); + } + + function _removeBridge( + uint32 domain, + ITokenBridge bridge + ) internal virtual { _allowedBridges[domain].remove(address(bridge)); } @@ -113,20 +124,46 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { uint256 amount, ITokenBridge bridge ) external payable onlyRebalancer onlyAllowedBridge(domain, bridge) { - address rebalancer = _msgSender(); + bytes32 recipient = _recipient(domain); + + Quote[] memory quotes = bridge.quoteTransferRemote( + domain, + recipient, + amount + ); + + if (quotes.length > 0) { + require( + quotes[quotes.length - 1].token == token(), + "MCR: collateral token mismatch" + ); + uint256 collateralFee = quotes[quotes.length - 1].amount; + + // charge the rebalancer any bridging fees denominated in the collateral + // token to avoid undercollateralization + if (collateralFee > amount) { + _transferFromSender(collateralFee - amount); + } + } + + uint256 nativeValue = _nativeRebalanceValue(amount); + bridge.transferRemote{value: nativeValue}(domain, recipient, amount); + emit CollateralMoved(domain, recipient, amount, msg.sender); + } + + function _nativeRebalanceValue( + uint256 /*amount*/ + ) internal virtual returns (uint256 nativeValue) { + return msg.value; + } - bytes32 recipient = allowedRecipient[domain]; + function _recipient( + uint32 domain + ) internal view returns (bytes32 recipient) { + recipient = allowedRecipient[domain]; if (recipient == bytes32(0)) { recipient = _mustHaveRemoteRouter(domain); } - - _rebalance(domain, recipient, amount, bridge); - emit CollateralMoved({ - domain: domain, - recipient: recipient, - amount: amount, - rebalancer: rebalancer - }); } /// @dev This function in `EnumerableSet` was introduced in OpenZeppelin v5. We are using 4.9 @@ -151,13 +188,4 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { _clear(_allowedBridges[domain]._inner); Router._unenrollRemoteRouter(domain); } - - function _rebalance( - uint32 domain, - bytes32 recipient, - uint256 amount, - ITokenBridge bridge - ) internal virtual { - bridge.transferRemote{value: msg.value}(domain, recipient, amount); - } } diff --git a/solidity/test/token/HypnativeMovable.t.sol b/solidity/test/token/HypnativeMovable.t.sol index 84f7d26a9f..dc1b3699fb 100644 --- a/solidity/test/token/HypnativeMovable.t.sol +++ b/solidity/test/token/HypnativeMovable.t.sol @@ -7,6 +7,7 @@ import {MockITokenBridge} from "./MovableCollateralRouter.t.sol"; import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; import {MockMailbox} from "contracts/mock/MockMailbox.sol"; +import {LinearFee} from "contracts/token/fees/LinearFee.sol"; import "forge-std/Test.sol"; @@ -32,21 +33,27 @@ contract MockITokenBridgeEth is ITokenBridge { contract HypNativeMovableTest is Test { HypNative internal router; - MockITokenBridgeEth internal vtb; + HypNative internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; address internal constant alice = address(1); function setUp() public { token = new ERC20Test("Foo Token", "FT", 1_000_000e18, 18); - router = new HypNative(1e18, address(new MockMailbox(uint32(1)))); + address mailbox = address(new MockMailbox(uint32(1))); + MockMailbox(mailbox).addRemoteMailbox( + destinationDomain, + MockMailbox(mailbox) + ); + router = new HypNative(1, mailbox); // Initialize the router -> we are the admin router.initialize(address(0), address(0), address(this)); router.enrollRemoteRouter( destinationDomain, bytes32(uint256(uint160(0))) ); - vtb = new MockITokenBridgeEth(); + vtb = new HypNative(1, mailbox); + vtb.enrollRemoteRouter(destinationDomain, bytes32(uint256(uint160(0)))); } function testMovingCollateral() public { @@ -82,4 +89,32 @@ contract HypNativeMovableTest is Test { vm.expectRevert("Native: rebalance amount exceeds balance"); router.rebalance(destinationDomain, 1 ether, vtb); } + + function test_rebalance_cannotUndercollateralize( + uint96 fee, + uint96 collateralAmount + ) public { + vm.assume(fee > 0); + vm.assume(collateralAmount > 1); + + vtb.setFeeRecipient( + address( + new LinearFee( + address(0), + fee, + collateralAmount / 2, + address(this) + ) + ) + ); + + router.addRebalancer(address(this)); + router.addBridge(destinationDomain, vtb); + + deal(address(router), collateralAmount); + deal(address(this), fee); + + router.rebalance{value: fee}(destinationDomain, collateralAmount, vtb); + assertEq(address(vtb).balance, collateralAmount); + } } diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts index 7738a308b8..030655f9a7 100644 --- a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts @@ -96,6 +96,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { let vaultFactory: ERC4626Test__factory; let vault: ERC4626Test; let token: ERC20Test; + let feeToken: ERC20Test; let signer: SignerWithAddress; let multiProvider: MultiProvider; let coreApp: TestCoreApp; @@ -133,6 +134,12 @@ describe('EvmERC20WarpHyperlaneModule', async () => { TOKEN_DECIMALS, ); + feeToken = await erc20Factory.deploy( + TOKEN_NAME, + TOKEN_NAME, + TOKEN_SUPPLY, + TOKEN_DECIMALS, + ); vaultFactory = new ERC4626Test__factory(signer); vault = await vaultFactory.deploy(token.address, TOKEN_NAME, TOKEN_NAME); @@ -953,7 +960,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { [domainId]: [ { bridge: allowedBridgeToAdd, - approvedTokens: [token.address], + approvedTokens: [feeToken.address], }, ], }, @@ -972,7 +979,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { await warpTokenInstance.callStatic.allowedBridges(domainId); expect(check[0]).to.eql(allowedBridgeToAdd); - const allowance = await token.callStatic.allowance( + const allowance = await feeToken.callStatic.allowance( evmERC20WarpModule.serialize().deployedTokenRoute, allowedBridgeToAdd, ); @@ -993,7 +1000,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { [domainId]: [ { bridge: allowedBridgeToAdd, - approvedTokens: [token.address], + approvedTokens: [feeToken.address], }, ], }, @@ -1045,7 +1052,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { [domainId]: [ { bridge: allowedBridgeToAdd, - approvedTokens: [token.address], + approvedTokens: [feeToken.address], }, ], }, @@ -1065,7 +1072,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { [domainId]: [ { bridge: allowedBridgeToAdd.toLowerCase(), - approvedTokens: [token.address], + approvedTokens: [feeToken.address], }, ], }, From f4786850a4c3267b2b4b5685e6be6242dff9c168 Mon Sep 17 00:00:00 2001 From: larryob Date: Tue, 8 Jul 2025 08:03:33 -0400 Subject: [PATCH 08/36] feat: Add Everclear TokenBridge (#6625) Co-authored-by: Yorke Rhodes --- .../interfaces/IEverclearAdapter.sol | 96 +++ .../token/bridge/EverclearTokenBridge.sol | 225 ++++++ solidity/contracts/token/interfaces/IWETH.sol | 9 + solidity/foundry.toml | 1 + solidity/script/EverclearTokenBridge.s.sol | 80 ++ .../test/token/EverclearTokenBridge.t.sol | 681 ++++++++++++++++++ 6 files changed, 1092 insertions(+) create mode 100644 solidity/contracts/interfaces/IEverclearAdapter.sol create mode 100644 solidity/contracts/token/bridge/EverclearTokenBridge.sol create mode 100644 solidity/contracts/token/interfaces/IWETH.sol create mode 100644 solidity/script/EverclearTokenBridge.s.sol create mode 100644 solidity/test/token/EverclearTokenBridge.t.sol diff --git a/solidity/contracts/interfaces/IEverclearAdapter.sol b/solidity/contracts/interfaces/IEverclearAdapter.sol new file mode 100644 index 0000000000..f30d7b1316 --- /dev/null +++ b/solidity/contracts/interfaces/IEverclearAdapter.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.22; + +// Taken from https://github.com/everclearorg/monorepo/blob/7651d2aa1d4909b35b5cb0829dea47eee1c2595a/packages/contracts/src/interfaces/intent/IFeeAdapter.sol#L1 +interface IEverclear { + /** + * @notice The structure of an intent + * @param initiator The address of the intent initiator + * @param receiver The address of the intent receiver + * @param inputAsset The address of the intent asset on origin + * @param outputAsset The address of the intent asset on destination + * @param maxFee The maximum fee that can be taken by solvers + * @param origin The origin chain of the intent + * @param destinations The possible destination chains of the intent + * @param nonce The nonce of the intent + * @param timestamp The timestamp of the intent + * @param ttl The time to live of the intent + * @param amount The amount of the intent asset normalized to 18 decimals + * @param data The data of the intent + */ + struct Intent { + bytes32 initiator; + bytes32 receiver; + bytes32 inputAsset; + bytes32 outputAsset; + uint24 maxFee; + uint32 origin; + uint64 nonce; + uint48 timestamp; + uint48 ttl; + uint256 amount; + uint32[] destinations; + bytes data; + } +} +interface IEverclearAdapter { + struct FeeParams { + uint256 fee; + uint256 deadline; + bytes sig; + } + /** + * @notice Emitted when a new intent is created with fees + * @param _intentId The ID of the created intent + * @param _initiator The address of the user who initiated the intent + * @param _tokenFee The amount of token fees paid + * @param _nativeFee The amount of native token fees paid + */ + event IntentWithFeesAdded( + bytes32 indexed _intentId, + bytes32 indexed _initiator, + uint256 _tokenFee, + uint256 _nativeFee + ); + + /** + * @notice Creates a new intent with fees + * @param _destinations Array of destination domains, preference ordered + * @param _receiver Address of the receiver on the destination chain + * @param _inputAsset Address of the input asset + * @param _outputAsset Address of the output asset + * @param _amount Amount of input asset to use for the intent + * @param _maxFee Maximum fee percentage allowed for the intent + * @param _ttl Time-to-live for the intent in seconds + * @param _data Additional data for the intent + * @param _feeParams Fee parameters including fee amount, deadline, and signature + * @return _intentId The ID of the created intent + * @return _intent The created intent object + */ + function newIntent( + uint32[] memory _destinations, + bytes32 _receiver, + address _inputAsset, + bytes32 _outputAsset, + uint256 _amount, + uint24 _maxFee, + uint48 _ttl, + bytes calldata _data, + FeeParams calldata _feeParams + ) external payable returns (bytes32, IEverclear.Intent memory); + + /** + * @notice Returns the current fee signer address + * @return The address whos signature is verified + */ + function feeSigner() external view returns (address); + + /** + * @notice Updates the fee signer address + * @dev Can only be called by the owner of the contract + * @param _feeSigner The new address that will sign for fees + */ + function updateFeeSigner(address _feeSigner) external; + + function owner() external view returns (address); +} diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol new file mode 100644 index 0000000000..d800168bd7 --- /dev/null +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.22; + +import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol"; +import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +import {IEverclearAdapter} from "../../interfaces/IEverclearAdapter.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {PackageVersioned} from "../../PackageVersioned.sol"; + +/** + * @notice Information about an output asset for a destination domain + * @param destination The destination domain ID + * @param outputAsset The output asset address on the destination chain + */ +struct OutputAssetInfo { + uint32 destination; + bytes32 outputAsset; +} + +/** + * @title EverclearTokenBridge + * @author Hyperlane Team + * @notice A token bridge that integrates with Everclear's intent-based architecture + * @dev Extends HypERC20Collateral to provide cross-chain token transfers via Everclear's intent system + */ +contract EverclearTokenBridge is + ITokenBridge, + OwnableUpgradeable, + PackageVersioned +{ + using SafeERC20 for IERC20; + + /// @notice The output asset for a given destination domain + /// @dev Everclear needs to know the output asset address to create intents for cross-chain transfers + mapping(uint32 destination => bytes32 outputAssets) public outputAssets; + + /// @notice Fee parameters for the bridge operations + /// @dev The signatures are produced by Everclear and stored here for re-use. We use the same fee for all transfers to all destinations + IEverclearAdapter.FeeParams public feeParams; + + /// @notice The Everclear adapter contract interface + /// @dev Immutable reference to the Everclear adapter used for creating intents + IEverclearAdapter public immutable everclearAdapter; + + /** + * @notice Emitted when fee parameters are updated + * @param fee The new fee amount + * @param deadline The new deadline timestamp for fee validity + */ + event FeeParamsUpdated(uint256 fee, uint256 deadline); + + /** + * @notice Emitted when an output asset is configured for a destination + * @param destination The destination domain ID + * @param outputAsset The output asset address on the destination chain + */ + event OutputAssetSet(uint32 destination, bytes32 outputAsset); + + IERC20 public immutable token; + + /** + * @notice Constructor to initialize the Everclear token bridge + * @param _erc20 The address of the ERC20 token to be used as collateral + * @param _everclearAdapter The address of the Everclear adapter contract + */ + constructor(IERC20 _erc20, IEverclearAdapter _everclearAdapter) { + token = _erc20; + everclearAdapter = _everclearAdapter; + } + + /** + * @notice Initializes the proxy contract. + * @dev Approves the Everclear adapter to spend tokens + */ + function initialize(address _owner) public initializer { + __Ownable_init(); + _transferOwnership(_owner); + token.approve(address(everclearAdapter), type(uint256).max); + } + + /** + * @notice Sets the fee parameters for Everclear bridge operations + * @dev Only callable by the contract owner + * @param _fee The fee amount to charge users for bridge operations + * @param _deadline The deadline timestamp for fee parameter validity + * @param _sig The signature for fee validation from Everclear + */ + function setFeeParams( + uint256 _fee, + uint256 _deadline, + bytes calldata _sig + ) external onlyOwner { + feeParams = IEverclearAdapter.FeeParams({ + fee: _fee, + deadline: _deadline, + sig: _sig + }); + emit FeeParamsUpdated(_fee, _deadline); + } + + function _setOutputAsset( + OutputAssetInfo calldata _outputAssetInfo + ) internal { + uint32 destination = _outputAssetInfo.destination; + bytes32 outputAsset = _outputAssetInfo.outputAsset; + outputAssets[destination] = outputAsset; + emit OutputAssetSet(destination, outputAsset); + } + + /** + * @notice Sets the output asset address for a destination domain + * @dev Only callable by the contract owner + * @param _outputAssetInfo The output asset information for the destination domain + */ + function setOutputAsset( + OutputAssetInfo calldata _outputAssetInfo + ) external onlyOwner { + _setOutputAsset(_outputAssetInfo); + } + + /** + * @notice Sets multiple output assets in a single transaction for gas efficiency + * @dev Only callable by the contract owner. Arrays must be the same length + * @param _outputAssetInfos Array of output asset information for the destination domains + */ + function setOutputAssetsBatch( + OutputAssetInfo[] calldata _outputAssetInfos + ) external onlyOwner { + uint256 len = _outputAssetInfos.length; + + for (uint256 i = 0; i < len; ++i) { + OutputAssetInfo calldata _outputAssetInfo = _outputAssetInfos[i]; + _setOutputAsset(_outputAssetInfo); + } + } + + /** + * @notice Provides a quote for transferring tokens to a remote chain + * @dev Returns the gas payment quote and the total token amount needed (including fees) + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of tokens to transfer + * @return quotes Array of quotes containing gas payment and token amount requirements + */ + function quoteTransferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) public view override returns (Quote[] memory quotes) { + _destination; // Keep this to avoid solc's documentation warning (3881) + _recipient; + + quotes = new Quote[](1); + quotes[0] = Quote({ + token: address(token), + amount: _amount + feeParams.fee + }); + } + + /** + * @notice Transfers tokens to a remote chain via Everclear's intent system + * @dev Creates an Everclear intent for cross-chain transfer. The actual Hyperlane message is sent by Everclear + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of tokens to transfer + * @return bytes32(0) as the transfer ID (actual ID is managed by Everclear) + */ + function transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) external payable override returns (bytes32) { + IEverclearAdapter.FeeParams memory _feeParams = feeParams; + + // Charge sender the stored fee + token.safeTransferFrom({ + from: msg.sender, + to: address(this), + value: _amount + _feeParams.fee + }); + + // Create everclear intent + _createIntent(_destination, _recipient, _amount, _feeParams); + + // A hyperlane message will be sent by everclear internally + // in a separate transaction. See `EverclearSpokeV3.processIntentQueue`. + return bytes32(0); + } + + /** + * @notice Creates an Everclear intent for cross-chain token transfer + * @dev Internal function to handle intent creation with Everclear adapter + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of tokens to transfer + * @param _feeParams The fee parameters for the intent + */ + function _createIntent( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + IEverclearAdapter.FeeParams memory _feeParams + ) internal { + bytes32 outputAsset = outputAssets[_destination]; + require(outputAsset != bytes32(0), "ETB: Output asset not set"); + + // Create everclear intent + uint32[] memory destinations = new uint32[](1); + destinations[0] = _destination; + + everclearAdapter.newIntent({ + _destinations: destinations, + _receiver: _recipient, + _inputAsset: address(token), + _outputAsset: outputAsset, + _amount: _amount, + _maxFee: 0, + _ttl: 0, + _data: "", + _feeParams: _feeParams + }); + } +} diff --git a/solidity/contracts/token/interfaces/IWETH.sol b/solidity/contracts/token/interfaces/IWETH.sol new file mode 100644 index 0000000000..5ba2a4fa17 --- /dev/null +++ b/solidity/contracts/token/interfaces/IWETH.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.22; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IWETH is IERC20 { + function deposit() external payable; + function withdraw(uint256 amount) external; +} diff --git a/solidity/foundry.toml b/solidity/foundry.toml index 12341fcdef..c276b9423c 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -28,6 +28,7 @@ verbosity = 4 mainnet = "https://eth.merkle.io" optimism = "https://mainnet.optimism.io " polygon = "https://rpc-mainnet.matic.quiknode.pro" +arbitrum = "https://arb1.arbitrum.io/rpc" base = "https://mainnet.base.org" [fuzz] diff --git a/solidity/script/EverclearTokenBridge.s.sol b/solidity/script/EverclearTokenBridge.s.sol new file mode 100644 index 0000000000..679ec00a8a --- /dev/null +++ b/solidity/script/EverclearTokenBridge.s.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.22; + +import {EverclearTokenBridge, IEverclearAdapter, OutputAssetInfo} from "contracts/token/bridge/EverclearTokenBridge.sol"; +import {TypeCasts} from "contracts/libs/TypeCasts.sol"; +import {IWETH} from "contracts/token/interfaces/IWETH.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "forge-std/Script.sol"; + +contract EverclearTokenBridgeScript is Script { + using TypeCasts for address; + + function run() public { + address deployer = _getDeployer(); + vm.startBroadcast(deployer); + + // Deploy the bridge. This is an ARB eth bridge. + EverclearTokenBridge bridge = new EverclearTokenBridge( + IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1), // WETH + IEverclearAdapter(0x15a7cA97D1ed168fB34a4055CEFa2E2f9Bdb6C75) // Everclear adapter + ); + + // Initialize the bridge + bridge.initialize(deployer); + + // Set the output asset for the bridge. + // This is optimism weth + bridge.setOutputAsset( + OutputAssetInfo({ + destination: 10, + outputAsset: (0x4200000000000000000000000000000000000006) + .addressToBytes32() + }) + ); + + // Set the fee params for the bridge. + bridge.setFeeParams( + 1000000000000, + 1751851366, + hex"4edddfdeabc459e3e9df4bc6807698e26443a663b3905c9b5d0f1054b4831b4616e89ff702f57e13d650331f11986ebe925ce497621b7f488c4672189b49b8e11c" + ); + + vm.stopBroadcast(); + } + + function depositEth() public { + EverclearTokenBridge bridge = _getBridge(); + + // Convert some eth to weth + (uint256 fee, , ) = bridge.feeParams(); + uint256 amount = 0.0001 ether; + uint256 totalAmount = amount + fee + 1; + IWETH weth = IWETH(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1); + weth.approve(address(bridge), type(uint256).max); + weth.deposit{value: totalAmount}(); + } + + function sendIntent() public { + address deployer = _getDeployer(); + vm.startBroadcast(deployer); + + EverclearTokenBridge bridge = _getBridge(); + + depositEth(); + + // Send a test intent + bridge.transferRemote(10, deployer.addressToBytes32(), 0.0001 ether); + + vm.stopBroadcast(); + } + + function _getDeployer() internal returns (address) { + return vm.rememberKey(vm.envUint("PRIVATE_KEY")); + } + + function _getBridge() internal returns (EverclearTokenBridge) { + return EverclearTokenBridge(0x02457BB8994C192F14d46568461E11723d169dB8); + } +} diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol new file mode 100644 index 0000000000..772015ce20 --- /dev/null +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -0,0 +1,681 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.22; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +import "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; +import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; +import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol"; +import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; + +import {EverclearTokenBridge, OutputAssetInfo} from "../../contracts/token/bridge/EverclearTokenBridge.sol"; +import {IEverclearAdapter, IEverclear} from "../../contracts/interfaces/IEverclearAdapter.sol"; +import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; +import {IWETH} from "contracts/token/interfaces/IWETH.sol"; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +/** + * @notice Mock implementation of IEverclearAdapter for testing + */ +contract MockEverclearAdapter is IEverclearAdapter { + uint256 public constant INTENT_FEE = 1000; // 0.001 ETH + bool public shouldRevert = false; + bytes32 public lastIntentId; + IEverclear.Intent public lastIntent; + + // Track calls for verification + uint256 public newIntentCallCount; + uint32[] public lastDestinations; + bytes32 public lastReceiver; + address public lastInputAsset; + bytes32 public lastOutputAsset; + uint256 public lastAmount; + uint24 public lastMaxFee; + uint48 public lastTtl; + bytes public lastData; + FeeParams public lastFeeParams; + + function setRevert(bool _shouldRevert) external { + shouldRevert = _shouldRevert; + } + + function newIntent( + uint32[] memory _destinations, + bytes32 _receiver, + address _inputAsset, + bytes32 _outputAsset, + uint256 _amount, + uint24 _maxFee, + uint48 _ttl, + bytes calldata _data, + FeeParams calldata _feeParams + ) external payable override returns (bytes32, IEverclear.Intent memory) { + if (shouldRevert) { + revert("MockEverclearAdapter: reverted"); + } + + // Store call data for verification + newIntentCallCount++; + lastDestinations = _destinations; + lastReceiver = _receiver; + lastInputAsset = _inputAsset; + lastOutputAsset = _outputAsset; + lastAmount = _amount; + lastMaxFee = _maxFee; + lastTtl = _ttl; + lastData = _data; + lastFeeParams = _feeParams; + + // Generate mock intent ID + lastIntentId = keccak256( + abi.encodePacked(block.timestamp, _receiver, _amount) + ); + + // Create mock intent + lastIntent = IEverclear.Intent({ + initiator: bytes32(uint256(uint160(msg.sender))), + receiver: _receiver, + inputAsset: bytes32(uint256(uint160(_inputAsset))), + outputAsset: _outputAsset, + maxFee: _maxFee, + origin: uint32(block.chainid), + destinations: _destinations, + nonce: uint64(newIntentCallCount), + timestamp: uint48(block.timestamp), + ttl: _ttl, + amount: _amount, + data: _data + }); + + return (lastIntentId, lastIntent); + } + + function feeSigner() external view returns (address) { + return address(0x222); + } + + function owner() external view returns (address) { + return address(0x1); + } + + function updateFeeSigner(address _feeSigner) external { + // Do nothing + } +} + +contract EverclearTokenBridgeTest is Test { + using TypeCasts for address; + + // Constants + uint32 internal constant ORIGIN = 11; + uint32 internal constant DESTINATION = 12; + uint8 internal constant DECIMALS = 18; + uint256 internal constant SCALE = 1e18; + uint256 internal constant TOTAL_SUPPLY = 1_000_000e18; + uint256 internal constant TRANSFER_AMT = 100e18; + uint256 internal constant FEE_AMOUNT = 5e18; // 5 tokens fee + uint256 internal constant GAS_PAYMENT = 0.001 ether; + string internal constant NAME = "TestToken"; + string internal constant SYMBOL = "TT"; + + // Test addresses + address internal ALICE = makeAddr("alice"); + address internal constant BOB = address(0x2); + address internal constant OWNER = address(0x3); + address internal constant PROXY_ADMIN = address(0x37); + + // Mock contracts + ERC20Test internal token; + MockMailbox internal mailbox; + MockEverclearAdapter internal everclearAdapter; + TestPostDispatchHook internal hook; + + // Main contract + EverclearTokenBridge internal bridge; + + // Test data + bytes32 internal constant OUTPUT_ASSET = bytes32(uint256(0x456)); + bytes32 internal constant RECIPIENT = bytes32(uint256(uint160(BOB))); + uint256 internal feeDeadline; + bytes internal feeSignature = hex"1234567890abcdef"; + + // Events to test + event FeeParamsUpdated(uint256 fee, uint256 deadline); + event OutputAssetSet(uint32 destination, bytes32 outputAsset); + + function setUp() public { + // Setup basic infrastructure + mailbox = new MockMailbox(ORIGIN); + token = new ERC20Test(NAME, SYMBOL, TOTAL_SUPPLY, DECIMALS); + everclearAdapter = new MockEverclearAdapter(); + hook = new TestPostDispatchHook(); + + // Set fee deadline to future + feeDeadline = block.timestamp + 3600; // 1 hour from now + + // Deploy bridge implementation + EverclearTokenBridge implementation = new EverclearTokenBridge( + token, + everclearAdapter + ); + + // Deploy proxy + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN, + abi.encodeWithSelector( + EverclearTokenBridge.initialize.selector, + OWNER + ) + ); + + bridge = EverclearTokenBridge(address(proxy)); + // Setup initial state + vm.startPrank(OWNER); + bridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + bridge.setOutputAsset( + OutputAssetInfo({ + destination: DESTINATION, + outputAsset: OUTPUT_ASSET + }) + ); + + vm.stopPrank(); + + // Mint tokens to users + token.mintTo(ALICE, 1000e18); + + // Setup allowances + vm.prank(ALICE); + token.approve(address(bridge), type(uint256).max); + } + + // ============ Constructor Tests ============ + + function testConstructor() public { + EverclearTokenBridge newBridge = new EverclearTokenBridge( + token, + everclearAdapter + ); + + assertEq(address(newBridge.token()), address(token)); + assertEq( + address(newBridge.everclearAdapter()), + address(everclearAdapter) + ); + } + + // ============ Initialize Tests ============ + + function testInitialize() public { + assertEq(bridge.owner(), OWNER); + assertEq( + token.allowance(address(bridge), address(everclearAdapter)), + type(uint256).max + ); + } + + function testInitializeCannotBeCalledTwice() public { + vm.expectRevert("Initializable: contract is already initialized"); + bridge.initialize(OWNER); + } + + // ============ setFeeParams Tests ============ + + function testSetFeeParams() public { + uint256 newFee = 10e18; + uint256 newDeadline = block.timestamp + 7200; + bytes memory newSig = hex"abcdef"; + + vm.expectEmit(true, true, false, true); + emit FeeParamsUpdated(newFee, newDeadline); + + vm.prank(OWNER); + bridge.setFeeParams(newFee, newDeadline, newSig); + + (uint256 fee, uint256 deadline, bytes memory sig) = bridge.feeParams(); + assertEq(fee, newFee); + assertEq(deadline, newDeadline); + assertEq(sig, newSig); + } + + function testSetFeeParamsOnlyOwner() public { + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(ALICE); + bridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + } + + // ============ setOutputAsset Tests ============ + + function testSetOutputAsset() public { + bytes32 newOutputAsset = bytes32(uint256(0x789)); + + vm.expectEmit(true, true, false, true); + emit OutputAssetSet(DESTINATION, newOutputAsset); + + vm.prank(OWNER); + bridge.setOutputAsset( + OutputAssetInfo({ + destination: DESTINATION, + outputAsset: newOutputAsset + }) + ); + + assertEq(bridge.outputAssets(DESTINATION), newOutputAsset); + } + + function testSetOutputAssetOnlyOwner() public { + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(ALICE); + bridge.setOutputAsset( + OutputAssetInfo({ + destination: DESTINATION, + outputAsset: OUTPUT_ASSET + }) + ); + } + + // ============ setOutputAssetsBatch Tests ============ + + function testSetOutputAssetsBatch() public { + OutputAssetInfo[] memory outputAssetInfos = new OutputAssetInfo[](2); + outputAssetInfos[0] = OutputAssetInfo({ + destination: 13, + outputAsset: bytes32(uint256(0x111)) + }); + outputAssetInfos[1] = OutputAssetInfo({ + destination: 14, + outputAsset: bytes32(uint256(0x222)) + }); + + vm.expectEmit(true, true, false, true); + emit OutputAssetSet(13, outputAssetInfos[0].outputAsset); + vm.expectEmit(true, true, false, true); + emit OutputAssetSet(14, outputAssetInfos[1].outputAsset); + + vm.prank(OWNER); + bridge.setOutputAssetsBatch(outputAssetInfos); + + assertEq(bridge.outputAssets(13), outputAssetInfos[0].outputAsset); + assertEq(bridge.outputAssets(14), outputAssetInfos[1].outputAsset); + } + + function testSetOutputAssetsBatchOnlyOwner() public { + OutputAssetInfo[] memory outputAssetInfos = new OutputAssetInfo[](1); + outputAssetInfos[0] = OutputAssetInfo({ + destination: 13, + outputAsset: bytes32(uint256(0x111)) + }); + + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(ALICE); + bridge.setOutputAssetsBatch(outputAssetInfos); + } + + // ============ quoteTransferRemote Tests ============ + + function testQuoteTransferRemote() public { + Quote[] memory quotes = bridge.quoteTransferRemote( + DESTINATION, + RECIPIENT, + TRANSFER_AMT + ); + + assertEq(quotes.length, 1); + assertEq(quotes[0].token, address(token)); + assertEq(quotes[0].amount, TRANSFER_AMT + FEE_AMOUNT); + } + + // ============ transferRemote Tests ============ + + function testTransferRemote() public { + uint256 initialBalance = token.balanceOf(ALICE); + uint256 initialBridgeBalance = token.balanceOf(address(bridge)); + + vm.prank(ALICE); + bytes32 result = bridge.transferRemote( + DESTINATION, + RECIPIENT, + TRANSFER_AMT + ); + + // Check return value + assertEq(result, bytes32(0)); + + // Check balances + assertEq( + token.balanceOf(ALICE), + initialBalance - TRANSFER_AMT - FEE_AMOUNT + ); + assertEq( + token.balanceOf(address(bridge)), + initialBridgeBalance + TRANSFER_AMT + FEE_AMOUNT + ); + + // Check Everclear adapter was called correctly + assertEq(everclearAdapter.newIntentCallCount(), 1); + assertEq(everclearAdapter.lastDestinations(0), DESTINATION); + assertEq(everclearAdapter.lastReceiver(), RECIPIENT); + assertEq(everclearAdapter.lastInputAsset(), address(token)); + assertEq(everclearAdapter.lastOutputAsset(), OUTPUT_ASSET); + assertEq(everclearAdapter.lastAmount(), TRANSFER_AMT); + assertEq(everclearAdapter.lastMaxFee(), 0); + assertEq(everclearAdapter.lastTtl(), 0); + assertEq(everclearAdapter.lastData(), ""); + + // Check fee params + (uint256 fee, uint256 deadline, bytes memory sig) = everclearAdapter + .lastFeeParams(); + assertEq(fee, FEE_AMOUNT); + assertEq(deadline, feeDeadline); + assertEq(sig, feeSignature); + } + + function testTransferRemoteOutputAssetNotSet() public { + vm.expectRevert("ETB: Output asset not set"); + vm.prank(ALICE); + bridge.transferRemote(999, RECIPIENT, TRANSFER_AMT); // Domain 999 has no output asset + } + + function testTransferRemoteInsufficientBalance() public { + // Try to transfer more than balance + fee + uint256 aliceBalance = token.balanceOf(ALICE); + + vm.expectRevert("ERC20: transfer amount exceeds balance"); + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, aliceBalance); + } + + function testTransferRemoteInsufficientAllowance() public { + vm.prank(ALICE); + token.approve(address(bridge), TRANSFER_AMT); // Less than transfer + fee + + vm.expectRevert("ERC20: insufficient allowance"); + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT); + } + + function testTransferRemoteEverclearAdapterReverts() public { + everclearAdapter.setRevert(true); + + vm.expectRevert("MockEverclearAdapter: reverted"); + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT); + } + + // ============ Edge Cases Tests ============ + + function testTransferRemoteZeroAmount() public { + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, 0); + + // Should still charge fee + assertEq(everclearAdapter.lastAmount(), 0); + // Fee should still be deducted + assertEq(token.balanceOf(ALICE), 1000e18 - FEE_AMOUNT); + } + + function testTransferRemoteMaxAmount() public { + uint256 maxAmount = token.balanceOf(ALICE) - FEE_AMOUNT; + + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, maxAmount); + + assertEq(everclearAdapter.lastAmount(), maxAmount); + assertEq(token.balanceOf(ALICE), 0); + } + + // ============ Fuzz Tests ============ + + function testFuzzTransferRemote(uint256 amount) public { + // Bound the amount to reasonable values + amount = bound(amount, 0, 500e18); // Max 500 tokens + + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, amount); + + assertEq(everclearAdapter.lastAmount(), amount); + assertEq(token.balanceOf(ALICE), 1000e18 - amount - FEE_AMOUNT); + } + + function testFuzzSetFeeParams(uint256 fee, uint256 deadline) public { + // Bound to reasonable values + fee = bound(fee, 0, 100e18); + deadline = bound( + deadline, + block.timestamp + 1, + block.timestamp + 365 days + ); + + vm.prank(OWNER); + bridge.setFeeParams(fee, deadline, feeSignature); + + (uint256 storedFee, uint256 storedDeadline, ) = bridge.feeParams(); + assertEq(storedFee, fee); + assertEq(storedDeadline, deadline); + } + + // ============ Integration Tests ============ + + function testFullTransferFlow() public { + // Setup: Alice wants to transfer 100 tokens to Bob on destination chain + uint256 transferAmount = 100e18; + uint256 initialAliceBalance = token.balanceOf(ALICE); + + // 1. Get quote + Quote[] memory quotes = bridge.quoteTransferRemote( + DESTINATION, + RECIPIENT, + transferAmount + ); + uint256 totalCost = quotes[0].amount; // Token cost including fee + + // 2. Execute transfer + vm.prank(ALICE); + bytes32 transferId = bridge.transferRemote( + DESTINATION, + RECIPIENT, + transferAmount + ); + + // 3. Verify state changes + assertEq(transferId, bytes32(0)); // Everclear manages the actual ID + assertEq(token.balanceOf(ALICE), initialAliceBalance - totalCost); + + // 4. Verify Everclear intent was created correctly + assertEq(everclearAdapter.newIntentCallCount(), 1); + assertEq(everclearAdapter.lastAmount(), transferAmount); + assertEq(everclearAdapter.lastReceiver(), RECIPIENT); + assertEq(everclearAdapter.lastOutputAsset(), OUTPUT_ASSET); + } + + function testMultipleTransfers() public { + uint256 transferAmount = 50e18; + + // Execute multiple transfers + vm.startPrank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, transferAmount); + bridge.transferRemote(DESTINATION, RECIPIENT, transferAmount); + vm.stopPrank(); + + // Verify both transfers were processed + assertEq(everclearAdapter.newIntentCallCount(), 2); + assertEq( + token.balanceOf(ALICE), + 1000e18 - 2 * (transferAmount + FEE_AMOUNT) + ); + } + + // ============ Gas Optimization Tests ============ + + function testGasUsageTransferRemote() public { + vm.prank(ALICE); + uint256 gasBefore = gasleft(); + bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT); + uint256 gasUsed = gasBefore - gasleft(); + + // Log gas usage for analysis (adjust threshold as needed) + emit log_named_uint("Gas used for transferRemote", gasUsed); + assertTrue(gasUsed < 600000); // Reasonable gas limit (adjusted based on actual usage) + } +} + +/** + * @notice Fork test contract for EverclearTokenBridge on Arbitrum + * @dev Tests the bridge using real Arbitrum state and contracts with WETH transfers to Optimism + * @dev We're running the cancun evm version, to avoid `NotActivated` errors + * forge-config: default.evm_version = "cancun" + */ +contract EverclearTokenBridgeForkTest is Test { + using TypeCasts for address; + + // Arbitrum mainnet constants + uint32 internal constant ARBITRUM_DOMAIN = 42161; + uint32 internal constant OPTIMISM_DOMAIN = 10; // Optimism destination + + // Real Arbitrum addresses + address internal constant ARBITRUM_WETH = + 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; + address internal constant EVERCLEAR_ADAPTER = + 0x15a7cA97D1ed168fB34a4055CEFa2E2f9Bdb6C75; + + // Optimism WETH address (for output asset) + address internal constant OPTIMISM_WETH = + 0x4200000000000000000000000000000000000006; + + // Test constants + uint256 internal constant FEE_AMOUNT = 1e16; // 0.01 WETH fee + + // Test addresses + address internal ALICE = makeAddr("alice"); + address internal constant BOB = address(0x2); + address internal constant OWNER = address(0x3); + address internal constant PROXY_ADMIN = address(0x37); + + // Contracts + IWETH internal weth; + IEverclearAdapter internal everclearAdapter; + EverclearTokenBridge internal bridge; + + // Test data + bytes32 internal constant OUTPUT_ASSET = + bytes32(uint256(uint160(OPTIMISM_WETH))); + bytes32 internal constant RECIPIENT = bytes32(uint256(uint160(BOB))); + uint256 internal feeDeadline; + address internal feeSigner; + bytes internal feeSignature = hex"123f"; // We will create a real signature in setUp + + function setUp() public { + // Fork Arbitrum at the latest block + vm.createSelectFork("arbitrum"); + + weth = IWETH(ARBITRUM_WETH); + // Get real Everclear adapter + everclearAdapter = IEverclearAdapter(EVERCLEAR_ADAPTER); + + // Set fee deadline to future + feeDeadline = block.timestamp + 3600; // 1 hour from now + + // Deploy bridge implementation + EverclearTokenBridge implementation = new EverclearTokenBridge( + weth, + everclearAdapter + ); + + // Deploy proxy + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN, + abi.encodeWithSelector( + EverclearTokenBridge.initialize.selector, + OWNER + ) + ); + + bridge = EverclearTokenBridge(address(proxy)); + + // It would be great if we could mock the ecrecover function to always return the fee signer for the adapter + // but we can't do that with forge. So we're going to sign the fee params with the fee signer private key + // and set the fee signature to the signed message. + // This is a bit of a hack, but it's the best we can do for now. + // Change the fee signer on the Everclear adapter + vm.prank(everclearAdapter.owner()); + (address _feeSigner, uint256 _feeSignerPrivateKey) = makeAddrAndKey( + "feeSigner" + ); + feeSigner = _feeSigner; + everclearAdapter.updateFeeSigner(feeSigner); + + bytes32 _hash = keccak256(abi.encode(FEE_AMOUNT, 0, weth, feeDeadline)); + bytes32 _digest = ECDSA.toEthSignedMessageHash(_hash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + _feeSignerPrivateKey, + _digest + ); + feeSignature = abi.encodePacked(r, s, v); + + // Configure the bridge + vm.startPrank(OWNER); + bridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + bridge.setOutputAsset( + OutputAssetInfo({ + destination: OPTIMISM_DOMAIN, + outputAsset: OUTPUT_ASSET + }) + ); + vm.stopPrank(); + + // Setup allowances + vm.prank(ALICE); + weth.approve(address(bridge), type(uint256).max); + } + + function testFuzz_ForkTransferRemote(uint256 amount) public { + // Fund Alice with WETH by wrapping ETH + amount = bound(amount, 1, 100e6 ether); + uint depositAmount = amount + FEE_AMOUNT; + vm.deal(ALICE, depositAmount); + vm.prank(ALICE); + weth.deposit{value: depositAmount}(); + + uint256 initialBalance = weth.balanceOf(ALICE); + uint256 initialBridgeBalance = weth.balanceOf(address(bridge)); + + // Test the transfer - it may succeed or fail depending on adapter state + vm.prank(ALICE); + // We don't want to check _intentId, as it's not used + // It can be found by getting the fetching the spoke from the adapter with `IEverclearAdapter.spoke`, + // fetching the intent queue with `SpokeStorage.intentQueue` + // (see https://github.com/everclearorg/monorepo/blob/2c256760f338ded02dc58c4dee128135aff1d0e9/packages/contracts/src/contracts/intent/SpokeStorage.sol#L81) + // and then calling `intentQueue.queue(intentQueue.last())`. + vm.expectEmit(false, true, true, true); + emit IEverclearAdapter.IntentWithFeesAdded({ + _intentId: bytes32(0), + _initiator: address(bridge).addressToBytes32(), + _tokenFee: FEE_AMOUNT, + _nativeFee: 0 + }); + bridge.transferRemote(OPTIMISM_DOMAIN, RECIPIENT, amount); + + // Verify the balance changes + // Alice should have lost the transfer amount and the fee + assertEq(weth.balanceOf(ALICE), initialBalance - amount - FEE_AMOUNT); + // The bridge forwards all weth to the adapter, so the bridge balance should be the same + assertEq(weth.balanceOf(address(bridge)), initialBridgeBalance); + } +} From dd16e3df4075368bec5389a2401d4d36c0b9c5b0 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 8 Jul 2025 13:26:09 -0400 Subject: [PATCH 09/36] feat: collateral LP interface (#6589) --- .changeset/stale-baboons-hope.md | 9 ++ .../contracts/test/TestLpCollateralRouter.sol | 25 ++++ solidity/contracts/token/HypERC20.sol | 12 -- .../contracts/token/HypERC20Collateral.sol | 10 +- solidity/contracts/token/HypERC721.sol | 12 +- .../contracts/token/HypERC721Collateral.sol | 14 +- solidity/contracts/token/HypNative.sol | 20 +-- .../token/extensions/HypERC721URIStorage.sol | 11 -- .../token/libs/FungibleTokenRouter.sol | 2 - .../token/libs/LpCollateralRouter.sol | 79 +++++++++++ solidity/contracts/token/libs/TokenRouter.sol | 14 +- solidity/test/token/HypERC20.t.sol | 78 ++++++++--- .../HypERC20CollateralVaultDeposit.t.sol | 14 +- solidity/test/token/HypERC4626Test.t.sol | 6 + solidity/test/token/HypERC721.t.sol | 13 +- solidity/test/token/LpCollateralRouter.t.sol | 124 ++++++++++++++++++ .../test/token/MovableCollateralRouter.t.sol | 6 - .../sdk/src/token/EvmERC20WarpRouteReader.ts | 39 +++--- 18 files changed, 359 insertions(+), 129 deletions(-) create mode 100644 .changeset/stale-baboons-hope.md create mode 100644 solidity/contracts/test/TestLpCollateralRouter.sol create mode 100644 solidity/contracts/token/libs/LpCollateralRouter.sol create mode 100644 solidity/test/token/LpCollateralRouter.t.sol diff --git a/.changeset/stale-baboons-hope.md b/.changeset/stale-baboons-hope.md new file mode 100644 index 0000000000..42536d6774 --- /dev/null +++ b/.changeset/stale-baboons-hope.md @@ -0,0 +1,9 @@ +--- +'@hyperlane-xyz/core': major +--- + +Add LP interface to collateral routers + +The `balanceOf` function has been removed from `TokenRouter` to remove ambiguity between `LpCollateralRouter.balanceOf`. + +To migrate, use the new `TokenRouter.token()` to get an `IERC20` or `IERC721` compliant address that you can call `balanceOf` on. diff --git a/solidity/contracts/test/TestLpCollateralRouter.sol b/solidity/contracts/test/TestLpCollateralRouter.sol new file mode 100644 index 0000000000..a6cfa3aa22 --- /dev/null +++ b/solidity/contracts/test/TestLpCollateralRouter.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {LpCollateralRouter} from "../token/libs/LpCollateralRouter.sol"; +import {FungibleTokenRouter} from "../token/libs/FungibleTokenRouter.sol"; + +contract TestLpCollateralRouter is LpCollateralRouter { + constructor( + uint256 _scale, + address _mailbox + ) FungibleTokenRouter(_scale, _mailbox) initializer { + _LpCollateralRouter_initialize(); + } + + function token() public view override returns (address) { + return address(0); + } + + function _transferFromSender(uint256 _amount) internal override {} + + function _transferTo( + address _recipient, + uint256 _amount + ) internal override {} +} diff --git a/solidity/contracts/token/HypERC20.sol b/solidity/contracts/token/HypERC20.sol index ad21a2dea7..a5ccd040ea 100644 --- a/solidity/contracts/token/HypERC20.sol +++ b/solidity/contracts/token/HypERC20.sol @@ -47,18 +47,6 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter { return _decimals; } - function balanceOf( - address _account - ) - public - view - virtual - override(TokenRouter, ERC20Upgradeable) - returns (uint256) - { - return ERC20Upgradeable.balanceOf(_account); - } - function token() public view virtual override returns (address) { return address(this); } diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index eb068d4155..0a537616a6 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -18,6 +18,7 @@ import {TokenMessage} from "./libs/TokenMessage.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; +import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol"; import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol"; // ============ External Imports ============ @@ -31,7 +32,7 @@ import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Cont * @title Hyperlane ERC20 Token Collateral that wraps an existing ERC20 with remote transfer functionality. * @author Abacus Works */ -contract HypERC20Collateral is MovableCollateralRouter { +contract HypERC20Collateral is LpCollateralRouter { using SafeERC20 for IERC20; IERC20 public immutable wrappedToken; @@ -55,12 +56,7 @@ contract HypERC20Collateral is MovableCollateralRouter { address _owner ) public virtual initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); - } - - function balanceOf( - address _account - ) external view virtual override returns (uint256) { - return wrappedToken.balanceOf(_account); + _LpCollateralRouter_initialize(); } function token() public view virtual override returns (address) { diff --git a/solidity/contracts/token/HypERC721.sol b/solidity/contracts/token/HypERC721.sol index b5138796a3..d92aa8d5c8 100644 --- a/solidity/contracts/token/HypERC721.sol +++ b/solidity/contracts/token/HypERC721.sol @@ -38,16 +38,8 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter { } } - function balanceOf( - address _account - ) - public - view - virtual - override(TokenRouter, ERC721Upgradeable, IERC721Upgradeable) - returns (uint256) - { - return ERC721Upgradeable.balanceOf(_account); + function token() public view virtual override returns (address) { + return address(this); } /** diff --git a/solidity/contracts/token/HypERC721Collateral.sol b/solidity/contracts/token/HypERC721Collateral.sol index 946bcf9da3..d7bafcd126 100644 --- a/solidity/contracts/token/HypERC721Collateral.sol +++ b/solidity/contracts/token/HypERC721Collateral.sol @@ -34,18 +34,8 @@ contract HypERC721Collateral is TokenRouter { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } - function ownerOf(uint256 _tokenId) external view returns (address) { - return IERC721(wrappedToken).ownerOf(_tokenId); - } - - /** - * @dev Returns the balance of `_account` for `wrappedToken`. - * @inheritdoc TokenRouter - */ - function balanceOf( - address _account - ) external view override returns (uint256) { - return IERC721(wrappedToken).balanceOf(_account); + function token() public view virtual override returns (address) { + return address(wrappedToken); } /** diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 5540ca7728..76023dc10f 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0; import {TokenRouter} from "./libs/TokenRouter.sol"; import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; -import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; +import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol"; import {Quote, ITokenBridge} from "../interfaces/ITokenBridge.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; @@ -13,17 +13,10 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; * @author Abacus Works * @dev Supply on each chain is not constant but the aggregate supply across all chains is. */ -contract HypNative is MovableCollateralRouter { +contract HypNative is LpCollateralRouter { string internal constant INSUFFICIENT_NATIVE_AMOUNT = "Native: amount exceeds msg.value"; - /** - * @dev Emitted when native tokens are donated to the contract. - * @param sender The address of the sender. - * @param amount The amount of native tokens donated. - */ - event Donation(address indexed sender, uint256 amount); - constructor( uint256 _scale, address _mailbox @@ -41,12 +34,7 @@ contract HypNative is MovableCollateralRouter { address _owner ) public virtual initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); - } - - function balanceOf( - address _account - ) external view override returns (uint256) { - return _account.balance; + _LpCollateralRouter_initialize(); } // override for single unified quote @@ -110,6 +98,6 @@ contract HypNative is MovableCollateralRouter { } receive() external payable { - emit Donation(msg.sender, msg.value); + donate(msg.value); } } diff --git a/solidity/contracts/token/extensions/HypERC721URIStorage.sol b/solidity/contracts/token/extensions/HypERC721URIStorage.sol index 88aa16cfdd..2987d25961 100644 --- a/solidity/contracts/token/extensions/HypERC721URIStorage.sol +++ b/solidity/contracts/token/extensions/HypERC721URIStorage.sol @@ -20,17 +20,6 @@ contract HypERC721URIStorage is HypERC721, ERC721URIStorageUpgradeable { constructor(address _mailbox) HypERC721(_mailbox) {} - function balanceOf( - address account - ) - public - view - override(HypERC721, ERC721Upgradeable, IERC721Upgradeable) - returns (uint256) - { - return HypERC721.balanceOf(account); - } - function _beforeDispatch( uint32 _destination, bytes32 _recipient, diff --git a/solidity/contracts/token/libs/FungibleTokenRouter.sol b/solidity/contracts/token/libs/FungibleTokenRouter.sol index 17f98ca2b0..12fdee8cd0 100644 --- a/solidity/contracts/token/libs/FungibleTokenRouter.sol +++ b/solidity/contracts/token/libs/FungibleTokenRouter.sol @@ -62,8 +62,6 @@ abstract contract FungibleTokenRouter is TokenRouter { return quotes; } - function token() public view virtual returns (address); - function _feeAmount( uint32 _destination, bytes32 _recipient, diff --git a/solidity/contracts/token/libs/LpCollateralRouter.sol b/solidity/contracts/token/libs/LpCollateralRouter.sol new file mode 100644 index 0000000000..8c0467057c --- /dev/null +++ b/solidity/contracts/token/libs/LpCollateralRouter.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {MovableCollateralRouter} from "./MovableCollateralRouter.sol"; + +abstract contract LpCollateralRouter is + MovableCollateralRouter, + ERC4626Upgradeable +{ + uint256 private lpAssets; + + event Donation(address sender, uint256 amount); + + function _LpCollateralRouter_initialize() internal onlyInitializing { + __ERC4626_init(IERC20Upgradeable(token())); + } + + function totalAssets() public view override returns (uint256) { + return lpAssets; + } + + function asset() public view override returns (address) { + return token(); + } + + // modeled after ERC4626Upgradeable._deposit + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual override { + // checks + _transferFromSender(assets); + + // effects + lpAssets += assets; + + // interactions + _mint(receiver, shares); + + emit Deposit(caller, receiver, assets, shares); + } + + // modeled after ERC4626Upgradeable._withdraw + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + // checks + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + _burn(owner, shares); + + // effects + lpAssets -= assets; + + // interactions + _transferTo(receiver, assets); + + emit Withdraw(caller, receiver, owner, assets, shares); + } + + // can be used to distribute rewards to LPs pro rata + function donate(uint256 amount) public { + // checks + _transferFromSender(amount); + + // effects + lpAssets += amount; + emit Donation(msg.sender, amount); + } +} diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index 86cdb6c688..455c8fbb28 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -42,6 +42,13 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { constructor(address _mailbox) GasRouter(_mailbox) {} + /** + * @notice Returns the address of the token managed by this router. + * @dev This function must be implemented by derived contracts to specify the token address. + * @return The address of the token contract. + */ + function token() public view virtual returns (address); + /** * @notice Transfers `_amountOrId` token to `_recipient` on `_destination` domain. * @dev Delegates transfer logic to `_transferFromSender` implementation. @@ -149,13 +156,6 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { */ function _transferFromSender(uint256 _amountOrId) internal virtual; - /** - * @notice Returns the balance of `account` on this token router. - * @param account The address to query the balance of. - * @return The balance of `account`. - */ - function balanceOf(address account) external virtual returns (uint256); - /** * @notice Returns the gas payment required to dispatch a message to the given domain's router. * @param _destination The domain of the router. diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol index 5597524f40..ec7312eb33 100644 --- a/solidity/test/token/HypERC20.t.sol +++ b/solidity/test/token/HypERC20.t.sol @@ -143,6 +143,10 @@ abstract contract HypTokenTest is Test { ); } + function _localTokenBalanceOf( + address _account + ) internal view virtual returns (uint256); + function _connectRouters( uint32[] memory _domains, bytes32[] memory _addresses @@ -352,8 +356,8 @@ abstract contract HypTokenTest is Test { uint256 recipientBalance ) { - senderBalance = localToken.balanceOf(sender); - beneficiaryBalance = localToken.balanceOf(address(feeContract)); + senderBalance = _localTokenBalanceOf(sender); + beneficiaryBalance = _localTokenBalanceOf(address(feeContract)); recipientBalance = remoteToken.balanceOf(recipient); } } @@ -397,6 +401,12 @@ contract HypERC20Test is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return HypERC20(address(localToken)).balanceOf(_account); + } + function testInitialize_revert_ifAlreadyInitialized() public { vm.expectRevert("Initializable: contract is already initialized"); erc20Token.initialize( @@ -487,6 +497,12 @@ contract HypERC20CollateralTest is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return ERC20Test(primaryToken).balanceOf(_account); + } + function test_constructor_revert_ifInvalidToken() public { vm.expectRevert("HypERC20Collateral: invalid token"); new HypERC20Collateral(address(0), SCALE, address(localMailbox)); @@ -495,12 +511,12 @@ contract HypERC20CollateralTest is HypTokenTest { function testInitialize_revert_ifAlreadyInitialized() public {} function testRemoteTransfer() public { - uint256 balanceBefore = localToken.balanceOf(ALICE); + uint256 balanceBefore = _localTokenBalanceOf(ALICE); vm.prank(ALICE); primaryToken.approve(address(localToken), TRANSFER_AMT); _performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0); - assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT); } function testRemoteTransfer_invalidAllowance() public { @@ -511,13 +527,13 @@ contract HypERC20CollateralTest is HypTokenTest { BOB.addressToBytes32(), TRANSFER_AMT ); - assertEq(localToken.balanceOf(ALICE), 1000e18); + assertEq(_localTokenBalanceOf(ALICE), 1000e18); } function testRemoteTransfer_withCustomGasConfig() public { _setCustomGasConfig(); - uint256 balanceBefore = localToken.balanceOf(ALICE); + uint256 balanceBefore = _localTokenBalanceOf(ALICE); vm.prank(ALICE); primaryToken.approve(address(localToken), TRANSFER_AMT); @@ -526,7 +542,7 @@ contract HypERC20CollateralTest is HypTokenTest { TRANSFER_AMT, GAS_LIMIT * igp.gasPrice() ); - assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT); } } @@ -558,8 +574,14 @@ contract HypXERC20Test is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return ERC20Test(primaryToken).balanceOf(_account); + } + function testRemoteTransfer() public { - uint256 balanceBefore = localToken.balanceOf(ALICE); + uint256 balanceBefore = _localTokenBalanceOf(ALICE); vm.prank(ALICE); primaryToken.approve(address(localToken), TRANSFER_AMT); @@ -568,7 +590,7 @@ contract HypXERC20Test is HypTokenTest { abi.encodeCall(IXERC20.burn, (ALICE, TRANSFER_AMT)) ); _performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0); - assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT); } function testHandle() public { @@ -613,6 +635,12 @@ contract HypXERC20LockboxTest is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return ERC20Test(primaryToken).balanceOf(_account); + } + uint256 constant MAX_INT = 2 ** 256 - 1; function testApproval() public { @@ -633,7 +661,7 @@ contract HypXERC20LockboxTest is HypTokenTest { } function testRemoteTransfer() public { - uint256 balanceBefore = localToken.balanceOf(ALICE); + uint256 balanceBefore = _localTokenBalanceOf(ALICE); vm.prank(ALICE); primaryToken.approve(address(localToken), TRANSFER_AMT); @@ -642,17 +670,17 @@ contract HypXERC20LockboxTest is HypTokenTest { abi.encodeCall(IXERC20.burn, (address(localToken), TRANSFER_AMT)) ); _performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0); - assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT); } function testHandle() public { - uint256 balanceBefore = localToken.balanceOf(ALICE); + uint256 balanceBefore = _localTokenBalanceOf(ALICE); vm.expectCall( address(xerc20Lockbox.xERC20()), abi.encodeCall(IXERC20.mint, (address(localToken), TRANSFER_AMT)) ); _handleLocalTransfer(TRANSFER_AMT); - assertEq(localToken.balanceOf(ALICE), balanceBefore + TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), balanceBefore + TRANSFER_AMT); } } @@ -684,8 +712,14 @@ contract HypFiatTokenTest is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return ERC20Test(primaryToken).balanceOf(_account); + } + function testRemoteTransfer() public { - uint256 balanceBefore = localToken.balanceOf(ALICE); + uint256 balanceBefore = _localTokenBalanceOf(ALICE); vm.prank(ALICE); primaryToken.approve(address(localToken), TRANSFER_AMT); @@ -694,7 +728,7 @@ contract HypFiatTokenTest is HypTokenTest { abi.encodeCall(IFiatToken.burn, (TRANSFER_AMT)) ); _performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0); - assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT); } function testHandle() public { @@ -735,6 +769,12 @@ contract HypNativeTest is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return _account.balance; + } + function testTransfer_withHookSpecified( uint256 fee, bytes calldata metadata @@ -769,7 +809,7 @@ contract HypNativeTest is HypTokenTest { BOB.addressToBytes32(), REQUIRED_VALUE + TRANSFER_AMT + 1 ); - assertEq(localToken.balanceOf(ALICE), 1000e18); + assertEq(_localTokenBalanceOf(ALICE), 1000e18); } function testRemoteTransfer_withCustomGasConfig() public { @@ -847,6 +887,12 @@ contract HypERC20ScaledTest is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return ERC20Test(address(localToken)).balanceOf(_account); + } + function testRemoteTransfer() public { vm.expectEmit(true, true, false, true); emit Transfer(ALICE, address(0x0), TRANSFER_AMT); diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index bf7f94dbbb..e20092fcc2 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -68,6 +68,12 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { _enrollRemoteTokenRouter(); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return IERC20(primaryToken).balanceOf(_account); + } + function _transferRoundTripAndIncreaseYields( uint256 transferAmount, uint256 yieldAmount @@ -123,10 +129,10 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { _transferRoundTripAndIncreaseYields(transferAmount, DUST_AMOUNT); // Check Alice's local token balance - uint256 prevBalance = localToken.balanceOf(ALICE); + uint256 prevBalance = _localTokenBalanceOf(ALICE); _handleLocalTransfer(transferAmount); - assertEq(localToken.balanceOf(ALICE), prevBalance + transferAmount); + assertEq(_localTokenBalanceOf(ALICE), prevBalance + transferAmount); assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0); } @@ -139,9 +145,9 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { _transferRoundTripAndIncreaseYields(TRANSFER_AMT, rewardAmount); // Check Alice's local token balance - uint256 prevBalance = localToken.balanceOf(ALICE); + uint256 prevBalance = _localTokenBalanceOf(ALICE); _handleLocalTransfer(TRANSFER_AMT); - assertEq(localToken.balanceOf(ALICE), prevBalance + TRANSFER_AMT); + assertEq(_localTokenBalanceOf(ALICE), prevBalance + TRANSFER_AMT); // Has leftover shares, but no assets deposited assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0); diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol index bb348ec6d8..17af08e7b0 100644 --- a/solidity/test/token/HypERC4626Test.t.sol +++ b/solidity/test/token/HypERC4626Test.t.sol @@ -116,6 +116,12 @@ contract HypERC4626CollateralTest is HypTokenTest { _connectRouters(domains, addresses); } + function _localTokenBalanceOf( + address _account + ) internal view override returns (uint256) { + return IERC20(primaryToken).balanceOf(_account); + } + function testDisableInitializers() public { vm.expectRevert("Initializable: contract is already initialized"); remoteToken.initialize(0, "", "", address(0), address(0), address(0)); diff --git a/solidity/test/token/HypERC721.t.sol b/solidity/test/token/HypERC721.t.sol index 70a2a229c5..88fef3c8f7 100644 --- a/solidity/test/token/HypERC721.t.sol +++ b/solidity/test/token/HypERC721.t.sol @@ -17,6 +17,7 @@ import "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {TestMailbox} from "../../contracts/test/TestMailbox.sol"; import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol"; @@ -141,7 +142,7 @@ abstract contract HypTokenTest is Test, IERC721Receiver { ); _processTransfers(BOB, _tokenId); - assertEq(remoteToken.balanceOf(BOB), 1); + assertEq(IERC721(remoteToken.token()).balanceOf(BOB), 1); } function testBenchmark_overheadGasUsage() public { @@ -340,7 +341,7 @@ contract HypERC721CollateralTest is HypTokenTest { _deployRemoteToken(isCollateral); _performRemoteTransfer(25000, 0); assertEq( - hyp721Collateral.balanceOf(address(this)), + localPrimaryToken.balanceOf(address(this)), INITIAL_SUPPLY * 2 - 2 ); } @@ -352,7 +353,7 @@ contract HypERC721CollateralTest is HypTokenTest { vm.expectRevert("ERC721: caller is not token owner or approved"); _performRemoteTransfer(25000, 1); assertEq( - hyp721Collateral.balanceOf(address(this)), + localPrimaryToken.balanceOf(address(this)), INITIAL_SUPPLY * 2 - 2 ); } @@ -362,7 +363,7 @@ contract HypERC721CollateralTest is HypTokenTest { vm.expectRevert("ERC721: invalid token ID"); _performRemoteTransfer(25000, INITIAL_SUPPLY * 2); assertEq( - hyp721Collateral.balanceOf(address(this)), + localPrimaryToken.balanceOf(address(this)), INITIAL_SUPPLY * 2 - 1 ); } @@ -417,9 +418,9 @@ contract HypERC721CollateralURIStorageTest is HypTokenTest { ); _processTransfers(BOB, 0); - assertEq(remoteToken.balanceOf(BOB), 1); + assertEq(IERC721(address(remoteToken)).balanceOf(BOB), 1); assertEq( - hyp721URICollateral.balanceOf(address(this)), + localPrimaryToken.balanceOf(address(this)), INITIAL_SUPPLY * 2 - 2 ); } diff --git a/solidity/test/token/LpCollateralRouter.t.sol b/solidity/test/token/LpCollateralRouter.t.sol new file mode 100644 index 0000000000..642dc2e76f --- /dev/null +++ b/solidity/test/token/LpCollateralRouter.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import {TestLpCollateralRouter} from "../../contracts/test/TestLpCollateralRouter.sol"; +import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract LpCollateralRouterTest is Test { + event Donation(address sender, uint256 amount); + + TestLpCollateralRouter internal router; + address internal alice = address(0x1); + address internal bob = address(0x2); + uint256 internal constant DEPOSIT_AMOUNT = 100e18; + uint256 internal constant DONATE_AMOUNT = 50e18; + + function setUp() public { + MockMailbox mailbox = new MockMailbox(1); + router = new TestLpCollateralRouter(1, address(mailbox)); + vm.label(alice, "Alice"); + vm.label(bob, "Bob"); + } + + function testDepositIncreasesBalances() public { + uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT); + vm.prank(alice); + router.deposit(DEPOSIT_AMOUNT, alice); + assertEq(router.balanceOf(alice), shares); + assertEq(router.totalAssets(), DEPOSIT_AMOUNT); + } + + function testWithdrawDecreasesBalances() public { + uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT); + vm.prank(alice); + router.deposit(DEPOSIT_AMOUNT, alice); + vm.prank(alice); + router.withdraw(DEPOSIT_AMOUNT, bob, alice); + assertEq(router.balanceOf(alice), 0); + assertEq(router.totalAssets(), 0); + } + + function testTotalSupplyTracksShares() public { + assertEq(router.totalSupply(), 0); + vm.prank(alice); + router.deposit(DEPOSIT_AMOUNT, alice); + assertEq(router.totalSupply(), router.balanceOf(alice)); + } + + function testTotalAssetsTracksDepositsAndWithdrawals() public { + assertEq(router.totalAssets(), 0); + vm.prank(alice); + router.deposit(DEPOSIT_AMOUNT, alice); + assertEq(router.totalAssets(), DEPOSIT_AMOUNT); + vm.prank(alice); + router.withdraw(DEPOSIT_AMOUNT, bob, alice); + assertEq(router.totalAssets(), 0); + } + + function testDonateIncreasesTotalAssets() public { + assertEq(router.totalAssets(), 0); + vm.prank(alice); + router.donate(DONATE_AMOUNT); + assertEq(router.totalAssets(), DONATE_AMOUNT); + } + + function testDonateEmitsEvent() public { + vm.expectEmit(true, true, true, true); + emit Donation(alice, DONATE_AMOUNT); + vm.prank(alice); + router.donate(DONATE_AMOUNT); + } + + function testDonateIsNotWithdrawable() public { + vm.prank(alice); + router.donate(DONATE_AMOUNT); + vm.prank(alice); + vm.expectRevert(); + router.withdraw(DONATE_AMOUNT, bob, alice); + } + + function testWithdrawMoreThanBalanceReverts() public { + vm.prank(alice); + router.deposit(DEPOSIT_AMOUNT, alice); + vm.prank(alice); + vm.expectRevert(); + router.withdraw(DEPOSIT_AMOUNT + 1, bob, alice); + } + + function testDonateDistributesToAllHolders( + uint8 aliceFactor, + uint8 bobFactor + ) public { + aliceFactor = uint8(bound(aliceFactor, 1, 100)); + bobFactor = uint8(bound(bobFactor, 1, 100)); + + uint256 aliceDeposit = aliceFactor * DEPOSIT_AMOUNT; + uint256 bobDeposit = bobFactor * DEPOSIT_AMOUNT; + uint256 donation = DONATE_AMOUNT; + + // Alice deposits + vm.prank(alice); + uint256 aliceShares = router.deposit(aliceDeposit, alice); + + // Bob deposits + vm.prank(bob); + uint256 bobShares = router.deposit(bobDeposit, bob); + + // Donate to the vault + router.donate(donation); + + uint256 totalShares = aliceShares + bobShares; + uint256 aliceDonation = (donation * aliceShares) / totalShares; + uint256 bobDonation = (donation * bobShares) / totalShares; + + // account for rounding errors + assertApproxEqAbs( + router.maxWithdraw(alice), + aliceShares + aliceDonation, + 1 + ); + assertApproxEqAbs(router.maxWithdraw(bob), bobShares + bobDonation, 1); + } +} diff --git a/solidity/test/token/MovableCollateralRouter.t.sol b/solidity/test/token/MovableCollateralRouter.t.sol index aa7b9e1d01..77f449f06a 100644 --- a/solidity/test/token/MovableCollateralRouter.t.sol +++ b/solidity/test/token/MovableCollateralRouter.t.sol @@ -19,12 +19,6 @@ contract MockMovableCollateralRouter is MovableCollateralRouter { return address(0); } - function balanceOf( - address _account - ) external view override returns (uint256) { - return 0; - } - function _transferFromSender(uint256 _amount) internal override {} function _transferTo(address _to, uint256 _amount) internal override {} diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts index 31ce7622db..33e029761a 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts @@ -1,4 +1,4 @@ -import { BigNumber, Contract } from 'ethers'; +import { Contract } from 'ethers'; import { HypERC20Collateral__factory, @@ -23,6 +23,7 @@ import { Address, arrayToObject, assert, + eqAddress, getLogLevel, isZeroishAddress, objFilter, @@ -267,10 +268,6 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader { factory: HypERC4626__factory, method: 'collateralDomain', }, - [TokenType.synthetic]: { - factory: HypERC20__factory, - method: 'decimals', - }, }; // Temporarily turn off SmartProvider logging @@ -327,23 +324,25 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader { } } - // Finally check native - // Using estimateGas to send 0 wei. Success implies that the Warp Route has a receive() function - try { - await this.multiProvider.estimateGas( - this.chain, - { - to: warpRouteAddress, - value: BigNumber.from(0), - }, - NON_ZERO_SENDER_ADDRESS, // Use non-zero address as signer is not provided for read commands - ); + // Check for native vs synthetic by looking at the token() method + // HypNative.token() returns address(0), HypERC20.token() returns address(this) + const tokenRouter = TokenRouter__factory.connect( + warpRouteAddress, + this.provider, + ); + const tokenAddress = await tokenRouter.token(); + + if (isZeroishAddress(tokenAddress)) { + // Native token returns address(0) return TokenType.native; - } catch (e) { - throw Error(`Error accessing token specific method ${e}`); - } finally { - this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger + } else if (eqAddress(tokenAddress, warpRouteAddress)) { + // Synthetic token returns its own address (address(this)) + return TokenType.synthetic; } + + throw new Error( + `Error deriving token type for token at address "${warpRouteAddress}" on chain "${this.chain}"`, + ); } async fetchXERC20Config( From 8ff74bba69cbc2cb1fe1a1a73f8f9cabed0f139a Mon Sep 17 00:00:00 2001 From: larryob Date: Wed, 9 Jul 2025 11:47:08 -0400 Subject: [PATCH 10/36] refactor: Add internal init function to HypERC20Collateral (#6675) --- solidity/contracts/token/HypERC20Collateral.sol | 8 ++++++++ solidity/contracts/token/TokenBridgeCctp.sol | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index 0a537616a6..f03993a8b8 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -55,6 +55,14 @@ contract HypERC20Collateral is LpCollateralRouter { address _interchainSecurityModule, address _owner ) public virtual initializer { + _HypERC20_initialize(_hook, _interchainSecurityModule, _owner); + } + + function _HypERC20_initialize( + address _hook, + address _interchainSecurityModule, + address _owner + ) internal { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); _LpCollateralRouter_initialize(); } diff --git a/solidity/contracts/token/TokenBridgeCctp.sol b/solidity/contracts/token/TokenBridgeCctp.sol index c1532240f7..1af214ae06 100644 --- a/solidity/contracts/token/TokenBridgeCctp.sol +++ b/solidity/contracts/token/TokenBridgeCctp.sol @@ -83,10 +83,12 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { address _owner, string[] memory __urls ) external virtual initializer { - __Ownable_init(); - setUrls(__urls); + // Call initialization functions of all parent contracts // ISM should not be set - _MailboxClient_initialize(_hook, address(0), _owner); + _HypERC20_initialize(_hook, address(0), _owner); + + // Setup urls for offchain lookup and do token approval + setUrls(__urls); wrappedToken.approve(address(tokenMessenger), type(uint256).max); } From 7997516067b163dde7cdcd50be5781ce5b7b2f9b Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Fri, 11 Jul 2025 23:15:29 -0400 Subject: [PATCH 11/36] feat: extend CCTP with hook and ISM interface (#6687) --- .changeset/nice-crabs-vanish.md | 5 + solidity/contracts/hooks/ArbL2ToL1Hook.sol | 2 +- solidity/contracts/hooks/DefaultHook.sol | 2 +- solidity/contracts/hooks/MerkleTreeHook.sol | 2 +- solidity/contracts/hooks/OPL2ToL1Hook.sol | 2 +- solidity/contracts/hooks/PausableHook.sol | 2 +- solidity/contracts/hooks/ProtocolFee.sol | 2 +- .../aggregation/StaticAggregationHook.sol | 2 +- .../hooks/igp/InterchainGasPaymaster.sol | 2 +- .../hooks/layer-zero/LayerZeroV1Hook.sol | 2 +- .../hooks/libs/AbstractMessageIdAuthHook.sol | 2 +- .../hooks/routing/AmountRoutingHook.sol | 2 +- .../hooks/routing/DomainRoutingHook.sol | 2 +- .../routing/FallbackDomainRoutingHook.sol | 2 +- .../hooks/warp-route/RateLimitedHook.sol | 2 +- solidity/contracts/interfaces/IMailbox.sol | 2 + .../interfaces/cctp/IMessageHandler.sol | 38 ++ .../interfaces/cctp/IMessageTransmitter.sol | 4 + .../interfaces/hooks/IPostDispatchHook.sol | 2 +- .../mock/MockCircleMessageTransmitter.sol | 47 ++- .../mock/MockCircleTokenMessenger.sol | 2 +- .../contracts/test/TestPostDispatchHook.sol | 2 +- solidity/contracts/token/CCTP.md | 109 ++++++ solidity/contracts/token/TokenBridgeCctp.sol | 214 ++++++++--- solidity/test/AmountRouting.t.sol | 2 +- solidity/test/MerkleTreeHook.t.sol | 5 +- solidity/test/hooks/AggregationHook.t.sol | 5 +- solidity/test/hooks/DefaultHook.t.sol | 2 +- solidity/test/hooks/DomainRoutingHook.t.sol | 4 +- solidity/test/hooks/ProtocolFee.t.sol | 5 +- .../hooks/layerzero/LayerZeroV1Hook.t.sol | 5 +- .../hooks/layerzero/LayerZeroV2Hook.t.sol | 5 +- .../test/igps/InterchainGasPaymaster.t.sol | 2 +- solidity/test/isms/ERC5164ISM.t.sol | 5 +- solidity/test/token/TokenBridgeCctp.t.sol | 355 ++++++++++++++++-- 35 files changed, 732 insertions(+), 116 deletions(-) create mode 100644 .changeset/nice-crabs-vanish.md create mode 100644 solidity/contracts/interfaces/cctp/IMessageHandler.sol create mode 100644 solidity/contracts/token/CCTP.md diff --git a/.changeset/nice-crabs-vanish.md b/.changeset/nice-crabs-vanish.md new file mode 100644 index 0000000000..9bb1e41617 --- /dev/null +++ b/.changeset/nice-crabs-vanish.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": minor +--- + +Extend CCTP TokenBridge with GMP support via hook diff --git a/solidity/contracts/hooks/ArbL2ToL1Hook.sol b/solidity/contracts/hooks/ArbL2ToL1Hook.sol index 9a6365b240..d2726b1a70 100644 --- a/solidity/contracts/hooks/ArbL2ToL1Hook.sol +++ b/solidity/contracts/hooks/ArbL2ToL1Hook.sol @@ -58,7 +58,7 @@ contract ArbL2ToL1Hook is AbstractMessageIdAuthHook { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.ARB_L2_TO_L1); + return uint8(IPostDispatchHook.HookTypes.ARB_L2_TO_L1); } /// @inheritdoc AbstractPostDispatchHook diff --git a/solidity/contracts/hooks/DefaultHook.sol b/solidity/contracts/hooks/DefaultHook.sol index 1d16180861..6bd2204c04 100644 --- a/solidity/contracts/hooks/DefaultHook.sol +++ b/solidity/contracts/hooks/DefaultHook.sol @@ -14,7 +14,7 @@ contract DefaultHook is AbstractPostDispatchHook, MailboxClient { constructor(address _mailbox) MailboxClient(_mailbox) {} function hookType() external pure returns (uint8) { - return uint8(IPostDispatchHook.Types.MAILBOX_DEFAULT_HOOK); + return uint8(IPostDispatchHook.HookTypes.MAILBOX_DEFAULT_HOOK); } function _hook() public view returns (IPostDispatchHook) { diff --git a/solidity/contracts/hooks/MerkleTreeHook.sol b/solidity/contracts/hooks/MerkleTreeHook.sol index 5af7e47aec..d2896e9889 100644 --- a/solidity/contracts/hooks/MerkleTreeHook.sol +++ b/solidity/contracts/hooks/MerkleTreeHook.sol @@ -54,7 +54,7 @@ contract MerkleTreeHook is AbstractPostDispatchHook, MailboxClient, Indexed { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.MERKLE_TREE); + return uint8(IPostDispatchHook.HookTypes.MERKLE_TREE); } // ============ Internal Functions ============ diff --git a/solidity/contracts/hooks/OPL2ToL1Hook.sol b/solidity/contracts/hooks/OPL2ToL1Hook.sol index 96fc89d03a..9294d581b2 100644 --- a/solidity/contracts/hooks/OPL2ToL1Hook.sol +++ b/solidity/contracts/hooks/OPL2ToL1Hook.sol @@ -58,7 +58,7 @@ contract OPL2ToL1Hook is AbstractMessageIdAuthHook { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.OP_L2_TO_L1); + return uint8(IPostDispatchHook.HookTypes.OP_L2_TO_L1); } /// @inheritdoc AbstractPostDispatchHook diff --git a/solidity/contracts/hooks/PausableHook.sol b/solidity/contracts/hooks/PausableHook.sol index aeffc7630a..1a42aa07f4 100644 --- a/solidity/contracts/hooks/PausableHook.sol +++ b/solidity/contracts/hooks/PausableHook.sol @@ -34,7 +34,7 @@ contract PausableHook is AbstractPostDispatchHook, Ownable, Pausable { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.PAUSABLE); + return uint8(IPostDispatchHook.HookTypes.PAUSABLE); } // ============ Internal functions ============ diff --git a/solidity/contracts/hooks/ProtocolFee.sol b/solidity/contracts/hooks/ProtocolFee.sol index 4bf086ee18..e0eb8d24f1 100644 --- a/solidity/contracts/hooks/ProtocolFee.sol +++ b/solidity/contracts/hooks/ProtocolFee.sol @@ -66,7 +66,7 @@ contract ProtocolFee is AbstractPostDispatchHook, Ownable { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.PROTOCOL_FEE); + return uint8(IPostDispatchHook.HookTypes.PROTOCOL_FEE); } /** diff --git a/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol b/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol index a8e6fc7acd..d83b1af260 100644 --- a/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol +++ b/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol @@ -34,7 +34,7 @@ contract StaticAggregationHook is AbstractPostDispatchHook { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.AGGREGATION); + return uint8(IPostDispatchHook.HookTypes.AGGREGATION); } /// @inheritdoc AbstractPostDispatchHook diff --git a/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol b/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol index 6df37c3f16..a2f90d1f9b 100644 --- a/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol +++ b/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol @@ -95,7 +95,7 @@ contract InterchainGasPaymaster is /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.INTERCHAIN_GAS_PAYMASTER); + return uint8(IPostDispatchHook.HookTypes.INTERCHAIN_GAS_PAYMASTER); } /** diff --git a/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol b/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol index 34ecc08a38..79761b41ef 100644 --- a/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol +++ b/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol @@ -50,7 +50,7 @@ contract LayerZeroV1Hook is AbstractPostDispatchHook, MailboxClient { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.LAYER_ZERO_V1); + return uint8(IPostDispatchHook.HookTypes.LAYER_ZERO_V1); } /// @inheritdoc AbstractPostDispatchHook diff --git a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol index 70c8f54756..a07b0fa76e 100644 --- a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol +++ b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol @@ -61,7 +61,7 @@ abstract contract AbstractMessageIdAuthHook is /// @inheritdoc IPostDispatchHook function hookType() external pure virtual returns (uint8) { - return uint8(IPostDispatchHook.Types.ID_AUTH_ISM); + return uint8(IPostDispatchHook.HookTypes.ID_AUTH_ISM); } // ============ Internal functions ============ diff --git a/solidity/contracts/hooks/routing/AmountRoutingHook.sol b/solidity/contracts/hooks/routing/AmountRoutingHook.sol index 995f1a2540..eaef3a78ea 100644 --- a/solidity/contracts/hooks/routing/AmountRoutingHook.sol +++ b/solidity/contracts/hooks/routing/AmountRoutingHook.sol @@ -17,7 +17,7 @@ contract AmountRoutingHook is AmountPartition, AbstractPostDispatchHook { ) AmountPartition(_lowerHook, _upperHook, _threshold) {} function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.AMOUNT_ROUTING); + return uint8(IPostDispatchHook.HookTypes.AMOUNT_ROUTING); } function _postDispatch( diff --git a/solidity/contracts/hooks/routing/DomainRoutingHook.sol b/solidity/contracts/hooks/routing/DomainRoutingHook.sol index 9e45ee4fc2..7806606065 100644 --- a/solidity/contracts/hooks/routing/DomainRoutingHook.sol +++ b/solidity/contracts/hooks/routing/DomainRoutingHook.sol @@ -45,7 +45,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient { /// @inheritdoc IPostDispatchHook function hookType() external pure virtual override returns (uint8) { - return uint8(IPostDispatchHook.Types.ROUTING); + return uint8(IPostDispatchHook.HookTypes.ROUTING); } function setHook(uint32 _destination, address _hook) public onlyOwner { diff --git a/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol b/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol index 100a5cb9aa..f52ad979da 100644 --- a/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol +++ b/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol @@ -39,7 +39,7 @@ contract FallbackDomainRoutingHook is DomainRoutingHook { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.FALLBACK_ROUTING); + return uint8(IPostDispatchHook.HookTypes.FALLBACK_ROUTING); } // ============ Internal Functions ============ diff --git a/solidity/contracts/hooks/warp-route/RateLimitedHook.sol b/solidity/contracts/hooks/warp-route/RateLimitedHook.sol index b1ab8ea68f..5423e878f4 100644 --- a/solidity/contracts/hooks/warp-route/RateLimitedHook.sol +++ b/solidity/contracts/hooks/warp-route/RateLimitedHook.sol @@ -70,7 +70,7 @@ contract RateLimitedHook is /// @inheritdoc IPostDispatchHook function hookType() external pure returns (uint8) { - return uint8(IPostDispatchHook.Types.RATE_LIMITED); + return uint8(IPostDispatchHook.HookTypes.RATE_LIMITED); } // ============ Internal Functions ============ diff --git a/solidity/contracts/interfaces/IMailbox.sol b/solidity/contracts/interfaces/IMailbox.sol index 60d2e9a2b6..08a70102fa 100644 --- a/solidity/contracts/interfaces/IMailbox.sol +++ b/solidity/contracts/interfaces/IMailbox.sol @@ -56,6 +56,8 @@ interface IMailbox { function latestDispatchedId() external view returns (bytes32); + function nonce() external view returns (uint32); + function dispatch( uint32 destinationDomain, bytes32 recipientAddress, diff --git a/solidity/contracts/interfaces/cctp/IMessageHandler.sol b/solidity/contracts/interfaces/cctp/IMessageHandler.sol new file mode 100644 index 0000000000..b63002f581 --- /dev/null +++ b/solidity/contracts/interfaces/cctp/IMessageHandler.sol @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022, Circle Internet Financial Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +pragma solidity >=0.8.0; + +// copied from https://github.com/circlefin/evm-cctp-contracts/blob/6e7513cdb2bee6bb0cddf331fe972600fc5017c9/src/interfaces/IMessageHandler.sol + +/** + * @title IMessageHandler + * @notice Handles messages on destination domain forwarded from + * an IReceiver + */ +interface IMessageHandler { + /** + * @notice handles an incoming message from a Receiver + * @param sourceDomain the source domain of the message + * @param sender the sender of the message + * @param messageBody The message raw bytes + * @return success bool, true if successful + */ + function handleReceiveMessage( + uint32 sourceDomain, + bytes32 sender, + bytes calldata messageBody + ) external returns (bool); +} diff --git a/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol b/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol index 7ace54d455..6522c7ed44 100644 --- a/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol +++ b/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol @@ -55,4 +55,8 @@ interface IMessageTransmitter is IRelayer, IReceiver { function version() external view returns (uint32); function localDomain() external view returns (uint32); + + function nextAvailableNonce() external view returns (uint64); + + function signatureThreshold() external view returns (uint256); } diff --git a/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol b/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol index 3a44d72fd2..68d22690a6 100644 --- a/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol +++ b/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol @@ -14,7 +14,7 @@ pragma solidity >=0.8.0; @@@@@@@@@ @@@@@@@@*/ interface IPostDispatchHook { - enum Types { + enum HookTypes { UNUSED, ROUTING, AGGREGATION, diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index ac80cdb96f..83c1a3fac4 100644 --- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol +++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; -import {ICircleMessageTransmitter} from "../middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol"; +import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; import {MockToken} from "./MockToken.sol"; -contract MockCircleMessageTransmitter is ICircleMessageTransmitter { +contract MockCircleMessageTransmitter is IMessageTransmitter { mapping(bytes32 => bool) processedNonces; MockToken token; uint32 public version; @@ -13,6 +13,14 @@ contract MockCircleMessageTransmitter is ICircleMessageTransmitter { token = _token; } + function nextAvailableNonce() external view returns (uint64) { + return 0; + } + + function signatureThreshold() external view returns (uint256) { + return 1; + } + function receiveMessage( bytes memory, bytes calldata @@ -36,11 +44,42 @@ contract MockCircleMessageTransmitter is ICircleMessageTransmitter { token.mint(_recipient, _amount); } - function usedNonces(bytes32 _nonceId) external view returns (bool) { - return processedNonces[_nonceId]; + function usedNonces(bytes32 _nonceId) external view returns (uint256) { + return processedNonces[_nonceId] ? 1 : 0; } function setVersion(uint32 _version) external { version = _version; } + + function localDomain() external view returns (uint32) { + return 0; + } + + function replaceMessage( + bytes calldata, + bytes calldata, + bytes calldata, + bytes32 + ) external { + revert("Not implemented"); + } + + function sendMessage( + uint32, + bytes32, + bytes calldata message + ) public returns (uint64) { + emit MessageSent(message); + return 0; + } + + function sendMessageWithCaller( + uint32, + bytes32, + bytes32, + bytes calldata message + ) external returns (uint64) { + return sendMessage(0, 0, message); + } } diff --git a/solidity/contracts/mock/MockCircleTokenMessenger.sol b/solidity/contracts/mock/MockCircleTokenMessenger.sol index 895605d02b..c4b8192ba3 100644 --- a/solidity/contracts/mock/MockCircleTokenMessenger.sol +++ b/solidity/contracts/mock/MockCircleTokenMessenger.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; -import {ITokenMessenger} from "../middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol"; +import {ITokenMessenger} from "../interfaces/cctp/ITokenMessenger.sol"; import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; import {MockToken} from "./MockToken.sol"; diff --git a/solidity/contracts/test/TestPostDispatchHook.sol b/solidity/contracts/test/TestPostDispatchHook.sol index 0ac1f0a851..105c93ce0f 100644 --- a/solidity/contracts/test/TestPostDispatchHook.sol +++ b/solidity/contracts/test/TestPostDispatchHook.sol @@ -21,7 +21,7 @@ contract TestPostDispatchHook is AbstractPostDispatchHook { /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { - return uint8(IPostDispatchHook.Types.UNUSED); + return uint8(IPostDispatchHook.HookTypes.UNUSED); } function supportsMetadata( diff --git a/solidity/contracts/token/CCTP.md b/solidity/contracts/token/CCTP.md new file mode 100644 index 0000000000..49deb1897c --- /dev/null +++ b/solidity/contracts/token/CCTP.md @@ -0,0 +1,109 @@ +## Burn Message + +```mermaid +flowchart LR + Iris((Iris)) + Relayer((Relayer)) + + subgraph Origin Chain + User + TBCCTP_O[TokenBridgeCctp] + M_O[(Mailbox)] + TM_O[TokenMessenger] + MT_O[MessageTransmitter] + USDC_O[USDC] + + User -- "transferRemote(amount, recipient)" --> TBCCTP_O + TBCCTP_O -- "depositForBurn()" --> TM_O + TM_O -- "burn" --> USDC_O + User -. "amount" .-> USDC_O + TM_O -- "sendMessage(burnMessage)" --> MT_O + TBCCTP_O -- "dispatch(tokenMessage)" --> M_O + end + + subgraph Destination Chain + Recipient[Recipient] + TBCCTP_D[TokenBridgeCctp] + M_D[(Mailbox)] + TM_D[TokenMessenger] + MT_D[MessageTransmitter] + USDC_D[USDC] + + TBCCTP_D -- "receiveMessage( + burnMessage, + attestation)" --> MT_D + MT_D -- "burnMessage" --> TM_D + TM_D -- "mint" --> USDC_D + USDC_D -. "amount" .-> Recipient + end + + M_O -. "tokenMessage" .-> Relayer + Relayer -- "getOffchainVerifyInfo(tokenMessage)" --> TBCCTP_D + TBCCTP_D -. "OffchainLookup" .-> Iris + Iris -. "burnMessage, attestation" .-> Relayer + + Relayer -- "process( + [burnMessage, attestation], + tokenMessage)" --> M_D + + M_D -- "verify([burnMessage, attestation], tokenMessage)" --> TBCCTP_D + M_D -- "handle(tokenMessage)" --> TBCCTP_D + + MT_O -. "burnMessage" .-> Iris + + classDef cctp fill:#e3f2fd + classDef hyperlane fill:#f3e5f5 + class MT_O,MT_D,TM_O,TM_D,Iris,USDC_O,USDC_D cctp + class M_O,M_D,Relayer hyperlane +``` + +## Hook Message + +```mermaid +flowchart LR + Iris((Iris)) + Relayer((Relayer)) + + subgraph Origin Chain + App + M_O[(Mailbox)] + TBCCTP_O[TokenBridgeCctp] + MT_O[MessageTransmitter] + + App -- "dispatch(hyperlaneMessage)" --> M_O + M_O -- "postDispatch(hyperlaneMessage)" --> TBCCTP_O + TBCCTP_O -- "sendMessage(hyperlaneMessage.id())" --> MT_O + end + + subgraph Destination Chain + Recipient[Recipient] + TBCCTP_D[TokenBridgeCctp] + M_D[(Mailbox)] + MT_D[MessageTransmitter] + + TBCCTP_D -- "receiveMessage( + cctpMessage, + attestation)" --> MT_D + MT_D -- "handleReceiveMessage(cctpMessage)" --> TBCCTP_D + end + + M_O -. "hyperlaneMessage" .-> Relayer + TBCCTP_D -. "interchainSecurityModule()" .- Recipient + TBCCTP_D -. "OffchainLookup" .-> Iris + Iris -. "cctpMessage, attestation" .-> Relayer + + Relayer -- "getOffchainVerifyInfo(hyperlaneMessage)" --> TBCCTP_D + Relayer -- "process( + [cctpMessage, attestation], + hyperlaneMessage)" --> M_D + + M_D -- "verify([cctpMessage, attestation], hyperlaneMessage)" --> TBCCTP_D + M_D -- "handle(hyperlaneMessage)" ----> Recipient + + MT_O -. "cctpMessage" .-> Iris + + classDef cctp fill:#e3f2fd + classDef hyperlane fill:#f3e5f5 + class MT_O,MT_D,TM_O,TM_D,Iris,USDC_O,USDC_D cctp + class M_O,M_D,Relayer hyperlane +``` diff --git a/solidity/contracts/token/TokenBridgeCctp.sol b/solidity/contracts/token/TokenBridgeCctp.sol index 1af214ae06..d90551589f 100644 --- a/solidity/contracts/token/TokenBridgeCctp.sol +++ b/solidity/contracts/token/TokenBridgeCctp.sol @@ -11,6 +11,15 @@ import {ITokenMessenger} from "./../interfaces/cctp/ITokenMessenger.sol"; import {Message} from "./../libs/Message.sol"; import {TokenMessage} from "./libs/TokenMessage.sol"; import {CctpMessage, BurnMessage} from "../libs/CctpMessage.sol"; +import {IPostDispatchHook} from "../interfaces/hooks/IPostDispatchHook.sol"; +import {StandardHookMetadata} from "../hooks/libs/StandardHookMetadata.sol"; +import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; +import {TypeCasts} from "../libs/TypeCasts.sol"; +import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; +import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; interface CctpService { function getCCTPAttestation( @@ -26,11 +35,21 @@ uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET + 8; // @dev Supports only CCTP V1 -contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { +contract TokenBridgeCctp is + MovableCollateralRouter, + AbstractCcipReadIsm, + IPostDispatchHook, + IMessageHandler +{ using CctpMessage for bytes29; using BurnMessage for bytes29; + using TypedMemView for bytes29; using Message for bytes; + using TypeCasts for bytes32; + using SafeERC20 for IERC20; + + IERC20 public immutable wrappedToken; uint32 internal constant CCTP_VERSION = 0; @@ -62,7 +81,7 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { address _mailbox, IMessageTransmitter _messageTransmitter, ITokenMessenger _tokenMessenger - ) HypERC20Collateral(_erc20, _scale, _mailbox) { + ) FungibleTokenRouter(_scale, _mailbox) { require( _messageTransmitter.version() == CCTP_VERSION, "Invalid messageTransmitter CCTP version" @@ -75,31 +94,28 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { ); tokenMessenger = _tokenMessenger; + wrappedToken = IERC20(_erc20); + _disableInitializers(); } + function token() public view virtual override returns (address) { + return address(wrappedToken); + } + function initialize( address _hook, address _owner, string[] memory __urls ) external virtual initializer { - // Call initialization functions of all parent contracts // ISM should not be set - _HypERC20_initialize(_hook, address(0), _owner); + _MailboxClient_initialize(_hook, address(0), _owner); // Setup urls for offchain lookup and do token approval setUrls(__urls); wrappedToken.approve(address(tokenMessenger), type(uint256).max); } - function initialize( - address _hook, - address _interchainSecurityModule, - address _owner - ) public override { - revert("Only TokenBridgeCctp.initialize() may be called"); - } - function interchainSecurityModule() external view @@ -147,52 +163,165 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { bytes calldata _hyperlaneMessage ) external returns (bool) { // decode return type of CctpService.getCCTPAttestation - (bytes memory cctpMessage, bytes memory attestation) = abi.decode( + (bytes memory cctpMessageBytes, bytes memory attestation) = abi.decode( _metadata, (bytes, bytes) ); - bytes calldata tokenMessage = _hyperlaneMessage.body(); - _validateMessageLength(tokenMessage); - - bytes29 originalMsg = TypedMemView.ref(cctpMessage, 0); + bytes29 cctpMessage = TypedMemView.ref(cctpMessageBytes, 0); - bytes29 burnMessage = originalMsg._messageBody(); + // check if CCTP message source matches the hyperlane message origin + uint32 origin = _hyperlaneMessage.origin(); + uint32 sourceDomain = cctpMessage._sourceDomain(); require( - TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), - "Invalid amount" + sourceDomain == hyperlaneDomainToCircleDomain(origin), + "Invalid source domain" ); - require( - TokenMessage.recipient(tokenMessage) == - burnMessage._getMintRecipient(), - "Invalid recipient" + + uint64 sourceNonce = cctpMessage._nonce(); + + address cctpMessageRecipient = cctpMessage + ._recipient() + .bytes32ToAddress(); + // check if CCTP message is a USDC burn message + if (cctpMessageRecipient == address(tokenMessenger)) { + bytes29 burnMessage = cctpMessage._messageBody(); + + // check that burner matches the sender of the hyperlane message (token router) + bytes32 circleBurnSender = burnMessage._getMessageSender(); + require( + circleBurnSender == _hyperlaneMessage.sender(), + "Invalid burn sender" + ); + + _validateTokenMessage( + _hyperlaneMessage.body(), + sourceNonce, + burnMessage + ); + // check if CCTP message is a GMP message to this contract + } else if (cctpMessageRecipient == address(this)) { + // check that sender matches the origin router + bytes32 cctpMessageSender = cctpMessage._sender(); + require( + cctpMessageSender == _mustHaveRemoteRouter(origin), + "Invalid circle sender" + ); + + // check that the body matches the hyperlane message ID + bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32); + require( + circleMessageId == _hyperlaneMessage.id(), + "Invalid message id" + ); + // do not allow other CCTP message types + } else { + revert("Invalid circle recipient"); + } + + // Receive only if the nonce hasn't been used before + bytes32 sourceAndNonceHash = keccak256( + abi.encodePacked(sourceDomain, sourceNonce) ); + if (messageTransmitter.usedNonces(sourceAndNonceHash) == 0) { + require( + messageTransmitter.receiveMessage( + cctpMessageBytes, + attestation + ), + "Failed to receive message" + ); + } - bytes32 sourceSender = burnMessage._getMessageSender(); - require(sourceSender == _hyperlaneMessage.sender(), "Invalid sender"); + return true; + } + + /// @inheritdoc IPostDispatchHook + function hookType() external pure override returns (uint8) { + return uint8(IPostDispatchHook.HookTypes.CCTP); + } + + /// @inheritdoc IPostDispatchHook + function supportsMetadata( + bytes calldata metadata + ) public pure override returns (bool) { + return true; + } + + /// @inheritdoc IPostDispatchHook + function quoteDispatch( + bytes calldata, + bytes calldata + ) external pure override returns (uint256) { + return 0; + } - uint32 sourceDomain = originalMsg._sourceDomain(); + /// @inheritdoc IPostDispatchHook + function postDispatch( + bytes calldata /*metadata*/, + bytes calldata message + ) external payable override { + require(_isLatestDispatched(message.id()), "Message not dispatched"); + + uint32 destination = message.destination(); + bytes32 ism = _mustHaveRemoteRouter(destination); + uint32 circleDestination = hyperlaneDomainToCircleDomain(destination); + + messageTransmitter.sendMessageWithCaller({ + destinationDomain: circleDestination, + // recipient must be this implementation with `handleReceiveMessage` + recipient: ism, + // enforces that only the enrolled ISM's verify() can deliver the CCTP message + destinationCaller: ism, + messageBody: abi.encode(message.id()) + }); + } + + /// @inheritdoc IMessageHandler + function handleReceiveMessage( + uint32 /*sourceDomain*/, + bytes32 /*sender*/, + bytes calldata /*body*/ + ) external override returns (bool) { + return msg.sender == address(messageTransmitter); + } + + function _validateMessageLength(bytes memory _tokenMessage) internal pure { require( - sourceDomain == - hyperlaneDomainToCircleDomain(_hyperlaneMessage.origin()), - "Invalid source domain" + _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, + "Invalid message body length" ); + } + + // @dev Validates that the CCTP message nonce and burn message fields match the hyperlane token router message + function _validateTokenMessage( + bytes calldata tokenMessage, + uint64 circleNonce, + bytes29 circleBody + ) internal pure { + circleBody._validateBurnMessageFormat(); + _validateMessageLength(tokenMessage); - uint64 sourceNonce = originalMsg._nonce(); require( - sourceNonce == uint64(bytes8(TokenMessage.metadata(tokenMessage))), + uint64(bytes8(TokenMessage.metadata(tokenMessage))) == circleNonce, "Invalid nonce" ); - // Receive only if the nonce hasn't been used before - bytes32 sourceAndNonceHash = keccak256( - abi.encodePacked(sourceDomain, sourceNonce) + require( + TokenMessage.amount(tokenMessage) == circleBody._getAmount(), + "Invalid mint amount" ); - if (messageTransmitter.usedNonces(sourceAndNonceHash) == 0) { - messageTransmitter.receiveMessage(cctpMessage, attestation); - } - return true; + require( + TokenMessage.recipient(tokenMessage) == + circleBody._getMintRecipient(), + "Invalid mint recipient" + ); + } + + // @dev Copied from HypERC20Collateral._transferFromSender + function _transferFromSender(uint256 _amount) internal virtual override { + wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); } function _beforeDispatch( @@ -235,11 +364,4 @@ contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm { ) internal override { // do not transfer to recipient as the CCTP transfer will do it } - - function _validateMessageLength(bytes memory _tokenMessage) internal pure { - require( - _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, - "Invalid message body length" - ); - } } diff --git a/solidity/test/AmountRouting.t.sol b/solidity/test/AmountRouting.t.sol index 1cfbeaf47e..2303bcca8b 100644 --- a/solidity/test/AmountRouting.t.sol +++ b/solidity/test/AmountRouting.t.sol @@ -141,7 +141,7 @@ contract AmountRoutingTest is Test { function test_hookType() public view { assertEq( hook.hookType(), - uint8(IPostDispatchHook.Types.AMOUNT_ROUTING) + uint8(IPostDispatchHook.HookTypes.AMOUNT_ROUTING) ); } diff --git a/solidity/test/MerkleTreeHook.t.sol b/solidity/test/MerkleTreeHook.t.sol index 5546523f29..f110ec8282 100644 --- a/solidity/test/MerkleTreeHook.t.sol +++ b/solidity/test/MerkleTreeHook.t.sol @@ -71,6 +71,9 @@ contract MerkleTreeHookTest is Test { } function testHookType() public { - assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.MERKLE_TREE)); + assertEq( + hook.hookType(), + uint8(IPostDispatchHook.HookTypes.MERKLE_TREE) + ); } } diff --git a/solidity/test/hooks/AggregationHook.t.sol b/solidity/test/hooks/AggregationHook.t.sol index 3a0fdf263d..cca05c96fa 100644 --- a/solidity/test/hooks/AggregationHook.t.sol +++ b/solidity/test/hooks/AggregationHook.t.sol @@ -147,7 +147,10 @@ contract AggregationHookTest is Test { function testHookType() public { deployHooks(1, 0); - assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.AGGREGATION)); + assertEq( + hook.hookType(), + uint8(IPostDispatchHook.HookTypes.AGGREGATION) + ); } receive() external payable {} diff --git a/solidity/test/hooks/DefaultHook.t.sol b/solidity/test/hooks/DefaultHook.t.sol index 27e514df8e..be68449795 100644 --- a/solidity/test/hooks/DefaultHook.t.sol +++ b/solidity/test/hooks/DefaultHook.t.sol @@ -33,7 +33,7 @@ contract DefaultHookTest is Test { function test_hookType() public { assertEq( hook.hookType(), - uint8(IPostDispatchHook.Types.MAILBOX_DEFAULT_HOOK) + uint8(IPostDispatchHook.HookTypes.MAILBOX_DEFAULT_HOOK) ); } diff --git a/solidity/test/hooks/DomainRoutingHook.t.sol b/solidity/test/hooks/DomainRoutingHook.t.sol index 193f227413..d0ac06030a 100644 --- a/solidity/test/hooks/DomainRoutingHook.t.sol +++ b/solidity/test/hooks/DomainRoutingHook.t.sol @@ -107,7 +107,7 @@ contract DomainRoutingHookTest is Test { } function testHookType() public virtual { - assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.ROUTING)); + assertEq(hook.hookType(), uint8(IPostDispatchHook.HookTypes.ROUTING)); } } @@ -171,7 +171,7 @@ contract FallbackDomainRoutingHookTest is DomainRoutingHookTest { function testHookType() public override { assertEq( hook.hookType(), - uint8(IPostDispatchHook.Types.FALLBACK_ROUTING) + uint8(IPostDispatchHook.HookTypes.FALLBACK_ROUTING) ); } } diff --git a/solidity/test/hooks/ProtocolFee.t.sol b/solidity/test/hooks/ProtocolFee.t.sol index e9b09501d8..e4766ac7ff 100644 --- a/solidity/test/hooks/ProtocolFee.t.sol +++ b/solidity/test/hooks/ProtocolFee.t.sol @@ -37,7 +37,10 @@ contract ProtocolFeeTest is Test { } function testHookType() public { - assertEq(fees.hookType(), uint8(IPostDispatchHook.Types.PROTOCOL_FEE)); + assertEq( + fees.hookType(), + uint8(IPostDispatchHook.HookTypes.PROTOCOL_FEE) + ); } function testSetProtocolFee(uint256 fee) public { diff --git a/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol b/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol index a99ec9a5ec..bfbb652750 100644 --- a/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol +++ b/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol @@ -176,6 +176,9 @@ contract LayerZeroV1HookTest is Test { // TODO test failed/retry function testLzV1Hook_HookType() public { - assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.LAYER_ZERO_V1)); + assertEq( + hook.hookType(), + uint8(IPostDispatchHook.HookTypes.LAYER_ZERO_V1) + ); } } diff --git a/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol b/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol index 8b5d6004c1..aeee739ff8 100644 --- a/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol +++ b/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol @@ -194,6 +194,9 @@ contract LayerZeroV2HookTest is Test { // TODO test failed/retry function testLzV2Hook_HookType() public { - assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.ID_AUTH_ISM)); + assertEq( + hook.hookType(), + uint8(IPostDispatchHook.HookTypes.ID_AUTH_ISM) + ); } } diff --git a/solidity/test/igps/InterchainGasPaymaster.t.sol b/solidity/test/igps/InterchainGasPaymaster.t.sol index de5337ef68..d12f3a1982 100644 --- a/solidity/test/igps/InterchainGasPaymaster.t.sol +++ b/solidity/test/igps/InterchainGasPaymaster.t.sol @@ -533,7 +533,7 @@ contract InterchainGasPaymasterTest is Test { function testHookType() public { assertEq( igp.hookType(), - uint8(IPostDispatchHook.Types.INTERCHAIN_GAS_PAYMASTER) + uint8(IPostDispatchHook.HookTypes.INTERCHAIN_GAS_PAYMASTER) ); } diff --git a/solidity/test/isms/ERC5164ISM.t.sol b/solidity/test/isms/ERC5164ISM.t.sol index f3900e621d..f304714f7f 100644 --- a/solidity/test/isms/ERC5164ISM.t.sol +++ b/solidity/test/isms/ERC5164ISM.t.sol @@ -99,7 +99,10 @@ contract ERC5164IsmTest is ExternalBridgeTest { } function testTypes() public view { - assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.ID_AUTH_ISM)); + assertEq( + hook.hookType(), + uint8(IPostDispatchHook.HookTypes.ID_AUTH_ISM) + ); assertEq(ism.moduleType(), uint8(IInterchainSecurityModule.Types.NULL)); } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 10fbbc5331..9251f6d001 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -22,9 +22,14 @@ import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openze import {CctpMessage, BurnMessage} from "../../contracts/libs/CctpMessage.sol"; import {Message} from "../../contracts/libs/Message.sol"; import {CctpService} from "../../contracts/token/TokenBridgeCctp.sol"; +import {TestRecipient} from "../../contracts/test/TestRecipient.sol"; +import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol"; +import {IMailbox} from "../../contracts/interfaces/IMailbox.sol"; +import {ISpecifiesInterchainSecurityModule} from "../../contracts/interfaces/IInterchainSecurityModule.sol"; contract TokenBridgeCctpTest is Test { using TypeCasts for address; + using TypeCasts for bytes32; using Message for bytes; uint32 internal constant CCTP_VERSION_1 = 0; @@ -140,14 +145,14 @@ contract TokenBridgeCctpTest is Test { vm.deal(user, 1 ether); } - function _encodeCctpMessage( + function _encodeCctpBurnMessage( uint64 nonce, uint32 sourceDomain, bytes32 recipient, uint256 amount ) internal view returns (bytes memory) { return - _encodeCctpMessage( + _encodeCctpBurnMessage( nonce, sourceDomain, recipient, @@ -156,7 +161,7 @@ contract TokenBridgeCctpTest is Test { ); } - function _encodeCctpMessage( + function _encodeCctpBurnMessage( uint64 nonce, uint32 sourceDomain, bytes32 recipient, @@ -177,7 +182,7 @@ contract TokenBridgeCctpTest is Test { cctpDestination, nonce, address(tokenMessengerOrigin).addressToBytes32(), - address(tbDestination).addressToBytes32(), + address(tokenMessengerDestination).addressToBytes32(), bytes32(0), burnMessage ); @@ -272,7 +277,7 @@ contract TokenBridgeCctpTest is Test { _expectOffChainLookUpRevert(message); tbDestination.getOffchainVerifyInfo(message); - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( cctpNonce, cctpOrigin, recipient, @@ -291,6 +296,26 @@ contract TokenBridgeCctpTest is Test { assertEq(tbDestination.verify(metadata, message), true); } + function _upgrade(TokenBridgeCctp bridge) internal { + TokenBridgeCctp newImplementation = new TokenBridgeCctp( + address(bridge.wrappedToken()), + bridge.scale(), + address(bridge.mailbox()), + bridge.messageTransmitter(), + bridge.tokenMessenger() + ); + + bytes32 adminBytes = vm.load( + address(bridge), + bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) + ); + address admin = address(uint160(uint256(adminBytes))); + vm.prank(admin); + ITransparentUpgradeableProxy(address(bridge)).upgradeTo( + address(newImplementation) + ); + } + function testFork_verify() public { TokenBridgeCctp recipient = TokenBridgeCctp( 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 @@ -305,24 +330,7 @@ contract TokenBridgeCctpTest is Test { vm.expectRevert(); recipient.verify(metadata, message); - TokenBridgeCctp newImplementation = new TokenBridgeCctp( - address(recipient.wrappedToken()), - recipient.scale(), - address(recipient.mailbox()), - recipient.messageTransmitter(), - recipient.tokenMessenger() - ); - - bytes32 adminBytes = vm.load( - address(recipient), - bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) - ); - address admin = address(uint160(uint256(adminBytes))); - vm.prank(admin); - ITransparentUpgradeableProxy(address(recipient)).upgradeTo( - address(newImplementation) - ); - + _upgrade(recipient); assertEq(recipient.verify(metadata, message), true); } @@ -335,7 +343,7 @@ contract TokenBridgeCctpTest is Test { // invalid nonce := nextNonce + 1 uint64 badNonce = cctpNonce + 1; - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( badNonce, cctpOrigin, recipient, @@ -357,7 +365,7 @@ contract TokenBridgeCctpTest is Test { // invalid source domain := destination uint32 badSourceDomain = cctpDestination; - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( cctpNonce, badSourceDomain, recipient, @@ -370,7 +378,7 @@ contract TokenBridgeCctpTest is Test { tbDestination.verify(metadata, message); } - function test_verify_revertsWhen_invalidAmount() public { + function test_verify_revertsWhen_invalidMintAmount() public { ( bytes memory message, uint64 cctpNonce, @@ -379,7 +387,7 @@ contract TokenBridgeCctpTest is Test { // invalid amount := amount + 1 uint256 badAmount = amount + 1; - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( cctpNonce, cctpOrigin, recipient, @@ -388,16 +396,16 @@ contract TokenBridgeCctpTest is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid amount")); + vm.expectRevert(bytes("Invalid mint amount")); tbDestination.verify(metadata, message); } - function test_verify_revertsWhen_invalidRecipient() public { + function test_verify_revertsWhen_invalidMintRecipient() public { (bytes memory message, uint64 cctpNonce, ) = _setupAndDispatch(); // invalid recipient := evil bytes32 badRecipient = evil.addressToBytes32(); - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( cctpNonce, cctpOrigin, badRecipient, @@ -406,11 +414,11 @@ contract TokenBridgeCctpTest is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid recipient")); + vm.expectRevert(bytes("Invalid mint recipient")); tbDestination.verify(metadata, message); } - function test_verify_revertsWhen_invalidSender() public { + function test_verify_revertsWhen_invalidBurnSender() public { ( bytes memory message, uint64 cctpNonce, @@ -418,7 +426,7 @@ contract TokenBridgeCctpTest is Test { ) = _setupAndDispatch(); // invalid sender := evil - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( cctpNonce, cctpOrigin, recipient, @@ -428,7 +436,7 @@ contract TokenBridgeCctpTest is Test { bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid sender")); + vm.expectRevert(bytes("Invalid burn sender")); tbDestination.verify(metadata, message); } @@ -439,7 +447,7 @@ contract TokenBridgeCctpTest is Test { bytes32 recipient ) = _setupAndDispatch(); - bytes memory cctpMessage = _encodeCctpMessage( + bytes memory cctpMessage = _encodeCctpBurnMessage( cctpNonce, cctpOrigin, recipient, @@ -540,8 +548,279 @@ contract TokenBridgeCctpTest is Test { ); } - function test_parent_initialize_reverts() public { - vm.expectRevert("Only TokenBridgeCctp.initialize() may be called"); - tbOrigin.initialize(address(0), address(0), address(0)); + function test_postDispatch(bytes32 recipient, bytes calldata body) public { + // precompute message ID + bytes32 id = Message.id( + Message.formatMessage( + 3, + 0, + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ) + ); + + vm.expectCall( + address(messageTransmitterOrigin), + abi.encodeCall( + MockCircleMessageTransmitter.sendMessageWithCaller, + ( + cctpDestination, + address(tbDestination).addressToBytes32(), + address(tbDestination).addressToBytes32(), + abi.encode(id) + ) + ) + ); + bytes32 actualId = mailboxOrigin.dispatch( + destination, + recipient, + body, + bytes(""), + tbOrigin + ); + assertEq(actualId, id); + } + + function testFork_postDispatch( + bytes32 recipient, + bytes calldata body + ) public { + vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); + TokenBridgeCctp hook = TokenBridgeCctp( + 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 + ); + _upgrade(hook); + + IMailbox mailbox = hook.mailbox(); + uint32 destination = 1; // ethereum + uint32 origin = mailbox.localDomain(); + bytes32 router = hook.routers(destination); + + // precompute message ID + bytes memory message = Message.formatMessage( + 3, + mailbox.nonce(), + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ); + + bytes memory cctpMessage = CctpMessage._formatMessage( + 0, + hook.messageTransmitter().localDomain(), + hook.hyperlaneDomainToCircleDomain(destination), + hook.messageTransmitter().nextAvailableNonce(), + address(hook).addressToBytes32(), + router, + router, + abi.encode(Message.id(message)) + ); + + vm.expectEmit( + true, + true, + true, + true, + address(hook.messageTransmitter()) + ); + emit IMessageTransmitter.MessageSent(cctpMessage); + + mailbox.dispatch(destination, recipient, body, bytes(""), hook); + } + + function testFork_verifyDeployerMessage() public { + vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); + TokenBridgeCctp hook = TokenBridgeCctp( + 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 + ); + bytes32 router = hook.routers(1); + uint32 origin = hook.localDomain(); + + // https://basescan.org/tx/0x16b2c15cff779f16ab16a279a12c45a143047e680f8ed538318c7d67eed35569 + bytes + memory message = hex"03001661f000002105000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba00000001000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9badeadbeef"; + + // https://basescan.org/tx/0x4eeffc2aa410ede620d17ae18f513bf31941d301e8ada6676b54d3300dac116a + bytes + memory cctpMessage = hex"0000000000000006000000000000000000096af6000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8a331d7762c517834242bea4b027d3dcebbd32e7d312ef3dd7a9d73ced95f9adb"; + + // https://iris-api.circle.com/v1/messages/6/0x4eeffc2aa410ede620d17ae18f513bf31941d301e8ada6676b54d3300dac116a + bytes + memory attestation = hex"4a713f6935bf2f0a9b6aa01a9a5c1c4e0da23f858193f20fde96e814e63345d85a65b6f1f53f0b22cde3c611d03a032eab7ac4c26232f3a7ff9185c69ee205ee1b614fac487343203b8c6e2c210440576fbe64e7fb70de5f4be87291187604656d19c4ebc4dc33558d36e6e799fc8adca45f8b704cf6eecf3adf7254ad88d2efd41c"; + bytes memory metadata = abi.encode(cctpMessage, attestation); + + vm.createSelectFork(vm.rpcUrl("mainnet"), 22_898_879); + TokenBridgeCctp ism = TokenBridgeCctp(router.bytes32ToAddress()); + _upgrade(ism); + + vm.expectRevert(bytes("Invalid circle sender")); + ism.verify(metadata, message); + + // CCTP message was sent by deployer on origin chain + // enroll the deployer as the origin router + address deployer = 0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba; + vm.prank(ism.owner()); + ism.enrollRemoteRouter(origin, deployer.addressToBytes32()); + + vm.expectCall( + address(ism), + abi.encode(TokenBridgeCctp.handleReceiveMessage.selector) + ); + assert(ism.verify(metadata, message)); + } + + function test_postDispatch_revertsWhen_messageNotDispatched( + bytes32 recipient, + bytes calldata body + ) public { + bytes memory message = Message.formatMessage( + 3, + 0, + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ); + vm.expectRevert(bytes("Message not dispatched")); + tbOrigin.postDispatch(bytes(""), message); + } + + function test_verify_hookMessage(bytes calldata body) public { + TestRecipient recipient = new TestRecipient(); + recipient.setInterchainSecurityModule(address(tbDestination)); + + bytes32 id = mailboxOrigin.dispatch( + destination, + address(recipient).addressToBytes32(), + body, + bytes(""), + tbOrigin + ); + + bytes memory cctpMessage = CctpMessage._formatMessage( + version, + cctpOrigin, + cctpDestination, + tokenMessengerOrigin.nextNonce(), + address(tbOrigin).addressToBytes32(), + address(tbDestination).addressToBytes32(), + bytes32(0), // destinationCaller + abi.encode(id) + ); + + bytes memory attestation = bytes(""); + bytes memory metadata = abi.encode(cctpMessage, attestation); + mailboxDestination.addInboundMetadata(0, metadata); + + mailboxDestination.processNextInboundMessage(); + + assertEq(recipient.lastData(), body); + } + + function test_verify_revertsWhen_invalidMessageSender( + bytes32 recipient, + bytes calldata body + ) public { + bytes memory message = Message.formatMessage( + 3, + 0, + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ); + + bytes32 badSender = ~address(tbOrigin).addressToBytes32(); + + bytes memory cctpMessage = CctpMessage._formatMessage( + version, + cctpOrigin, + cctpDestination, + tokenMessengerOrigin.nextNonce(), + badSender, + address(tbDestination).addressToBytes32(), + bytes32(0), // destinationCaller + message + ); + + bytes memory attestation = bytes(""); + bytes memory metadata = abi.encode(cctpMessage, attestation); + + vm.expectRevert(bytes("Invalid circle sender")); + tbDestination.verify(metadata, message); + } + + function test_verify_revertsWhen_invalidMessageId( + bytes32 recipient, + bytes calldata body + ) public { + bytes memory message = Message.formatMessage( + 3, + 0, + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ); + bytes32 badMessageId = ~Message.id(message); + + bytes memory cctpMessage = CctpMessage._formatMessage( + version, + cctpOrigin, + cctpDestination, + tokenMessengerOrigin.nextNonce(), + address(tbOrigin).addressToBytes32(), + address(tbDestination).addressToBytes32(), + bytes32(0), // destinationCaller + abi.encode(badMessageId) + ); + + bytes memory attestation = bytes(""); + bytes memory metadata = abi.encode(cctpMessage, attestation); + + vm.expectRevert(bytes("Invalid message id")); + tbDestination.verify(metadata, message); + } + + function test_verify_revertsWhen_invalidMessageRecipient( + bytes32 recipient, + bytes calldata body + ) public { + bytes memory message = Message.formatMessage( + 3, + 0, + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ); + + address badRecipient = address(~bytes20(address(tbDestination))); + + bytes memory cctpMessage = CctpMessage._formatMessage( + version, + cctpOrigin, + cctpDestination, + tokenMessengerOrigin.nextNonce(), + address(tbOrigin).addressToBytes32(), + badRecipient.addressToBytes32(), + bytes32(0), // destinationCaller + abi.encode(Message.id(message)) + ); + + bytes memory attestation = bytes(""); + bytes memory metadata = abi.encode(cctpMessage, attestation); + + vm.expectRevert(bytes("Invalid circle recipient")); + tbDestination.verify(metadata, message); } } From 2c6506735d93051c8cb898d728d46257240424ba Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 14 Jul 2025 19:24:17 -0400 Subject: [PATCH 12/36] feat: support CCTP v2 fast transfers (#6709) --- .changeset/light-years-reply.md | 5 + .../interfaces/cctp/IMessageHandlerV2.sol | 57 ++ .../interfaces/cctp/IMessageTransmitterV2.sol | 64 ++ .../interfaces/cctp/ITokenMessenger.sol | 6 +- .../interfaces/cctp/ITokenMessengerV2.sol | 6 +- .../{CctpMessage.sol => CctpMessageV1.sol} | 4 +- solidity/contracts/libs/CctpMessageV2.sol | 286 ++++++++- .../mock/MockCircleMessageTransmitter.sol | 16 +- .../mock/MockCircleTokenMessenger.sol | 36 +- ...BridgeCctp.sol => TokenBridgeCctpBase.sol} | 200 ++---- .../contracts/token/TokenBridgeCctpV1.sol | 176 ++++++ .../contracts/token/TokenBridgeCctpV2.sol | 195 ++++++ solidity/test/token/TokenBridgeCctp.t.sol | 570 +++++++++++++++--- .../testnet4/warp/getCCTPConfig.ts | 1 + .../sdk/src/token/EvmERC20WarpRouteReader.ts | 55 +- typescript/sdk/src/token/contracts.ts | 13 +- typescript/sdk/src/token/deploy.ts | 69 ++- typescript/sdk/src/token/types.ts | 3 + 18 files changed, 1476 insertions(+), 286 deletions(-) create mode 100644 .changeset/light-years-reply.md create mode 100644 solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol create mode 100644 solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol rename solidity/contracts/libs/{CctpMessage.sol => CctpMessageV1.sol} (99%) rename solidity/contracts/token/{TokenBridgeCctp.sol => TokenBridgeCctpBase.sol} (60%) create mode 100644 solidity/contracts/token/TokenBridgeCctpV1.sol create mode 100644 solidity/contracts/token/TokenBridgeCctpV2.sol diff --git a/.changeset/light-years-reply.md b/.changeset/light-years-reply.md new file mode 100644 index 0000000000..a73dc189ae --- /dev/null +++ b/.changeset/light-years-reply.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": minor +--- + +Implement support for CCTP v2 fast transfers diff --git a/solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol b/solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol new file mode 100644 index 0000000000..cce4c9c070 --- /dev/null +++ b/solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +pragma solidity >=0.8.0; + +/** + * @title IMessageHandlerV2 + * @notice Handles messages on the destination domain, forwarded from + * an IReceiverV2. + */ +interface IMessageHandlerV2 { + /** + * @notice Handles an incoming finalized message from an IReceiverV2 + * @dev Finalized messages have finality threshold values greater than or equal to 2000 + * @param sourceDomain The source domain of the message + * @param sender The sender of the message + * @param finalityThresholdExecuted the finality threshold at which the message was attested to + * @param messageBody The raw bytes of the message body + * @return success True, if successful; false, if not. + */ + function handleReceiveFinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes calldata messageBody + ) external returns (bool); + + /** + * @notice Handles an incoming unfinalized message from an IReceiverV2 + * @dev Unfinalized messages have finality threshold values less than 2000 + * @param sourceDomain The source domain of the message + * @param sender The sender of the message + * @param finalityThresholdExecuted The finality threshold at which the message was attested to + * @param messageBody The raw bytes of the message body + * @return success True, if successful; false, if not. + */ + function handleReceiveUnfinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes calldata messageBody + ) external returns (bool); +} diff --git a/solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol b/solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol new file mode 100644 index 0000000000..59205a8594 --- /dev/null +++ b/solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +pragma solidity >=0.8.0; + +import {IMessageTransmitter} from "./IMessageTransmitter.sol"; +import {IReceiver} from "./IMessageTransmitter.sol"; + +/** + * @title IReceiverV2 + * @notice Receives messages on the destination chain and forwards them to contracts implementing + * IMessageHandlerV2. + */ +interface IReceiverV2 is IReceiver {} + +/** + * @title IRelayerV2 + * @notice Sends messages from the source domain to the destination domain + */ +interface IRelayerV2 { + /** + * @notice Sends an outgoing message from the source domain. + * @dev Emits a `MessageSent` event with message information. + * WARNING: if the `destinationCaller` does not represent a valid address as bytes32, then it will not be possible + * to broadcast the message on the destination domain. If set to bytes32(0), anyone will be able to broadcast it. + * This is an advanced feature, and using bytes32(0) should be preferred for use cases where a specific destination caller is not required. + * @param destinationDomain Domain of destination chain + * @param recipient Address of message recipient on destination domain as bytes32 + * @param destinationCaller Allowed caller on destination domain (see above WARNING). + * @param minFinalityThreshold Minimum finality threshold at which the message must be attested to. + * @param messageBody Content of the message, as raw bytes + */ + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes calldata messageBody + ) external; +} + +/** + * @title IMessageTransmitterV2 + * @notice Interface for V2 message transmitters, which both relay and receive messages. + */ +interface IMessageTransmitterV2 is + IRelayerV2, + IReceiverV2, + IMessageTransmitter +{} diff --git a/solidity/contracts/interfaces/cctp/ITokenMessenger.sol b/solidity/contracts/interfaces/cctp/ITokenMessenger.sol index 7a1ff729a9..e6815731c5 100644 --- a/solidity/contracts/interfaces/cctp/ITokenMessenger.sol +++ b/solidity/contracts/interfaces/cctp/ITokenMessenger.sol @@ -2,6 +2,10 @@ pragma solidity ^0.8.0; interface ITokenMessenger { + function messageBodyVersion() external returns (uint32); +} + +interface ITokenMessengerV1 is ITokenMessenger { event DepositForBurn( uint64 indexed nonce, address indexed burnToken, @@ -19,6 +23,4 @@ interface ITokenMessenger { bytes32 mintRecipient, address burnToken ) external returns (uint64 _nonce); - - function messageBodyVersion() external returns (uint32); } diff --git a/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol b/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol index c5985dd5e2..ab2348f9c8 100644 --- a/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol +++ b/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -interface ITokenMessengerV2 { +import {ITokenMessenger} from "./ITokenMessenger.sol"; + +interface ITokenMessengerV2 is ITokenMessenger { event DepositForBurn( address indexed burnToken, uint256 amount, @@ -24,6 +26,4 @@ interface ITokenMessengerV2 { uint256 maxFee, uint32 minFinalityThreshold ) external; - - function messageBodyVersion() external returns (uint32); } diff --git a/solidity/contracts/libs/CctpMessage.sol b/solidity/contracts/libs/CctpMessageV1.sol similarity index 99% rename from solidity/contracts/libs/CctpMessage.sol rename to solidity/contracts/libs/CctpMessageV1.sol index 8841e6ac65..3740f3b5af 100644 --- a/solidity/contracts/libs/CctpMessage.sol +++ b/solidity/contracts/libs/CctpMessageV1.sol @@ -40,7 +40,7 @@ import {TypedMemView} from "./TypedMemView.sol"; * messageBody dynamic bytes 116 * **/ -library CctpMessage { +library CctpMessageV1 { using TypedMemView for bytes; using TypedMemView for bytes29; @@ -182,7 +182,7 @@ library CctpMessage { * amount 32 uint256 68 * messageSender 32 bytes32 100 **/ -library BurnMessage { +library BurnMessageV1 { using TypedMemView for bytes; using TypedMemView for bytes29; diff --git a/solidity/contracts/libs/CctpMessageV2.sol b/solidity/contracts/libs/CctpMessageV2.sol index f653ff0467..6e9c64d747 100644 --- a/solidity/contracts/libs/CctpMessageV2.sol +++ b/solidity/contracts/libs/CctpMessageV2.sol @@ -20,17 +20,301 @@ pragma solidity >=0.8.0; import {TypedMemView} from "./TypedMemView.sol"; // @dev copied from https://github.com/circlefin/evm-cctp-contracts/blob/release-2025-03-11T143015/src/messages/v2/MessageV2.sol -// @dev We need only source domain and nonce which have the same indexes of Cctp message version 1 // @dev We are using the 'latest-solidity' branch for @memview-sol, which supports solidity version // greater or equal than 0.8.0 + +/** + * @title MessageV2 Library + * @notice Library for formatted v2 messages used by Relayer and Receiver. + * + * @dev The message body is dynamically-sized to support custom message body + * formats. Other fields must be fixed-size to avoid hash collisions. + * Each other input value has an explicit type to guarantee fixed-size. + * Padding: uintNN fields are left-padded, and bytesNN fields are right-padded. + * + * Field Bytes Type Index + * version 4 uint32 0 + * sourceDomain 4 uint32 4 + * destinationDomain 4 uint32 8 + * nonce 32 bytes32 12 + * sender 32 bytes32 44 + * recipient 32 bytes32 76 + * destinationCaller 32 bytes32 108 + * minFinalityThreshold 4 uint32 140 + * finalityThresholdExecuted 4 uint32 144 + * messageBody dynamic bytes 148 + * @dev Differences from v1: + * - Nonce is now bytes32 (vs. uint64) + * - minFinalityThreshold added + * - finalityThresholdExecuted added + **/ library CctpMessageV2 { using TypedMemView for bytes; using TypedMemView for bytes29; // Indices of each field in message + uint8 private constant VERSION_INDEX = 0; + uint8 private constant SOURCE_DOMAIN_INDEX = 4; + uint8 private constant DESTINATION_DOMAIN_INDEX = 8; uint8 private constant NONCE_INDEX = 12; + uint8 private constant SENDER_INDEX = 44; + uint8 private constant RECIPIENT_INDEX = 76; + uint8 private constant DESTINATION_CALLER_INDEX = 108; + uint8 private constant MIN_FINALITY_THRESHOLD_INDEX = 140; + uint8 private constant FINALITY_THRESHOLD_EXECUTED_INDEX = 144; + uint8 private constant MESSAGE_BODY_INDEX = 148; + + bytes32 private constant EMPTY_NONCE = bytes32(0); + uint32 private constant EMPTY_FINALITY_THRESHOLD_EXECUTED = 0; + + /** + * @notice Returns formatted (packed) message with provided fields + * @param _version the version of the message format + * @param _sourceDomain Domain of home chain + * @param _destinationDomain Domain of destination chain + * @param _sender Address of sender on source chain as bytes32 + * @param _recipient Address of recipient on destination chain as bytes32 + * @param _destinationCaller Address of caller on destination chain as bytes32 + * @param _minFinalityThreshold the minimum finality at which the message should be attested to + * @param _messageBody Raw bytes of message body + * @return Formatted message + **/ + function _formatMessageForRelay( + uint32 _version, + uint32 _sourceDomain, + uint32 _destinationDomain, + bytes32 _sender, + bytes32 _recipient, + bytes32 _destinationCaller, + uint32 _minFinalityThreshold, + bytes memory _messageBody + ) internal pure returns (bytes memory) { + return + abi.encodePacked( + _version, + _sourceDomain, + _destinationDomain, + EMPTY_NONCE, + _sender, + _recipient, + _destinationCaller, + _minFinalityThreshold, + EMPTY_FINALITY_THRESHOLD_EXECUTED, + _messageBody + ); + } + + // @notice Returns _message's version field + function _getVersion(bytes29 _message) internal pure returns (uint32) { + return uint32(_message.indexUint(VERSION_INDEX, 4)); + } + + // @notice Returns _message's sourceDomain field + function _getSourceDomain(bytes29 _message) internal pure returns (uint32) { + return uint32(_message.indexUint(SOURCE_DOMAIN_INDEX, 4)); + } + // @notice Returns _message's destinationDomain field + function _getDestinationDomain( + bytes29 _message + ) internal pure returns (uint32) { + return uint32(_message.indexUint(DESTINATION_DOMAIN_INDEX, 4)); + } + + // @notice Returns _message's nonce field function _getNonce(bytes29 _message) internal pure returns (bytes32) { return _message.index(NONCE_INDEX, 32); } + + // @notice Returns _message's sender field + function _getSender(bytes29 _message) internal pure returns (bytes32) { + return _message.index(SENDER_INDEX, 32); + } + + // @notice Returns _message's recipient field + function _getRecipient(bytes29 _message) internal pure returns (bytes32) { + return _message.index(RECIPIENT_INDEX, 32); + } + + // @notice Returns _message's destinationCaller field + function _getDestinationCaller( + bytes29 _message + ) internal pure returns (bytes32) { + return _message.index(DESTINATION_CALLER_INDEX, 32); + } + + // @notice Returns _message's minFinalityThreshold field + function _getMinFinalityThreshold( + bytes29 _message + ) internal pure returns (uint32) { + return uint32(_message.indexUint(MIN_FINALITY_THRESHOLD_INDEX, 4)); + } + + // @notice Returns _message's finalityThresholdExecuted field + function _getFinalityThresholdExecuted( + bytes29 _message + ) internal pure returns (uint32) { + return uint32(_message.indexUint(FINALITY_THRESHOLD_EXECUTED_INDEX, 4)); + } + + // @notice Returns _message's messageBody field + function _getMessageBody(bytes29 _message) internal pure returns (bytes29) { + return + _message.slice( + MESSAGE_BODY_INDEX, + _message.len() - MESSAGE_BODY_INDEX, + 0 + ); + } + + /** + * @notice Reverts if message is malformed or too short + * @param _message The message as bytes29 + */ + function _validateMessageFormat(bytes29 _message) internal pure { + require(_message.isValid(), "Malformed message"); + require( + _message.len() >= MESSAGE_BODY_INDEX, + "Invalid message: too short" + ); + } +} + +import {BurnMessageV1} from "./CctpMessageV1.sol"; + +/** + * @title BurnMessageV2 Library + * @notice Library for formatted V2 BurnMessages used by TokenMessengerV2. + * @dev BurnMessageV2 format: + * Field Bytes Type Index + * version 4 uint32 0 + * burnToken 32 bytes32 4 + * mintRecipient 32 bytes32 36 + * amount 32 uint256 68 + * messageSender 32 bytes32 100 + * maxFee 32 uint256 132 + * feeExecuted 32 uint256 164 + * expirationBlock 32 uint256 196 + * hookData dynamic bytes 228 + * @dev Additions from v1: + * - maxFee + * - feeExecuted + * - expirationBlock + * - hookData + **/ +library BurnMessageV2 { + using TypedMemView for bytes; + using TypedMemView for bytes29; + using BurnMessageV1 for bytes29; + + // Field indices + uint8 private constant MAX_FEE_INDEX = 132; + uint8 private constant FEE_EXECUTED_INDEX = 164; + uint8 private constant EXPIRATION_BLOCK_INDEX = 196; + uint8 private constant HOOK_DATA_INDEX = 228; + + uint256 private constant EMPTY_FEE_EXECUTED = 0; + uint256 private constant EMPTY_EXPIRATION_BLOCK = 0; + + /** + * @notice Formats a V2 burn message + * @param _version The message body version + * @param _burnToken The burn token address on the source domain, as bytes32 + * @param _mintRecipient The mint recipient address as bytes32 + * @param _amount The burn amount + * @param _messageSender The message sender + * @param _maxFee The maximum fee to be paid on destination domain + * @param _hookData Optional hook data for processing on the destination domain + * @return Formatted message bytes. + */ + function _formatMessageForRelay( + uint32 _version, + bytes32 _burnToken, + bytes32 _mintRecipient, + uint256 _amount, + bytes32 _messageSender, + uint256 _maxFee, + bytes memory _hookData + ) internal pure returns (bytes memory) { + return + abi.encodePacked( + _version, + _burnToken, + _mintRecipient, + _amount, + _messageSender, + _maxFee, + EMPTY_FEE_EXECUTED, + EMPTY_EXPIRATION_BLOCK, + _hookData + ); + } + + // @notice Returns _message's version field + function _getVersion(bytes29 _message) internal pure returns (uint32) { + return _message._getVersion(); + } + + // @notice Returns _message's burnToken field + function _getBurnToken(bytes29 _message) internal pure returns (bytes32) { + return _message._getBurnToken(); + } + + // @notice Returns _message's mintRecipient field + function _getMintRecipient( + bytes29 _message + ) internal pure returns (bytes32) { + return _message._getMintRecipient(); + } + + // @notice Returns _message's amount field + function _getAmount(bytes29 _message) internal pure returns (uint256) { + return _message._getAmount(); + } + + // @notice Returns _message's messageSender field + function _getMessageSender( + bytes29 _message + ) internal pure returns (bytes32) { + return _message._getMessageSender(); + } + + // @notice Returns _message's maxFee field + function _getMaxFee(bytes29 _message) internal pure returns (uint256) { + return _message.indexUint(MAX_FEE_INDEX, 32); + } + + // @notice Returns _message's feeExecuted field + function _getFeeExecuted(bytes29 _message) internal pure returns (uint256) { + return _message.indexUint(FEE_EXECUTED_INDEX, 32); + } + + // @notice Returns _message's expirationBlock field + function _getExpirationBlock( + bytes29 _message + ) internal pure returns (uint256) { + return _message.indexUint(EXPIRATION_BLOCK_INDEX, 32); + } + + // @notice Returns _message's hookData field + function _getHookData(bytes29 _message) internal pure returns (bytes29) { + return + _message.slice( + HOOK_DATA_INDEX, + _message.len() - HOOK_DATA_INDEX, + 0 + ); + } + + /** + * @notice Reverts if burn message is malformed or invalid length + * @param _message The burn message as bytes29 + */ + function _validateBurnMessageFormat(bytes29 _message) internal pure { + require(_message.isValid(), "Malformed message"); + require( + _message.len() >= HOOK_DATA_INDEX, + "Invalid burn message: too short" + ); + } } diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index 83c1a3fac4..fa35f16363 100644 --- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol +++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol @@ -2,9 +2,13 @@ pragma solidity ^0.8.13; import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; +import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; import {MockToken} from "./MockToken.sol"; -contract MockCircleMessageTransmitter is IMessageTransmitter { +contract MockCircleMessageTransmitter is + IMessageTransmitter, + IMessageTransmitterV2 +{ mapping(bytes32 => bool) processedNonces; MockToken token; uint32 public version; @@ -82,4 +86,14 @@ contract MockCircleMessageTransmitter is IMessageTransmitter { ) external returns (uint64) { return sendMessage(0, 0, message); } + + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes32, + uint32, + bytes calldata messageBody + ) external { + sendMessage(destinationDomain, recipient, messageBody); + } } diff --git a/solidity/contracts/mock/MockCircleTokenMessenger.sol b/solidity/contracts/mock/MockCircleTokenMessenger.sol index c4b8192ba3..b6570271ed 100644 --- a/solidity/contracts/mock/MockCircleTokenMessenger.sol +++ b/solidity/contracts/mock/MockCircleTokenMessenger.sol @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; -import {ITokenMessenger} from "../interfaces/cctp/ITokenMessenger.sol"; +import {ITokenMessenger, ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol"; import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; import {MockToken} from "./MockToken.sol"; -contract MockCircleTokenMessenger is ITokenMessenger { +contract MockCircleTokenMessenger is ITokenMessengerV1, ITokenMessengerV2 { uint64 public nextNonce = 0; MockToken token; + uint32 public version; constructor(MockToken _token) { token = _token; @@ -18,7 +19,7 @@ contract MockCircleTokenMessenger is ITokenMessenger { uint32, bytes32, address _burnToken - ) external returns (uint64 _nonce) { + ) public returns (uint64 _nonce) { _nonce = nextNonce; nextNonce += 1; require(address(token) == _burnToken); @@ -27,27 +28,21 @@ contract MockCircleTokenMessenger is ITokenMessenger { } function depositForBurnWithCaller( - uint256, + uint256 _amount, uint32, bytes32, - address, + address _burnToken, bytes32 ) external returns (uint64 _nonce) { - _nonce = nextNonce; - nextNonce += 1; + depositForBurn(_amount, 0, 0, _burnToken); } - function messageBodyVersion() external returns (uint32) { - return 0; + function messageBodyVersion() external override returns (uint32) { + return version; } -} - -contract MockCircleTokenMessengerV2 is ITokenMessengerV2 { - uint64 public nextNonce = 0; - MockToken token; - constructor(MockToken _token) { - token = _token; + function setVersion(uint32 _version) external { + version = _version; } function depositForBurn( @@ -59,13 +54,6 @@ contract MockCircleTokenMessengerV2 is ITokenMessengerV2 { uint256, uint32 ) external { - nextNonce += 1; - require(address(token) == _burnToken); - token.transferFrom(msg.sender, address(this), _amount); - token.burn(_amount); - } - - function messageBodyVersion() external returns (uint32) { - return 1; + depositForBurn(_amount, 0, 0, _burnToken); } } diff --git a/solidity/contracts/token/TokenBridgeCctp.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol similarity index 60% rename from solidity/contracts/token/TokenBridgeCctp.sol rename to solidity/contracts/token/TokenBridgeCctpBase.sol index d90551589f..19d2481150 100644 --- a/solidity/contracts/token/TokenBridgeCctp.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -10,7 +10,6 @@ import {TypedMemView} from "./../libs/TypedMemView.sol"; import {ITokenMessenger} from "./../interfaces/cctp/ITokenMessenger.sol"; import {Message} from "./../libs/Message.sol"; import {TokenMessage} from "./libs/TokenMessage.sol"; -import {CctpMessage, BurnMessage} from "../libs/CctpMessage.sol"; import {IPostDispatchHook} from "../interfaces/hooks/IPostDispatchHook.sol"; import {StandardHookMetadata} from "../hooks/libs/StandardHookMetadata.sol"; import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; @@ -30,29 +29,17 @@ interface CctpService { returns (bytes memory cctpMessage, bytes memory attestation); } -// TokenMessage.metadata := uint8 cctpNonce -uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET + - 8; - -// @dev Supports only CCTP V1 -contract TokenBridgeCctp is +abstract contract TokenBridgeCctpBase is MovableCollateralRouter, AbstractCcipReadIsm, - IPostDispatchHook, - IMessageHandler + IPostDispatchHook { - using CctpMessage for bytes29; - using BurnMessage for bytes29; - using TypedMemView for bytes29; - using Message for bytes; using TypeCasts for bytes32; using SafeERC20 for IERC20; IERC20 public immutable wrappedToken; - uint32 internal constant CCTP_VERSION = 0; - // @notice CCTP message transmitter contract IMessageTransmitter public immutable messageTransmitter; @@ -83,13 +70,13 @@ contract TokenBridgeCctp is ITokenMessenger _tokenMessenger ) FungibleTokenRouter(_scale, _mailbox) { require( - _messageTransmitter.version() == CCTP_VERSION, + _messageTransmitter.version() == _getCCTPVersion(), "Invalid messageTransmitter CCTP version" ); messageTransmitter = _messageTransmitter; require( - _tokenMessenger.messageBodyVersion() == CCTP_VERSION, + _tokenMessenger.messageBodyVersion() == _getCCTPVersion(), "Invalid TokenMessenger CCTP version" ); tokenMessenger = _tokenMessenger; @@ -157,6 +144,36 @@ contract TokenBridgeCctp is return domain.circle; } + function _getCCTPVersion() internal pure virtual returns (uint32); + + function _getCircleRecipient( + bytes29 cctpMessage + ) internal pure virtual returns (address); + + function _getCircleSource( + bytes29 cctpMessage + ) internal pure virtual returns (uint32); + + function _getCircleNonce( + bytes29 cctpMessage + ) internal pure virtual returns (bytes32); + + function _validateTokenMessage( + bytes calldata hyperlaneMessage, + bytes29 cctpMessage + ) internal pure virtual; + + function _validateHookMessage( + bytes calldata hyperlaneMessage, + bytes29 cctpMessage + ) internal view virtual; + + function _sendMessageIdToIsm( + uint32 destinationDomain, + bytes32 ism, + bytes32 messageId + ) internal virtual; + // @dev Enforces that the CCTP message source domain and nonce matches the Hyperlane message origin and nonce. function verify( bytes calldata _metadata, @@ -172,58 +189,29 @@ contract TokenBridgeCctp is // check if CCTP message source matches the hyperlane message origin uint32 origin = _hyperlaneMessage.origin(); - uint32 sourceDomain = cctpMessage._sourceDomain(); + uint32 sourceDomain = _getCircleSource(cctpMessage); require( sourceDomain == hyperlaneDomainToCircleDomain(origin), "Invalid source domain" ); - uint64 sourceNonce = cctpMessage._nonce(); - - address cctpMessageRecipient = cctpMessage - ._recipient() - .bytes32ToAddress(); + address circleRecipient = _getCircleRecipient(cctpMessage); // check if CCTP message is a USDC burn message - if (cctpMessageRecipient == address(tokenMessenger)) { - bytes29 burnMessage = cctpMessage._messageBody(); - - // check that burner matches the sender of the hyperlane message (token router) - bytes32 circleBurnSender = burnMessage._getMessageSender(); - require( - circleBurnSender == _hyperlaneMessage.sender(), - "Invalid burn sender" - ); - - _validateTokenMessage( - _hyperlaneMessage.body(), - sourceNonce, - burnMessage - ); - // check if CCTP message is a GMP message to this contract - } else if (cctpMessageRecipient == address(this)) { - // check that sender matches the origin router - bytes32 cctpMessageSender = cctpMessage._sender(); - require( - cctpMessageSender == _mustHaveRemoteRouter(origin), - "Invalid circle sender" - ); - - // check that the body matches the hyperlane message ID - bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32); - require( - circleMessageId == _hyperlaneMessage.id(), - "Invalid message id" - ); - // do not allow other CCTP message types - } else { + if (circleRecipient == address(tokenMessenger)) { + _validateTokenMessage(_hyperlaneMessage, cctpMessage); + } + // check if CCTP message is a GMP message to this contract + else if (circleRecipient == address(this)) { + _validateHookMessage(_hyperlaneMessage, cctpMessage); + } + // disallow other CCTP message destinations + else { revert("Invalid circle recipient"); } + bytes32 nonce = _getCircleNonce(cctpMessage); // Receive only if the nonce hasn't been used before - bytes32 sourceAndNonceHash = keccak256( - abi.encodePacked(sourceDomain, sourceNonce) - ); - if (messageTransmitter.usedNonces(sourceAndNonceHash) == 0) { + if (messageTransmitter.usedNonces(nonce) == 0) { require( messageTransmitter.receiveMessage( cctpMessageBytes, @@ -236,6 +224,12 @@ contract TokenBridgeCctp is return true; } + function _offchainLookupCalldata( + bytes calldata _message + ) internal pure override returns (bytes memory) { + return abi.encodeCall(CctpService.getCCTPAttestation, (_message)); + } + /// @inheritdoc IPostDispatchHook function hookType() external pure override returns (uint8) { return uint8(IPostDispatchHook.HookTypes.CCTP); @@ -261,62 +255,14 @@ contract TokenBridgeCctp is bytes calldata /*metadata*/, bytes calldata message ) external payable override { - require(_isLatestDispatched(message.id()), "Message not dispatched"); + bytes32 id = message.id(); + require(_isLatestDispatched(id), "Message not dispatched"); uint32 destination = message.destination(); bytes32 ism = _mustHaveRemoteRouter(destination); uint32 circleDestination = hyperlaneDomainToCircleDomain(destination); - messageTransmitter.sendMessageWithCaller({ - destinationDomain: circleDestination, - // recipient must be this implementation with `handleReceiveMessage` - recipient: ism, - // enforces that only the enrolled ISM's verify() can deliver the CCTP message - destinationCaller: ism, - messageBody: abi.encode(message.id()) - }); - } - - /// @inheritdoc IMessageHandler - function handleReceiveMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, - bytes calldata /*body*/ - ) external override returns (bool) { - return msg.sender == address(messageTransmitter); - } - - function _validateMessageLength(bytes memory _tokenMessage) internal pure { - require( - _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, - "Invalid message body length" - ); - } - - // @dev Validates that the CCTP message nonce and burn message fields match the hyperlane token router message - function _validateTokenMessage( - bytes calldata tokenMessage, - uint64 circleNonce, - bytes29 circleBody - ) internal pure { - circleBody._validateBurnMessageFormat(); - _validateMessageLength(tokenMessage); - - require( - uint64(bytes8(TokenMessage.metadata(tokenMessage))) == circleNonce, - "Invalid nonce" - ); - - require( - TokenMessage.amount(tokenMessage) == circleBody._getAmount(), - "Invalid mint amount" - ); - - require( - TokenMessage.recipient(tokenMessage) == - circleBody._getMintRecipient(), - "Invalid mint recipient" - ); + _sendMessageIdToIsm(circleDestination, ism, id); } // @dev Copied from HypERC20Collateral._transferFromSender @@ -324,40 +270,6 @@ contract TokenBridgeCctp is wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); } - function _beforeDispatch( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) - internal - virtual - override - returns (uint256 dispatchValue, bytes memory message) - { - dispatchValue = _chargeSender(_destination, _recipient, _amount); - - uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination); - uint64 nonce = tokenMessenger.depositForBurn( - _amount, - circleDomain, - _recipient, - address(wrappedToken) - ); - - message = TokenMessage.format( - _recipient, - _outboundAmount(_amount), - abi.encodePacked(nonce) - ); - _validateMessageLength(message); - } - - function _offchainLookupCalldata( - bytes calldata _message - ) internal pure override returns (bytes memory) { - return abi.encodeCall(CctpService.getCCTPAttestation, (_message)); - } - function _transferTo( address _recipient, uint256 _amount diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol new file mode 100644 index 0000000000..26011b0293 --- /dev/null +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {TokenBridgeCctpBase} from "./TokenBridgeCctpBase.sol"; +import {TypedMemView} from "./../libs/TypedMemView.sol"; +import {Message} from "./../libs/Message.sol"; +import {TokenMessage} from "./libs/TokenMessage.sol"; +import {CctpMessageV1, BurnMessageV1} from "../libs/CctpMessageV1.sol"; +import {TypeCasts} from "../libs/TypeCasts.sol"; +import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; +import {ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol"; +import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; + +// TokenMessage.metadata := uint8 cctpNonce +uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET + + 8; + +// @dev Supports only CCTP V1 +contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { + using CctpMessageV1 for bytes29; + using BurnMessageV1 for bytes29; + using TypedMemView for bytes29; + + using Message for bytes; + using TypeCasts for bytes32; + + constructor( + address _erc20, + uint256 _scale, + address _mailbox, + IMessageTransmitter _messageTransmitter, + ITokenMessengerV1 _tokenMessenger + ) + TokenBridgeCctpBase( + _erc20, + _scale, + _mailbox, + _messageTransmitter, + _tokenMessenger + ) + {} + + function _getCCTPVersion() internal pure override returns (uint32) { + return 0; + } + + function _getCircleRecipient( + bytes29 cctpMessage + ) internal pure override returns (address) { + return cctpMessage._recipient().bytes32ToAddress(); + } + + function _getCircleNonce( + bytes29 cctpMessage + ) internal pure override returns (bytes32) { + bytes32 sourceAndNonceHash = keccak256( + abi.encodePacked(cctpMessage._sourceDomain(), cctpMessage._nonce()) + ); + return sourceAndNonceHash; + } + + function _getCircleSource( + bytes29 cctpMessage + ) internal pure override returns (uint32) { + return cctpMessage._sourceDomain(); + } + + function _validateTokenMessage( + bytes calldata hyperlaneMessage, + bytes29 cctpMessage + ) internal pure override { + bytes29 burnMessage = cctpMessage._messageBody(); + burnMessage._validateBurnMessageFormat(); + + bytes32 circleBurnSender = burnMessage._getMessageSender(); + require( + circleBurnSender == hyperlaneMessage.sender(), + "Invalid burn sender" + ); + + bytes calldata tokenMessage = hyperlaneMessage.body(); + _validateTokenMessageLength(tokenMessage); + + require( + uint64(bytes8(TokenMessage.metadata(tokenMessage))) == + cctpMessage._nonce(), + "Invalid nonce" + ); + + require( + TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), + "Invalid mint amount" + ); + + require( + TokenMessage.recipient(tokenMessage) == + burnMessage._getMintRecipient(), + "Invalid mint recipient" + ); + } + + function _validateHookMessage( + bytes calldata hyperlaneMessage, + bytes29 cctpMessage + ) internal view override { + bytes32 circleSender = cctpMessage._sender(); + require( + circleSender == _mustHaveRemoteRouter(hyperlaneMessage.origin()), + "Invalid circle sender" + ); + + bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32); + require(circleMessageId == hyperlaneMessage.id(), "Invalid message id"); + } + + /// @inheritdoc IMessageHandler + function handleReceiveMessage( + uint32 /*sourceDomain*/, + bytes32 /*sender*/, + bytes calldata /*body*/ + ) external pure override returns (bool) { + return true; + } + + function _sendMessageIdToIsm( + uint32 destinationDomain, + bytes32 ism, + bytes32 messageId + ) internal override { + IMessageTransmitter(messageTransmitter).sendMessageWithCaller( + destinationDomain, + ism, + ism, + abi.encode(messageId) + ); + } + + function _validateTokenMessageLength( + bytes memory _tokenMessage + ) internal pure { + require( + _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, + "Invalid message body length" + ); + } + + function _beforeDispatch( + uint32 destination, + bytes32 recipient, + uint256 amount + ) + internal + virtual + override + returns (uint256 dispatchValue, bytes memory message) + { + dispatchValue = _chargeSender(destination, recipient, amount); + + uint32 circleDomain = hyperlaneDomainToCircleDomain(destination); + + uint64 nonce = ITokenMessengerV1(address(tokenMessenger)) + .depositForBurn( + amount, + circleDomain, + recipient, + address(wrappedToken) + ); + + message = TokenMessage.format( + recipient, + _outboundAmount(amount), + abi.encodePacked(nonce) + ); + _validateTokenMessageLength(message); + } +} diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol new file mode 100644 index 0000000000..fe285964cd --- /dev/null +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {TokenBridgeCctpBase} from "./TokenBridgeCctpBase.sol"; +import {TypedMemView} from "./../libs/TypedMemView.sol"; +import {Message} from "./../libs/Message.sol"; +import {TokenMessage} from "./libs/TokenMessage.sol"; +import {CctpMessageV2, BurnMessageV2} from "../libs/CctpMessageV2.sol"; +import {TypeCasts} from "../libs/TypeCasts.sol"; +import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; +import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; +import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; + +// TokenMessage.metadata := null +uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET; + +// @dev Supports only CCTP V2 +contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { + using CctpMessageV2 for bytes29; + using BurnMessageV2 for bytes29; + using TypedMemView for bytes29; + + using Message for bytes; + using TypeCasts for bytes32; + + // see https://developers.circle.com/cctp/cctp-finality-and-fees#defined-finality-thresholds + uint32 public immutable minFinalityThreshold; + uint256 public immutable maxFeeBps; + + constructor( + address _erc20, + uint256 _scale, + address _mailbox, + IMessageTransmitterV2 _messageTransmitter, + ITokenMessengerV2 _tokenMessenger, + uint256 _maxFeeBps, + uint32 _minFinalityThreshold + ) + TokenBridgeCctpBase( + _erc20, + _scale, + _mailbox, + _messageTransmitter, + _tokenMessenger + ) + { + maxFeeBps = _maxFeeBps; + minFinalityThreshold = _minFinalityThreshold; + } + + function _getCCTPVersion() internal pure override returns (uint32) { + return 1; + } + + function _getCircleRecipient( + bytes29 cctpMessage + ) internal pure override returns (address) { + return cctpMessage._getRecipient().bytes32ToAddress(); + } + + function _getCircleNonce( + bytes29 cctpMessage + ) internal pure override returns (bytes32) { + return cctpMessage._getNonce(); + } + + function _getCircleSource( + bytes29 cctpMessage + ) internal pure override returns (uint32) { + return cctpMessage._getSourceDomain(); + } + + function _validateTokenMessageLength( + bytes memory tokenMessage + ) internal pure { + require( + tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, + "Invalid message length" + ); + } + + function _validateTokenMessage( + bytes calldata hyperlaneMessage, + bytes29 cctpMessage + ) internal pure override { + bytes29 burnMessage = cctpMessage._getMessageBody(); + burnMessage._validateBurnMessageFormat(); + + bytes32 circleBurnSender = burnMessage._getMessageSender(); + require( + circleBurnSender == hyperlaneMessage.sender(), + "Invalid burn sender" + ); + + bytes calldata tokenMessage = hyperlaneMessage.body(); + _validateTokenMessageLength(tokenMessage); + + require( + TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), + "Invalid mint amount" + ); + + require( + TokenMessage.recipient(tokenMessage) == + burnMessage._getMintRecipient(), + "Invalid mint recipient" + ); + } + + function _validateHookMessage( + bytes calldata hyperlaneMessage, + bytes29 cctpMessage + ) internal view override { + bytes32 circleSender = cctpMessage._getSender(); + require( + circleSender == _mustHaveRemoteRouter(hyperlaneMessage.origin()), + "Invalid circle sender" + ); + + bytes32 circleMessageId = cctpMessage._getMessageBody().index(0, 32); + require(circleMessageId == hyperlaneMessage.id(), "Invalid message id"); + } + + // @inheritdoc IMessageHandlerV2 + function handleReceiveFinalizedMessage( + uint32 /*sourceDomain*/, + bytes32 /*sender*/, + uint32 /*finalityThresholdExecuted*/, + bytes calldata /*messageBody*/ + ) external pure override returns (bool) { + return true; + } + + // @inheritdoc IMessageHandlerV2 + function handleReceiveUnfinalizedMessage( + uint32 /*sourceDomain*/, + bytes32 /*sender*/, + uint32 /*finalityThresholdExecuted*/, + bytes calldata /*messageBody*/ + ) external pure override returns (bool) { + return true; + } + + function _sendMessageIdToIsm( + uint32 destinationDomain, + bytes32 ism, + bytes32 messageId + ) internal override { + IMessageTransmitterV2(address(messageTransmitter)).sendMessage( + destinationDomain, + ism, + ism, + minFinalityThreshold, + abi.encode(messageId) + ); + } + + function _feeAmount( + uint32 destination, + bytes32 recipient, + uint256 amount + ) internal view override returns (uint256 feeAmount) { + return (amount * maxFeeBps) / 10_000; + } + + function _beforeDispatch( + uint32 destination, + bytes32 recipient, + uint256 amount + ) + internal + virtual + override + returns (uint256 dispatchValue, bytes memory message) + { + uint256 fastFee = _feeAmount(destination, recipient, amount); + _transferFromSender(amount + fastFee); + + uint32 circleDomain = hyperlaneDomainToCircleDomain(destination); + + ITokenMessengerV2(address(tokenMessenger)).depositForBurn( + amount, + circleDomain, + recipient, + address(wrappedToken), + bytes32(0), // allow anyone to relay + maxFeeBps, + minFinalityThreshold + ); + + dispatchValue = msg.value; + message = TokenMessage.format(recipient, _outboundAmount(amount)); + _validateTokenMessageLength(message); + } +} diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 9251f6d001..55b3d1d633 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -6,28 +6,33 @@ import "forge-std/StdCheats.sol"; import {MockToken} from "../../contracts/mock/MockToken.sol"; import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; -import {TokenBridgeCctp} from "../../contracts/token/TokenBridgeCctp.sol"; +import {TokenBridgeCctpV1} from "../../contracts/token/TokenBridgeCctpV1.sol"; +import {TokenBridgeCctpV2} from "../../contracts/token/TokenBridgeCctpV2.sol"; import {MockHyperlaneEnvironment} from "../../contracts/mock/MockHyperlaneEnvironment.sol"; import {MockCircleMessageTransmitter} from "../../contracts/mock/MockCircleMessageTransmitter.sol"; -import {MockCircleTokenMessenger, MockCircleTokenMessengerV2} from "../../contracts/mock/MockCircleTokenMessenger.sol"; +import {MockCircleTokenMessenger} from "../../contracts/mock/MockCircleTokenMessenger.sol"; import {TestInterchainGasPaymaster} from "../../contracts/test/TestInterchainGasPaymaster.sol"; import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; import {ICcipReadIsm} from "../../contracts/interfaces/isms/ICcipReadIsm.sol"; -import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol"; -import {ITokenMessenger} from "../../contracts/interfaces/cctp/ITokenMessenger.sol"; +import {IMessageTransmitter, IRelayer} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol"; +import {IMessageTransmitterV2, IRelayerV2} from "../../contracts/interfaces/cctp/IMessageTransmitterV2.sol"; +import {ITokenMessenger, ITokenMessengerV1} from "../../contracts/interfaces/cctp/ITokenMessenger.sol"; import {ITokenMessengerV2} from "../../contracts/interfaces/cctp/ITokenMessengerV2.sol"; import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol"; import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {CctpMessage, BurnMessage} from "../../contracts/libs/CctpMessage.sol"; +import {CctpMessageV1, BurnMessageV1} from "../../contracts/libs/CctpMessageV1.sol"; +import {CctpMessageV2, BurnMessageV2} from "../../contracts/libs/CctpMessageV2.sol"; import {Message} from "../../contracts/libs/Message.sol"; -import {CctpService} from "../../contracts/token/TokenBridgeCctp.sol"; +import {CctpService} from "../../contracts/token/TokenBridgeCctpBase.sol"; import {TestRecipient} from "../../contracts/test/TestRecipient.sol"; +import {TokenBridgeCctpBase} from "../../contracts/token/TokenBridgeCctpBase.sol"; import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol"; import {IMailbox} from "../../contracts/interfaces/IMailbox.sol"; import {ISpecifiesInterchainSecurityModule} from "../../contracts/interfaces/IInterchainSecurityModule.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -contract TokenBridgeCctpTest is Test { +contract TokenBridgeCctpV1Test is Test { using TypeCasts for address; using TypeCasts for bytes32; using Message for bytes; @@ -44,8 +49,8 @@ contract TokenBridgeCctpTest is Test { TestInterchainGasPaymaster internal igpOrigin; TestInterchainGasPaymaster internal igpDestination; - TokenBridgeCctp internal tbOrigin; - TokenBridgeCctp internal tbDestination; + TokenBridgeCctpBase internal tbOrigin; + TokenBridgeCctpBase internal tbDestination; address internal proxyAdmin; address internal evil = makeAddr("evil"); @@ -54,7 +59,7 @@ contract TokenBridgeCctpTest is Test { MockToken internal tokenOrigin; MockToken internal tokenDestination; - uint32 internal version = 0; // CCTPv1 + uint32 internal version = CCTP_VERSION_1; uint256 internal amount = 1_000_000; // 1 USDC address internal user = address(11); uint256 internal balance = 10_000_000; // 10 USDC @@ -103,12 +108,12 @@ contract TokenBridgeCctpTest is Test { tokenDestination ); - TokenBridgeCctp originImplementation = new TokenBridgeCctp( + TokenBridgeCctpV1 originImplementation = new TokenBridgeCctpV1( address(tokenOrigin), scale, address(mailboxOrigin), - IMessageTransmitter(address(messageTransmitterOrigin)), - ITokenMessenger(address(tokenMessengerOrigin)) + messageTransmitterOrigin, + tokenMessengerOrigin ); bytes memory initData = abi.encodeWithSignature( @@ -122,14 +127,14 @@ contract TokenBridgeCctpTest is Test { proxyAdmin, initData ); - tbOrigin = TokenBridgeCctp(address(proxyOrigin)); + tbOrigin = TokenBridgeCctpV1(address(proxyOrigin)); - TokenBridgeCctp destinationImplementation = new TokenBridgeCctp( + TokenBridgeCctpV1 destinationImplementation = new TokenBridgeCctpV1( address(tokenDestination), scale, address(mailboxDestination), - IMessageTransmitter(address(messageTransmitterDestination)), - ITokenMessenger(address(tokenMessengerDestination)) + messageTransmitterDestination, + tokenMessengerDestination ); TransparentUpgradeableProxy proxyDestination = new TransparentUpgradeableProxy( @@ -138,7 +143,7 @@ contract TokenBridgeCctpTest is Test { initData ); - tbDestination = TokenBridgeCctp(address(proxyDestination)); + tbDestination = TokenBridgeCctpV1(address(proxyDestination)); _setupTokenBridgesCctp(tbOrigin, tbDestination); @@ -150,7 +155,7 @@ contract TokenBridgeCctpTest is Test { uint32 sourceDomain, bytes32 recipient, uint256 amount - ) internal view returns (bytes memory) { + ) internal view virtual returns (bytes memory) { return _encodeCctpBurnMessage( nonce, @@ -167,8 +172,8 @@ contract TokenBridgeCctpTest is Test { bytes32 recipient, uint256 amount, address sender - ) internal view returns (bytes memory) { - bytes memory burnMessage = BurnMessage._formatMessage( + ) internal view virtual returns (bytes memory) { + bytes memory burnMessage = BurnMessageV1._formatMessage( version, address(tokenOrigin).addressToBytes32(), recipient, @@ -176,7 +181,7 @@ contract TokenBridgeCctpTest is Test { sender.addressToBytes32() ); return - CctpMessage._formatMessage( + CctpMessageV1._formatMessage( version, sourceDomain, cctpDestination, @@ -188,6 +193,35 @@ contract TokenBridgeCctpTest is Test { ); } + function _encodeCctpHookMessage( + bytes32 sender, + bytes32 recipient, + bytes memory message + ) internal view virtual returns (bytes memory) { + return + CctpMessageV1._formatMessage( + version, + cctpOrigin, + cctpDestination, + tokenMessengerOrigin.nextNonce(), + sender, + recipient, + bytes32(0), // destinationCaller + message + ); + } + + function _encodeCctpHookMessage( + bytes memory message + ) internal view returns (bytes memory) { + return + _encodeCctpHookMessage( + address(tbOrigin).addressToBytes32(), + address(tbDestination).addressToBytes32(), + message + ); + } + function _setupAndDispatch() internal returns (bytes memory message, uint64 cctpNonce, bytes32 recipient) @@ -224,7 +258,7 @@ contract TokenBridgeCctpTest is Test { tbOrigin.addDomain(destination, cctpDestination); } - function test_quoteTransferRemote_getCorrectQuote() public { + function test_quoteTransferRemote_getCorrectQuote() public virtual { Quote[] memory quotes = tbOrigin.quoteTransferRemote( destination, user.addressToBytes32(), @@ -233,10 +267,15 @@ contract TokenBridgeCctpTest is Test { assertEq(quotes.length, 2); assertEq(quotes[0].token, address(0)); + assertEq( + quotes[0].amount, + igpOrigin.quoteGasPayment(destination, gasLimit) + ); assertEq(quotes[1].token, address(tokenOrigin)); + assertEq(quotes[1].amount, amount); } - function test_transferRemoteCctp() public { + function test_transferRemoteCctp() public virtual { Quote[] memory quote = tbOrigin.quoteTransferRemote( destination, user.addressToBytes32(), @@ -251,7 +290,7 @@ contract TokenBridgeCctpTest is Test { vm.expectCall( address(tokenMessengerOrigin), abi.encodeCall( - MockCircleTokenMessenger.depositForBurn, + ITokenMessengerV1.depositForBurn, ( amount, cctpDestination, @@ -296,13 +335,13 @@ contract TokenBridgeCctpTest is Test { assertEq(tbDestination.verify(metadata, message), true); } - function _upgrade(TokenBridgeCctp bridge) internal { - TokenBridgeCctp newImplementation = new TokenBridgeCctp( + function _upgrade(TokenBridgeCctpBase bridge) internal virtual { + TokenBridgeCctpV1 newImplementation = new TokenBridgeCctpV1( address(bridge.wrappedToken()), bridge.scale(), address(bridge.mailbox()), bridge.messageTransmitter(), - bridge.tokenMessenger() + ITokenMessengerV1(address(bridge.tokenMessenger())) ); bytes32 adminBytes = vm.load( @@ -316,8 +355,8 @@ contract TokenBridgeCctpTest is Test { ); } - function testFork_verify() public { - TokenBridgeCctp recipient = TokenBridgeCctp( + function testFork_verify_upgrade() public virtual { + TokenBridgeCctpV1 recipient = TokenBridgeCctpV1( 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 ); vm.createSelectFork(vm.rpcUrl("base"), 32_126_535); @@ -331,10 +370,10 @@ contract TokenBridgeCctpTest is Test { recipient.verify(metadata, message); _upgrade(recipient); - assertEq(recipient.verify(metadata, message), true); + assert(recipient.verify(metadata, message)); } - function test_verify_revertsWhen_invalidNonce() public { + function test_verify_revertsWhen_invalidNonce() public virtual { ( bytes memory message, uint64 cctpNonce, @@ -464,29 +503,25 @@ contract TokenBridgeCctpTest is Test { } function test_revertsWhen_versionIsNotSupported() public virtual { - messageTransmitterOrigin.setVersion(CCTP_VERSION_1); - MockCircleTokenMessengerV2 tokenMessengerV2 = new MockCircleTokenMessengerV2( - tokenOrigin - ); + tokenMessengerOrigin.setVersion(CCTP_VERSION_2); vm.expectRevert(bytes("Invalid TokenMessenger CCTP version")); - TokenBridgeCctp v1 = new TokenBridgeCctp( + TokenBridgeCctpV1 v1 = new TokenBridgeCctpV1( address(tokenOrigin), scale, address(mailboxOrigin), - IMessageTransmitter(address(messageTransmitterOrigin)), - ITokenMessenger(address(tokenMessengerV2)) + messageTransmitterOrigin, + tokenMessengerOrigin ); messageTransmitterOrigin.setVersion(CCTP_VERSION_2); - vm.expectRevert(bytes("Invalid messageTransmitter CCTP version")); - v1 = new TokenBridgeCctp( + v1 = new TokenBridgeCctpV1( address(tokenOrigin), scale, address(mailboxOrigin), - IMessageTransmitter(address(messageTransmitterOrigin)), - ITokenMessenger(address(tokenMessengerOrigin)) + messageTransmitterOrigin, + tokenMessengerOrigin ); } @@ -508,8 +543,8 @@ contract TokenBridgeCctpTest is Test { } function _setupTokenBridgesCctp( - TokenBridgeCctp _tbOrigin, - TokenBridgeCctp _tbDestination + TokenBridgeCctpBase _tbOrigin, + TokenBridgeCctpBase _tbDestination ) internal { _tbOrigin.setUrls(_getUrls()); _tbOrigin.addDomain(destination, cctpDestination); @@ -548,7 +583,10 @@ contract TokenBridgeCctpTest is Test { ); } - function test_postDispatch(bytes32 recipient, bytes calldata body) public { + function test_postDispatch( + bytes32 recipient, + bytes calldata body + ) public virtual { // precompute message ID bytes32 id = Message.id( Message.formatMessage( @@ -565,7 +603,7 @@ contract TokenBridgeCctpTest is Test { vm.expectCall( address(messageTransmitterOrigin), abi.encodeCall( - MockCircleMessageTransmitter.sendMessageWithCaller, + IRelayer.sendMessageWithCaller, ( cctpDestination, address(tbDestination).addressToBytes32(), @@ -587,9 +625,9 @@ contract TokenBridgeCctpTest is Test { function testFork_postDispatch( bytes32 recipient, bytes calldata body - ) public { + ) public virtual { vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); - TokenBridgeCctp hook = TokenBridgeCctp( + TokenBridgeCctpV1 hook = TokenBridgeCctpV1( 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 ); _upgrade(hook); @@ -610,7 +648,7 @@ contract TokenBridgeCctpTest is Test { body ); - bytes memory cctpMessage = CctpMessage._formatMessage( + bytes memory cctpMessage = CctpMessageV1._formatMessage( 0, hook.messageTransmitter().localDomain(), hook.hyperlaneDomainToCircleDomain(destination), @@ -633,9 +671,9 @@ contract TokenBridgeCctpTest is Test { mailbox.dispatch(destination, recipient, body, bytes(""), hook); } - function testFork_verifyDeployerMessage() public { + function testFork_verify() public virtual { vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); - TokenBridgeCctp hook = TokenBridgeCctp( + TokenBridgeCctpV1 hook = TokenBridgeCctpV1( 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 ); bytes32 router = hook.routers(1); @@ -655,7 +693,7 @@ contract TokenBridgeCctpTest is Test { bytes memory metadata = abi.encode(cctpMessage, attestation); vm.createSelectFork(vm.rpcUrl("mainnet"), 22_898_879); - TokenBridgeCctp ism = TokenBridgeCctp(router.bytes32ToAddress()); + TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress()); _upgrade(ism); vm.expectRevert(bytes("Invalid circle sender")); @@ -669,7 +707,7 @@ contract TokenBridgeCctpTest is Test { vm.expectCall( address(ism), - abi.encode(TokenBridgeCctp.handleReceiveMessage.selector) + abi.encode(TokenBridgeCctpV1.handleReceiveMessage.selector) ); assert(ism.verify(metadata, message)); } @@ -703,17 +741,7 @@ contract TokenBridgeCctpTest is Test { tbOrigin ); - bytes memory cctpMessage = CctpMessage._formatMessage( - version, - cctpOrigin, - cctpDestination, - tokenMessengerOrigin.nextNonce(), - address(tbOrigin).addressToBytes32(), - address(tbDestination).addressToBytes32(), - bytes32(0), // destinationCaller - abi.encode(id) - ); - + bytes memory cctpMessage = _encodeCctpHookMessage(abi.encode(id)); bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); mailboxDestination.addInboundMetadata(0, metadata); @@ -739,14 +767,9 @@ contract TokenBridgeCctpTest is Test { bytes32 badSender = ~address(tbOrigin).addressToBytes32(); - bytes memory cctpMessage = CctpMessage._formatMessage( - version, - cctpOrigin, - cctpDestination, - tokenMessengerOrigin.nextNonce(), + bytes memory cctpMessage = _encodeCctpHookMessage( badSender, address(tbDestination).addressToBytes32(), - bytes32(0), // destinationCaller message ); @@ -772,14 +795,7 @@ contract TokenBridgeCctpTest is Test { ); bytes32 badMessageId = ~Message.id(message); - bytes memory cctpMessage = CctpMessage._formatMessage( - version, - cctpOrigin, - cctpDestination, - tokenMessengerOrigin.nextNonce(), - address(tbOrigin).addressToBytes32(), - address(tbDestination).addressToBytes32(), - bytes32(0), // destinationCaller + bytes memory cctpMessage = _encodeCctpHookMessage( abi.encode(badMessageId) ); @@ -805,15 +821,9 @@ contract TokenBridgeCctpTest is Test { ); address badRecipient = address(~bytes20(address(tbDestination))); - - bytes memory cctpMessage = CctpMessage._formatMessage( - version, - cctpOrigin, - cctpDestination, - tokenMessengerOrigin.nextNonce(), + bytes memory cctpMessage = _encodeCctpHookMessage( address(tbOrigin).addressToBytes32(), badRecipient.addressToBytes32(), - bytes32(0), // destinationCaller abi.encode(Message.id(message)) ); @@ -824,3 +834,391 @@ contract TokenBridgeCctpTest is Test { tbDestination.verify(metadata, message); } } + +contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { + using TypeCasts for address; + + uint256 constant maxFee = 1; + uint32 constant minFinalityThreshold = 1000; + + address constant deployer = 0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba; + + function setUp() public override { + super.setUp(); + + version = CCTP_VERSION_2; + + tokenMessengerOrigin.setVersion(CCTP_VERSION_2); + messageTransmitterOrigin.setVersion(CCTP_VERSION_2); + + tokenMessengerDestination.setVersion(CCTP_VERSION_2); + messageTransmitterDestination.setVersion(CCTP_VERSION_2); + + TokenBridgeCctpV2 originImplementation = new TokenBridgeCctpV2( + address(tokenOrigin), + scale, + address(mailboxOrigin), + messageTransmitterOrigin, + tokenMessengerOrigin, + maxFee, + minFinalityThreshold + ); + + bytes memory initData = abi.encodeWithSignature( + "initialize(address,address,string[])", + address(0), + address(this), + _getUrls() + ); + TransparentUpgradeableProxy proxyOrigin = new TransparentUpgradeableProxy( + address(originImplementation), + proxyAdmin, + initData + ); + tbOrigin = TokenBridgeCctpV2(address(proxyOrigin)); + + TokenBridgeCctpV2 destinationImplementation = new TokenBridgeCctpV2( + address(tokenDestination), + scale, + address(mailboxDestination), + messageTransmitterDestination, + tokenMessengerDestination, + maxFee, + minFinalityThreshold + ); + + TransparentUpgradeableProxy proxyDestination = new TransparentUpgradeableProxy( + address(destinationImplementation), + proxyAdmin, + initData + ); + + tbDestination = TokenBridgeCctpV2(address(proxyDestination)); + + _setupTokenBridgesCctp(tbOrigin, tbDestination); + } + + function _encodeCctpBurnMessage( + uint64 nonce, + uint32 sourceDomain, + bytes32 recipient, + uint256 amount, + address sender + ) internal view override returns (bytes memory) { + bytes memory burnMessage = BurnMessageV2._formatMessageForRelay( + version, + address(tokenOrigin).addressToBytes32(), + recipient, + amount, + sender.addressToBytes32(), + maxFee, + bytes("") + ); + return + CctpMessageV2._formatMessageForRelay( + version, + sourceDomain, + cctpDestination, + address(tokenMessengerOrigin).addressToBytes32(), + address(tokenMessengerDestination).addressToBytes32(), + bytes32(0), + minFinalityThreshold, + burnMessage + ); + } + + function _encodeCctpHookMessage( + bytes32 sender, + bytes32 recipient, + bytes memory message + ) internal view override returns (bytes memory) { + return + CctpMessageV2._formatMessageForRelay( + version, + cctpOrigin, + cctpDestination, + sender, + recipient, + bytes32(0), + minFinalityThreshold, + message + ); + } + + function _deploy() internal returns (TokenBridgeCctpV2) { + ITokenMessengerV2 tokenMessenger = ITokenMessengerV2( + address(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d) + ); + + IMessageTransmitterV2 messageTransmitter = IMessageTransmitterV2( + address(0x81D40F21F12A8F0E3252Bccb954D722d4c464B64) + ); + + TokenBridgeCctpV2 implementation = new TokenBridgeCctpV2( + 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + 1, + 0xeA87ae93Fa0019a82A727bfd3eBd1cFCa8f64f1D, + messageTransmitter, + tokenMessenger, + maxFee, + minFinalityThreshold + ); + + // deploy proxy code to deployer address, which is configured as recipient on cctp messages + deployCodeTo( + "../node_modules/@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol:TransparentUpgradeableProxy", + abi.encode( + address(implementation), + proxyAdmin, + abi.encodeWithSignature( + "initialize(address,address,string[])", + address(0), + address(this), + _getUrls() + ) + ), + address(deployer) + ); + + return TokenBridgeCctpV2(address(deployer)); + } + + function testFork_verify() public override { + vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); + + uint32 circleDestination = 6; + uint32 origin = 10; + TokenBridgeCctpV2 ism = _deploy(); + uint32 circleOrigin = 2; + ism.addDomain(origin, circleOrigin); + ism.enrollRemoteRouter(origin, deployer.addressToBytes32()); + + // https://optimistic.etherscan.io/tx/0xf53a6a2cb5a334706912b96088171251df1400156a0a0a68a79fe70961634f65 + bytes + memory message = hex"030010EF000000000A000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA00002105000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BADEADBEEF"; + + // https://optimistic.etherscan.io/tx/0xc50f4acd4e442529b9814b252e8b568b72e10720b18603232c73124ac1e9ae1f + bytes + memory originalCctpMessage = hex"0000000100000002000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA000003E800000000B410A464EC38D27F7C9394F9BF9B1EF1A5921F5E82FE77CF67A10DB6FE8425FD"; + + // https://iris-api.circle.com/v2/messages/2?transactionHash=0xc50f4acd4e442529b9814b252e8b568b72e10720b18603232c73124ac1e9ae1f + bytes + memory cctpMessage = hex"000000010000000200000006a94cc8b2c5a35f696379d89ca4cd0a0d7058c6c2e949ac08e8dfc607cc0590f9000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000003e8000003e8b410a464ec38d27f7c9394f9bf9b1ef1a5921f5e82fe77cf67a10db6fe8425fd"; + bytes + memory attestation = hex"fdaca657526b164d6b09678297565d40e1e68cad3bfb0786470b0e8bce013ee340a985970d69629af69599f3deff5cc975b3df46d2efeadfebd867d049e5e5641cba6f5e720dc86c90d8d51747619fbe2b24246e36fa0603792cb86ad88bdc06136663d6211a8d5d134cf94cf8197892a460b24a5e21715642d338530b472a325d1c"; + bytes memory metadata = abi.encode(cctpMessage, attestation); + + vm.expectCall( + address(ism), + abi.encode( + TokenBridgeCctpV2.handleReceiveUnfinalizedMessage.selector + ) + ); + assert(ism.verify(metadata, message)); + } + + function testFork_transferRemote(bytes32 recipient, uint32 amount) public { + // depositForBurn will revert if amount is less than maxFee + vm.assume(amount > maxFee); + vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); + + bytes32 ism = 0x0000000000000000000000000000000000000000000000000000000000000001; + + TokenBridgeCctpV2 router = _deploy(); + + uint32 destination = 1; // ethereum + router.addDomain(destination, 0); + router.enrollRemoteRouter(destination, ism); + + Quote[] memory quotes = router.quoteTransferRemote( + destination, + recipient, + amount + ); + + deal(quotes[1].token, address(this), quotes[1].amount); + IERC20(quotes[1].token).approve(address(router), quotes[1].amount); + + router.transferRemote{value: quotes[0].amount}( + destination, + recipient, + amount + ); + } + + function testFork_postDispatch( + bytes32 recipient, + bytes calldata body + ) public override { + vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); + + bytes32 ism = 0x0000000000000000000000000000000000000000000000000000000000000001; + + TokenBridgeCctpV2 hook = _deploy(); + + IMailbox mailbox = hook.mailbox(); + uint32 origin = mailbox.localDomain(); + uint32 destination = 1; // ethereum + hook.addDomain(destination, 0); + hook.enrollRemoteRouter(destination, ism); + + // precompute message ID + bytes memory message = Message.formatMessage( + 3, + mailbox.nonce(), + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ); + + bytes memory cctpMessage = CctpMessageV2._formatMessageForRelay( + CCTP_VERSION_2, + hook.messageTransmitter().localDomain(), + hook.hyperlaneDomainToCircleDomain(destination), + address(hook).addressToBytes32(), + ism, + ism, + minFinalityThreshold, + abi.encode(Message.id(message)) + ); + + vm.expectEmit( + true, + true, + true, + true, + address(hook.messageTransmitter()) + ); + emit IMessageTransmitter.MessageSent(cctpMessage); + + mailbox.dispatch(destination, recipient, body, bytes(""), hook); + } + + function test_transferRemoteCctp() public override { + Quote[] memory quote = tbOrigin.quoteTransferRemote( + destination, + user.addressToBytes32(), + amount + ); + + vm.startPrank(user); + tokenOrigin.approve(address(tbOrigin), quote[1].amount); + + vm.expectCall( + address(tokenMessengerOrigin), + abi.encodeCall( + ITokenMessengerV2.depositForBurn, + ( + amount, + cctpDestination, + user.addressToBytes32(), + address(tokenOrigin), + bytes32(0), + maxFee, + minFinalityThreshold + ) + ) + ); + tbOrigin.transferRemote{value: quote[0].amount}( + destination, + user.addressToBytes32(), + amount + ); + } + + function test_postDispatch( + bytes32 recipient, + bytes calldata body + ) public override { + // precompute message ID + bytes32 id = Message.id( + Message.formatMessage( + 3, + 0, + origin, + address(this).addressToBytes32(), + destination, + recipient, + body + ) + ); + + vm.expectCall( + address(messageTransmitterOrigin), + abi.encodeCall( + IRelayerV2.sendMessage, + ( + cctpDestination, + address(tbDestination).addressToBytes32(), + address(tbDestination).addressToBytes32(), + minFinalityThreshold, + abi.encode(id) + ) + ) + ); + bytes32 actualId = mailboxOrigin.dispatch( + destination, + recipient, + body, + bytes(""), + tbOrigin + ); + assertEq(actualId, id); + } + + function test_revertsWhen_versionIsNotSupported() public override { + tokenMessengerOrigin.setVersion(CCTP_VERSION_1); + + vm.expectRevert(bytes("Invalid TokenMessenger CCTP version")); + TokenBridgeCctpV2 v2 = new TokenBridgeCctpV2( + address(tokenOrigin), + scale, + address(mailboxOrigin), + messageTransmitterOrigin, + tokenMessengerOrigin, + maxFee, + minFinalityThreshold + ); + + messageTransmitterOrigin.setVersion(CCTP_VERSION_1); + vm.expectRevert(bytes("Invalid messageTransmitter CCTP version")); + v2 = new TokenBridgeCctpV2( + address(tokenOrigin), + scale, + address(mailboxOrigin), + messageTransmitterOrigin, + tokenMessengerOrigin, + maxFee, + minFinalityThreshold + ); + } + + function test_verify_revertsWhen_invalidNonce() public override { + vm.skip(true); + // cannot assert nonce in v2 + } + + function testFork_verify_upgrade() public override { + vm.skip(true); + } + + function test_quoteTransferRemote_getCorrectQuote() public override { + Quote[] memory quotes = tbOrigin.quoteTransferRemote( + destination, + user.addressToBytes32(), + amount + ); + + assertEq(quotes.length, 2); + assertEq(quotes[0].token, address(0)); + assertEq( + quotes[0].amount, + igpOrigin.quoteGasPayment(destination, gasLimit) + ); + assertEq(quotes[1].token, address(tokenOrigin)); + uint256 fastFee = (amount * maxFee) / 10_000; + assertEq(quotes[1].amount, amount + fastFee); + } +} diff --git a/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts b/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts index 00ff410b06..58cee65d8d 100644 --- a/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts +++ b/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts @@ -38,6 +38,7 @@ export const getCCTPWarpConfig = async ( messageTransmitter: messageTransmitterAddresses[chain], tokenMessenger: tokenMessengerAddresses[chain], urls: ['https://offchain-lookup.web3tools.net/cctp/getProofs'], + cctpVersion: 'V1', }; return [chain, config]; }), diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts index 33e029761a..8c33e51d6f 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts @@ -9,13 +9,15 @@ import { HypXERC20Lockbox__factory, HypXERC20__factory, IFiatToken__factory, + IMessageTransmitter__factory, IXERC20__factory, MovableCollateralRouter__factory, OpL1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, PackageVersioned__factory, ProxyAdmin__factory, - TokenBridgeCctp__factory, + TokenBridgeCctpBase__factory, + TokenBridgeCctpV2__factory, TokenRouter__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; @@ -459,22 +461,53 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader { const collateralConfig = await this.deriveHypCollateralTokenConfig(hypToken); - const tokenBridge = TokenBridgeCctp__factory.connect( + const tokenBridge = TokenBridgeCctpBase__factory.connect( hypToken, this.provider, ); - const messageTransmitter = await tokenBridge.messageTransmitter(); - const tokenMessenger = await tokenBridge.tokenMessenger(); - const urls = await tokenBridge.urls(); + const [messageTransmitter, tokenMessenger, urls] = await Promise.all([ + tokenBridge.messageTransmitter(), + tokenBridge.tokenMessenger(), + tokenBridge.urls(), + ]); - return { - ...collateralConfig, - type: TokenType.collateralCctp, + const onchainCctpVersion = await IMessageTransmitter__factory.connect( messageTransmitter, - tokenMessenger, - urls, - }; + this.provider, + ).version(); + + if (onchainCctpVersion === 0) { + return { + ...collateralConfig, + type: TokenType.collateralCctp, + cctpVersion: 'V1', + messageTransmitter, + tokenMessenger, + urls, + }; + } else if (onchainCctpVersion === 1) { + const tokenBridgeV2 = TokenBridgeCctpV2__factory.connect( + hypToken, + this.provider, + ); + const [minFinalityThreshold, maxFeeBps] = await Promise.all([ + tokenBridgeV2.minFinalityThreshold(), + tokenBridgeV2.maxFeeBps(), + ]); + return { + ...collateralConfig, + type: TokenType.collateralCctp, + cctpVersion: 'V2', + messageTransmitter, + tokenMessenger, + urls, + minFinalityThreshold, + maxFeeBps: maxFeeBps.toNumber(), + }; + } else { + throw new Error(`Unsupported CCTP version ${onchainCctpVersion}`); + } } private async deriveHypCollateralTokenConfig( diff --git a/typescript/sdk/src/token/contracts.ts b/typescript/sdk/src/token/contracts.ts index b1c87ac81f..103872995f 100644 --- a/typescript/sdk/src/token/contracts.ts +++ b/typescript/sdk/src/token/contracts.ts @@ -14,7 +14,8 @@ import { HypXERC20__factory, OpL1V1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, - TokenBridgeCctp__factory, + TokenBridgeCctpV1__factory, + TokenBridgeCctpV2__factory, } from '@hyperlane-xyz/core'; import { TokenType } from './config.js'; @@ -42,7 +43,8 @@ export type HypERC20contracts = typeof hypERC20contracts; export const hypERC20factories = { [TokenType.synthetic]: new HypERC20__factory(), [TokenType.collateral]: new HypERC20Collateral__factory(), - [TokenType.collateralCctp]: new TokenBridgeCctp__factory(), + // use V1 here to satisfy type requirements + [TokenType.collateralCctp]: new TokenBridgeCctpV1__factory(), [TokenType.collateralVault]: new HypERC4626OwnerCollateral__factory(), [TokenType.collateralVaultRebase]: new HypERC4626Collateral__factory(), [TokenType.syntheticRebase]: new HypERC4626__factory(), @@ -57,6 +59,13 @@ export const hypERC20factories = { } as const; export type HypERC20Factories = typeof hypERC20factories; +// Helper function to get the appropriate CCTP factory based on version +export function getCctpFactory(version: 'V1' | 'V2') { + return version === 'V1' + ? new TokenBridgeCctpV1__factory() + : new TokenBridgeCctpV2__factory(); +} + export const hypERC721contracts = { [TokenType.collateralUri]: 'HypERC721URICollateral', [TokenType.collateral]: 'HypERC721Collateral', diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index ca4b4287a0..f6590630fb 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -10,7 +10,7 @@ import { MovableCollateralRouter__factory, OpL1V1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, - TokenBridgeCctp__factory, + TokenBridgeCctpBase__factory, } from '@hyperlane-xyz/core'; import { ProtocolType, @@ -40,6 +40,7 @@ import { HypERC20contracts, HypERC721Factories, TokenFactories, + getCctpFactory, hypERC20contracts, hypERC20factories, hypERC721contracts, @@ -93,7 +94,7 @@ export const TOKEN_INITIALIZE_SIGNATURE = ( return OP_L1_INITIALIZE_SIGNATURE; case 'TokenBridgeCctp': assert( - TokenBridgeCctp__factory.createInterface().functions[ + TokenBridgeCctpBase__factory.createInterface().functions[ CCTP_INITIALIZE_SIGNATURE ], 'missing expected initialize function', @@ -147,13 +148,28 @@ abstract class TokenDeployer< ); return [config.decimals, scale, config.mailbox, collateralDomain]; } else if (isCctpTokenConfig(config)) { - return [ - config.token, - scale, - config.mailbox, - config.messageTransmitter, - config.tokenMessenger, - ]; + switch (config.cctpVersion) { + case 'V1': + return [ + config.token, + scale, + config.mailbox, + config.messageTransmitter, + config.tokenMessenger, + ]; + case 'V2': + return [ + config.token, + scale, + config.mailbox, + config.messageTransmitter, + config.tokenMessenger, + config.minFinalityThreshold, + config.maxFeeBps, + ]; + default: + throw new Error('Unsupported CCTP version'); + } } else { throw new Error('Unknown token type when constructing arguments'); } @@ -333,7 +349,7 @@ abstract class TokenDeployer< await promiseObjAll( objMap(cctpConfigs, async (chain, _config) => { const router = this.router(deployedContractsMap[chain]).address; - const tokenBridge = TokenBridgeCctp__factory.connect( + const tokenBridge = TokenBridgeCctpBase__factory.connect( router, this.multiProvider.getSigner(chain), ); @@ -530,8 +546,41 @@ export class HypERC20Deployer extends TokenDeployer { } routerContractName(config: HypTokenRouterConfig): string { + // Handle CCTP version-specific contract names + if (isCctpTokenConfig(config)) { + return `TokenBridgeCctp${config.cctpVersion}`; + } return hypERC20contracts[this.routerContractKey(config)]; } + + // Override deployContractFromFactory to handle CCTP version selection + async deployContractFromFactory( + chain: ChainName, + factory: any, + contractName: string, + constructorArgs: any[], + initializeArgs?: any[], + shouldRecover = true, + implementationAddress?: string, + ): Promise { + // For CCTP contracts, use the version-specific factory + if (contractName.startsWith('TokenBridgeCctp')) { + factory = getCctpFactory( + contractName.split('TokenBridgeCctp')[1] as 'V1' | 'V2', + ); + } + + // Use the default deployment for other types + return super.deployContractFromFactory( + chain, + factory, + contractName, + constructorArgs, + initializeArgs, + shouldRecover, + implementationAddress, + ); + } } export class HypERC721Deployer extends TokenDeployer { diff --git a/typescript/sdk/src/token/types.ts b/typescript/sdk/src/token/types.ts index cd5c0c6d4c..25fb223d4e 100644 --- a/typescript/sdk/src/token/types.ts +++ b/typescript/sdk/src/token/types.ts @@ -164,6 +164,9 @@ export const CctpTokenConfigSchema = CollateralTokenConfigSchema.omit({ tokenMessenger: z .string() .describe('CCTP Token Messenger contract address'), + cctpVersion: z.enum(['V1', 'V2']), + minFinalityThreshold: z.number().optional(), + maxFeeBps: z.number().optional(), }) .merge(OffchainLookupIsmConfigSchema.omit({ type: true, owner: true })); From 80f019913ac6dc97b9684aa217c5cb960b5afe81 Mon Sep 17 00:00:00 2001 From: larryob Date: Tue, 15 Jul 2025 12:15:19 -0400 Subject: [PATCH 13/36] feat: Add Everclear bridge for native ETH (#6720) --- .../interfaces/IEverclearAdapter.sol | 28 + .../token/bridge/EverclearEthBridge.sol | 95 ++++ .../token/bridge/EverclearTokenBridge.sol | 183 +++++-- solidity/script/EverclearTokenBridge.s.sol | 6 +- .../test/token/EverclearTokenBridge.t.sol | 490 ++++++++++++++++-- 5 files changed, 701 insertions(+), 101 deletions(-) create mode 100644 solidity/contracts/token/bridge/EverclearEthBridge.sol diff --git a/solidity/contracts/interfaces/IEverclearAdapter.sol b/solidity/contracts/interfaces/IEverclearAdapter.sol index f30d7b1316..a645517677 100644 --- a/solidity/contracts/interfaces/IEverclearAdapter.sol +++ b/solidity/contracts/interfaces/IEverclearAdapter.sol @@ -32,6 +32,19 @@ interface IEverclear { uint32[] destinations; bytes data; } + + enum IntentStatus { + NONE, // 0 + ADDED, // 1 + DEPOSIT_PROCESSED, // 2 + FILLED, // 3 + ADDED_AND_FILLED, // 4 + INVOICED, // 5 + SETTLED, // 6 + SETTLED_AND_MANUALLY_EXECUTED, // 7 + UNSUPPORTED, // 8 + UNSUPPORTED_RETURNED // 9 + } } interface IEverclearAdapter { struct FeeParams { @@ -93,4 +106,19 @@ interface IEverclearAdapter { function updateFeeSigner(address _feeSigner) external; function owner() external view returns (address); + + function spoke() external view returns (IEverclearSpoke spoke); +} + +interface IEverclearSpoke { + /** + * @notice returns the status of an intent + * @param _intentId The ID of the intent + * @return _status The status of the intent + */ + function status( + bytes32 _intentId + ) external view returns (IEverclear.IntentStatus _status); + + function executeIntentCalldata(IEverclear.Intent calldata _intent) external; } diff --git a/solidity/contracts/token/bridge/EverclearEthBridge.sol b/solidity/contracts/token/bridge/EverclearEthBridge.sol new file mode 100644 index 0000000000..cd9e78fbbe --- /dev/null +++ b/solidity/contracts/token/bridge/EverclearEthBridge.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.22; + +import {EverclearTokenBridge, Quote} from "./EverclearTokenBridge.sol"; +import {IEverclearAdapter, IEverclear} from "../../interfaces/IEverclearAdapter.sol"; +import {IWETH} from "../interfaces/IWETH.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {TypeCasts} from "../../libs/TypeCasts.sol"; +import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +import {TokenMessage} from "../libs/TokenMessage.sol"; + +/** + * @title EverclearEthBridge + * @author Hyperlane Team + * @notice A specialized ETH bridge that integrates with Everclear's intent-based architecture + * @dev Extends EverclearTokenBridge to handle ETH by wrapping to WETH for transfers and unwrapping on destination + */ +contract EverclearEthBridge is EverclearTokenBridge { + using TokenMessage for bytes; + using SafeERC20 for IERC20; + using Address for address payable; + using TypeCasts for bytes32; + + /** + * @notice Constructor to initialize the Everclear ETH bridge + * @param _everclearAdapter The address of the Everclear adapter contract + */ + constructor( + IWETH _weth, + uint256 _scale, + address _mailbox, + IEverclearAdapter _everclearAdapter + ) + EverclearTokenBridge( + address(_weth), + _scale, + _mailbox, + _everclearAdapter + ) + {} + + function quoteTransferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) public view override returns (Quote[] memory quotes) { + quotes = new Quote[](1); + quotes[0] = Quote({ + token: address(0), + amount: _amount + + feeParams.fee + + _quoteGasPayment(_destination, _recipient, _amount) + }); + } + + /** + * @notice Transfers ETH from sender, wrapping to WETH + */ + function _transferFromSender(uint256 _amount) internal override { + // The `_amount` here will be amount + fee where amount is what the user wants to send, + // And `fee` is what is being payed to everclear. + // The user will also include the gas payment in the msg.value. + require(msg.value >= _amount, "EEB: ETH amount mismatch"); + IWETH(address(wrappedToken)).deposit{value: _amount}(); + } + + function _transferTo( + address _recipient, + uint256 _amount + ) internal override { + // Withdraw WETH to ETH + IWETH(address(wrappedToken)).withdraw(_amount); + + // Send ETH to recipient + payable(_recipient).sendValue(_amount); + } + + function _chargeSender( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal virtual override returns (uint256 dispatchValue) { + uint256 fee = _feeAmount(_destination, _recipient, _amount); + + uint256 totalAmount = _amount + fee + feeParams.fee; + _transferFromSender(totalAmount); + dispatchValue = msg.value - totalAmount; + if (fee > 0) { + _transferTo(feeRecipient(), fee); + } + return dispatchValue; + } +} diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index d800168bd7..2a7eace37d 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -3,11 +3,14 @@ pragma solidity ^0.8.22; import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol"; import {HypERC20Collateral} from "../HypERC20Collateral.sol"; -import {IEverclearAdapter} from "../../interfaces/IEverclearAdapter.sol"; +import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../interfaces/IEverclearAdapter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {PackageVersioned} from "../../PackageVersioned.sol"; +import {IWETH} from "../interfaces/IWETH.sol"; +import {TokenMessage} from "../libs/TokenMessage.sol"; +import {TypeCasts} from "../../libs/TypeCasts.sol"; /** * @notice Information about an output asset for a destination domain @@ -25,17 +28,19 @@ struct OutputAssetInfo { * @notice A token bridge that integrates with Everclear's intent-based architecture * @dev Extends HypERC20Collateral to provide cross-chain token transfers via Everclear's intent system */ -contract EverclearTokenBridge is - ITokenBridge, - OwnableUpgradeable, - PackageVersioned -{ +contract EverclearTokenBridge is HypERC20Collateral { + using TokenMessage for bytes; + using TypeCasts for bytes32; using SafeERC20 for IERC20; /// @notice The output asset for a given destination domain /// @dev Everclear needs to know the output asset address to create intents for cross-chain transfers mapping(uint32 destination => bytes32 outputAssets) public outputAssets; + /// @notice Whether an intent has been settled + /// @dev This is used to prevent funds from being sent to a recipient that has already received them + mapping(bytes32 intentId => bool isSettled) public intentSettled; + /// @notice Fee parameters for the bridge operations /// @dev The signatures are produced by Everclear and stored here for re-use. We use the same fee for all transfers to all destinations IEverclearAdapter.FeeParams public feeParams; @@ -44,6 +49,9 @@ contract EverclearTokenBridge is /// @dev Immutable reference to the Everclear adapter used for creating intents IEverclearAdapter public immutable everclearAdapter; + /// @notice The Everclear spoke contract + IEverclearSpoke public immutable everclearSpoke; + /** * @notice Emitted when fee parameters are updated * @param fee The new fee amount @@ -58,26 +66,27 @@ contract EverclearTokenBridge is */ event OutputAssetSet(uint32 destination, bytes32 outputAsset); - IERC20 public immutable token; - /** * @notice Constructor to initialize the Everclear token bridge - * @param _erc20 The address of the ERC20 token to be used as collateral * @param _everclearAdapter The address of the Everclear adapter contract */ - constructor(IERC20 _erc20, IEverclearAdapter _everclearAdapter) { - token = _erc20; + constructor( + address _erc20, + uint256 _scale, + address _mailbox, + IEverclearAdapter _everclearAdapter + ) HypERC20Collateral(_erc20, _scale, _mailbox) { everclearAdapter = _everclearAdapter; + everclearSpoke = _everclearAdapter.spoke(); } /** * @notice Initializes the proxy contract. * @dev Approves the Everclear adapter to spend tokens */ - function initialize(address _owner) public initializer { - __Ownable_init(); - _transferOwnership(_owner); - token.approve(address(everclearAdapter), type(uint256).max); + function initialize(address _hook, address _owner) public initializer { + _HypERC20_initialize(_hook, address(0), _owner); + wrappedToken.approve(address(everclearAdapter), type(uint256).max); } /** @@ -148,45 +157,34 @@ contract EverclearTokenBridge is uint32 _destination, bytes32 _recipient, uint256 _amount - ) public view override returns (Quote[] memory quotes) { + ) public view virtual override returns (Quote[] memory quotes) { _destination; // Keep this to avoid solc's documentation warning (3881) _recipient; - quotes = new Quote[](1); + quotes = new Quote[](2); quotes[0] = Quote({ - token: address(token), + token: address(0), + amount: _quoteGasPayment(_destination, _recipient, _amount) + }); + quotes[1] = Quote({ + token: address(wrappedToken), amount: _amount + feeParams.fee }); } - /** - * @notice Transfers tokens to a remote chain via Everclear's intent system - * @dev Creates an Everclear intent for cross-chain transfer. The actual Hyperlane message is sent by Everclear - * @param _destination The destination domain ID - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of tokens to transfer - * @return bytes32(0) as the transfer ID (actual ID is managed by Everclear) - */ - function transferRemote( + /// @dev We can't use _feeAmount here because Everclear wants to pull tokens from this contract + /// and the amount from _feeAmount is sent to the fee recipient. + function _chargeSender( uint32 _destination, bytes32 _recipient, uint256 _amount - ) external payable override returns (bytes32) { - IEverclearAdapter.FeeParams memory _feeParams = feeParams; - - // Charge sender the stored fee - token.safeTransferFrom({ - from: msg.sender, - to: address(this), - value: _amount + _feeParams.fee - }); - - // Create everclear intent - _createIntent(_destination, _recipient, _amount, _feeParams); - - // A hyperlane message will be sent by everclear internally - // in a separate transaction. See `EverclearSpokeV3.processIntentQueue`. - return bytes32(0); + ) internal virtual override returns (uint256 dispatchValue) { + return + super._chargeSender( + _destination, + _recipient, + _amount + feeParams.fee + ); } /** @@ -195,31 +193,106 @@ contract EverclearTokenBridge is * @param _destination The destination domain ID * @param _recipient The recipient address on the destination chain * @param _amount The amount of tokens to transfer - * @param _feeParams The fee parameters for the intent */ function _createIntent( uint32 _destination, bytes32 _recipient, - uint256 _amount, - IEverclearAdapter.FeeParams memory _feeParams - ) internal { - bytes32 outputAsset = outputAssets[_destination]; - require(outputAsset != bytes32(0), "ETB: Output asset not set"); + uint256 _amount + ) internal virtual returns (IEverclear.Intent memory) { + require( + outputAssets[_destination] != bytes32(0), + "ETB: Output asset not set" + ); // Create everclear intent uint32[] memory destinations = new uint32[](1); destinations[0] = _destination; - everclearAdapter.newIntent({ + // Create intent + // We always send the funds to the remote router, which will then send them to the recipient in _handle + (, IEverclear.Intent memory intent) = everclearAdapter.newIntent({ _destinations: destinations, - _receiver: _recipient, - _inputAsset: address(token), - _outputAsset: outputAsset, + _receiver: _mustHaveRemoteRouter(_destination), + _inputAsset: address(wrappedToken), + _outputAsset: outputAssets[_destination], // We load this from storage again to avoid stack too deep _amount: _amount, _maxFee: 0, _ttl: 0, - _data: "", - _feeParams: _feeParams + _data: _getIntentCalldata(_recipient, _amount), + _feeParams: feeParams }); + + return intent; + } + + /** + * @notice Gets the calldata for the intent that will unwrap WETH to ETH on destination + * @dev Overrides parent to return calldata for unwrapping WETH to ETH + * @return The encoded calldata for the unwrap and send operation + */ + function _getIntentCalldata( + bytes32 _recipient, + uint256 _amount + ) internal view returns (bytes memory) { + return abi.encode(_recipient, _amount); + } + + function _beforeDispatch( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal virtual override returns (uint256, bytes memory) { + (uint256 _dispatchValue, bytes memory _msg) = super._beforeDispatch( + _destination, + _recipient, + _amount + ); + + IEverclear.Intent memory intent = _createIntent( + _destination, + _recipient, + _amount + ); + + // Add the intent to the `TokenMessage` as metadata + // The original `_msg` is abi.encodePacked(_recipient, _amount) + // We need can't use abi.encodePacked because the intent is a struct + _msg = bytes.concat(_msg, abi.encode(intent)); + + return (_dispatchValue, _msg); + } + + function _handle( + uint32 _origin, + bytes32 /* sender */, + bytes calldata _message + ) internal virtual override { + // Get intent from hyperlane message + bytes memory metadata = _message.metadata(); + IEverclear.Intent memory intent = abi.decode( + metadata, + (IEverclear.Intent) + ); + + /* CHECKS */ + // Check that intent is settled + bytes32 intentId = keccak256(abi.encode(intent)); + require( + everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED, + "ETB: Intent Status != SETTLED" + ); + // Check that we have not processed this intent before + require(!intentSettled[intentId], "ETB: Intent already processed"); + (bytes32 _recipient, uint256 _amount) = abi.decode( + intent.data, + (bytes32, uint256) + ); + + /* EFFECTS */ + intentSettled[intentId] = true; + emit ReceivedTransferRemote(_origin, _recipient, _amount); + + /* INTERACTIONS */ + _transferTo(_recipient.bytes32ToAddress(), _amount); } } diff --git a/solidity/script/EverclearTokenBridge.s.sol b/solidity/script/EverclearTokenBridge.s.sol index 679ec00a8a..ee5271edb3 100644 --- a/solidity/script/EverclearTokenBridge.s.sol +++ b/solidity/script/EverclearTokenBridge.s.sol @@ -17,12 +17,14 @@ contract EverclearTokenBridgeScript is Script { // Deploy the bridge. This is an ARB eth bridge. EverclearTokenBridge bridge = new EverclearTokenBridge( - IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1), // WETH + 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, // WETH + 1, + address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox IEverclearAdapter(0x15a7cA97D1ed168fB34a4055CEFa2E2f9Bdb6C75) // Everclear adapter ); // Initialize the bridge - bridge.initialize(deployer); + bridge.initialize(address(0), deployer); // Set the output asset for the bridge. // This is optimism weth diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 772015ce20..952b9216bc 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -21,10 +21,13 @@ import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol"; import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; - +import {MockHyperlaneEnvironment} from "../../contracts/mock/MockHyperlaneEnvironment.sol"; +import {Message} from "../../contracts/libs/Message.sol"; import {EverclearTokenBridge, OutputAssetInfo} from "../../contracts/token/bridge/EverclearTokenBridge.sol"; -import {IEverclearAdapter, IEverclear} from "../../contracts/interfaces/IEverclearAdapter.sol"; +import {EverclearEthBridge} from "../../contracts/token/bridge/EverclearEthBridge.sol"; +import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../contracts/interfaces/IEverclearAdapter.sol"; import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; +import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; import {IWETH} from "contracts/token/interfaces/IWETH.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; @@ -115,10 +118,14 @@ contract MockEverclearAdapter is IEverclearAdapter { function updateFeeSigner(address _feeSigner) external { // Do nothing } + + function spoke() external view returns (IEverclearSpoke) { + return IEverclearSpoke(address(0x333)); + } } contract EverclearTokenBridgeTest is Test { - using TypeCasts for address; + using TypeCasts for *; // Constants uint32 internal constant ORIGIN = 11; @@ -131,6 +138,7 @@ contract EverclearTokenBridgeTest is Test { uint256 internal constant GAS_PAYMENT = 0.001 ether; string internal constant NAME = "TestToken"; string internal constant SYMBOL = "TT"; + MockHyperlaneEnvironment internal environment; // Test addresses address internal ALICE = makeAddr("alice"); @@ -159,7 +167,9 @@ contract EverclearTokenBridgeTest is Test { function setUp() public { // Setup basic infrastructure - mailbox = new MockMailbox(ORIGIN); + environment = new MockHyperlaneEnvironment(ORIGIN, DESTINATION); + mailbox = environment.mailboxes(ORIGIN); + token = new ERC20Test(NAME, SYMBOL, TOTAL_SUPPLY, DECIMALS); everclearAdapter = new MockEverclearAdapter(); hook = new TestPostDispatchHook(); @@ -169,7 +179,9 @@ contract EverclearTokenBridgeTest is Test { // Deploy bridge implementation EverclearTokenBridge implementation = new EverclearTokenBridge( - token, + address(token), + 1, + address(mailbox), everclearAdapter ); @@ -177,10 +189,7 @@ contract EverclearTokenBridgeTest is Test { TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(implementation), PROXY_ADMIN, - abi.encodeWithSelector( - EverclearTokenBridge.initialize.selector, - OWNER - ) + abi.encodeCall(EverclearTokenBridge.initialize, (address(0), OWNER)) ); bridge = EverclearTokenBridge(address(proxy)); @@ -193,6 +202,8 @@ contract EverclearTokenBridgeTest is Test { outputAsset: OUTPUT_ASSET }) ); + bridge.enrollRemoteRouter(ORIGIN, address(bridge).addressToBytes32()); + bridge.enrollRemoteRouter(DESTINATION, RECIPIENT); vm.stopPrank(); @@ -208,7 +219,9 @@ contract EverclearTokenBridgeTest is Test { function testConstructor() public { EverclearTokenBridge newBridge = new EverclearTokenBridge( - token, + address(token), + 1, + address(mailbox), everclearAdapter ); @@ -231,7 +244,7 @@ contract EverclearTokenBridgeTest is Test { function testInitializeCannotBeCalledTwice() public { vm.expectRevert("Initializable: contract is already initialized"); - bridge.initialize(OWNER); + bridge.initialize(address(0), OWNER); } // ============ setFeeParams Tests ============ @@ -335,9 +348,11 @@ contract EverclearTokenBridgeTest is Test { TRANSFER_AMT ); - assertEq(quotes.length, 1); - assertEq(quotes[0].token, address(token)); - assertEq(quotes[0].amount, TRANSFER_AMT + FEE_AMOUNT); + assertEq(quotes.length, 2); + assertEq(quotes[0].token, address(0)); + assertEq(quotes[0].amount, 0); // Gas payment is 0 for test dispatch hooks + assertEq(quotes[1].token, address(token)); + assertEq(quotes[1].amount, TRANSFER_AMT + FEE_AMOUNT); } // ============ transferRemote Tests ============ @@ -353,9 +368,6 @@ contract EverclearTokenBridgeTest is Test { TRANSFER_AMT ); - // Check return value - assertEq(result, bytes32(0)); - // Check balances assertEq( token.balanceOf(ALICE), @@ -375,7 +387,10 @@ contract EverclearTokenBridgeTest is Test { assertEq(everclearAdapter.lastAmount(), TRANSFER_AMT); assertEq(everclearAdapter.lastMaxFee(), 0); assertEq(everclearAdapter.lastTtl(), 0); - assertEq(everclearAdapter.lastData(), ""); + assertEq( + everclearAdapter.lastData(), + abi.encode(RECIPIENT, TRANSFER_AMT) + ); // Check fee params (uint256 fee, uint256 deadline, bytes memory sig) = everclearAdapter @@ -482,7 +497,7 @@ contract EverclearTokenBridgeTest is Test { RECIPIENT, transferAmount ); - uint256 totalCost = quotes[0].amount; // Token cost including fee + uint256 tokenCost = quotes[1].amount; // Token cost including fee // 2. Execute transfer vm.prank(ALICE); @@ -493,8 +508,7 @@ contract EverclearTokenBridgeTest is Test { ); // 3. Verify state changes - assertEq(transferId, bytes32(0)); // Everclear manages the actual ID - assertEq(token.balanceOf(ALICE), initialAliceBalance - totalCost); + assertEq(token.balanceOf(ALICE), initialAliceBalance - tokenCost); // 4. Verify Everclear intent was created correctly assertEq(everclearAdapter.newIntentCallCount(), 1); @@ -520,17 +534,199 @@ contract EverclearTokenBridgeTest is Test { ); } - // ============ Gas Optimization Tests ============ + // ============ IntentSettled Tests ============ + + function testIntentSettledInitiallyFalse() public { + // Create a mock intent + IEverclear.Intent memory intent = IEverclear.Intent({ + initiator: bytes32(uint256(uint160(ALICE))), + receiver: RECIPIENT, + inputAsset: bytes32(uint256(uint160(address(token)))), + outputAsset: bytes32(uint256(uint160(address(token)))), + maxFee: 0, + origin: ORIGIN, + destinations: new uint32[](1), + nonce: 1, + timestamp: uint48(block.timestamp), + ttl: 0, + amount: 100e18, + data: abi.encode(RECIPIENT, 100e18) + }); + intent.destinations[0] = DESTINATION; - function testGasUsageTransferRemote() public { - vm.prank(ALICE); - uint256 gasBefore = gasleft(); - bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT); - uint256 gasUsed = gasBefore - gasleft(); + bytes32 intentId = keccak256(abi.encode(intent)); + + // Verify intent is not initially settled + assertFalse(bridge.intentSettled(intentId)); + } + + function testIntentSettledAfterProcessing() public { + // Create a mock intent + IEverclear.Intent memory intent = IEverclear.Intent({ + initiator: bytes32(uint256(uint160(ALICE))), + receiver: RECIPIENT, + inputAsset: bytes32(uint256(uint160(address(token)))), + outputAsset: bytes32(uint256(uint160(address(token)))), + maxFee: 0, + origin: ORIGIN, + destinations: new uint32[](1), + nonce: 1, + timestamp: uint48(block.timestamp), + ttl: 0, + amount: 100e18, + data: abi.encode(RECIPIENT, 100e18) + }); + intent.destinations[0] = DESTINATION; + + bytes32 intentId = keccak256(abi.encode(intent)); + + // Mock the spoke to return SETTLED status + vm.mockCall( + address(everclearAdapter.spoke()), + abi.encodeWithSelector(IEverclearSpoke.status.selector, intentId), + abi.encode(IEverclear.IntentStatus.SETTLED) + ); + + // Give the bridge some tokens to transfer + token.mintTo(address(bridge), 100e18); + + // Create a mock message with the intent in metadata + bytes memory metadata = abi.encode(intent); + bytes memory message = TokenMessage.format(RECIPIENT, 100e18, metadata); + + // Simulate receiving the message (this should process the intent) + vm.prank(address(mailbox)); + bridge.handle( + ORIGIN, + bytes32(uint256(uint160(address(bridge)))), + message + ); + + // Verify intent is now settled + assertTrue(bridge.intentSettled(intentId)); + } + + function testIntentAlreadyProcessedReverts() public { + // Create a mock intent + IEverclear.Intent memory intent = IEverclear.Intent({ + initiator: bytes32(uint256(uint160(ALICE))), + receiver: RECIPIENT, + inputAsset: bytes32(uint256(uint160(address(token)))), + outputAsset: bytes32(uint256(uint160(address(token)))), + maxFee: 0, + origin: ORIGIN, + destinations: new uint32[](1), + nonce: 1, + timestamp: uint48(block.timestamp), + ttl: 0, + amount: 100e18, + data: abi.encode(RECIPIENT, 100e18) + }); + intent.destinations[0] = DESTINATION; + + bytes32 intentId = keccak256(abi.encode(intent)); + + // Mock the spoke to return SETTLED status + vm.mockCall( + address(everclearAdapter.spoke()), + abi.encodeWithSelector(IEverclearSpoke.status.selector, intentId), + abi.encode(IEverclear.IntentStatus.SETTLED) + ); + + // Give the bridge some tokens to transfer + token.mintTo(address(bridge), 200e18); + + // Create a mock message with the intent in metadata + bytes memory metadata = abi.encode(intent); + bytes memory message = TokenMessage.format(RECIPIENT, 100e18, metadata); + + // Process the intent first time (should succeed) + vm.prank(address(mailbox)); + bridge.handle( + ORIGIN, + bytes32(uint256(uint160(address(bridge)))), + message + ); + + // Verify intent is settled + assertTrue(bridge.intentSettled(intentId)); + + // Try to process the same intent again (should revert) + vm.prank(address(mailbox)); + vm.expectRevert("ETB: Intent already processed"); + bridge.handle( + ORIGIN, + bytes32(uint256(uint160(address(bridge)))), + message + ); + } + + function testIntentNotSettledReverts() public { + // Create a mock intent + IEverclear.Intent memory intent = IEverclear.Intent({ + initiator: bytes32(uint256(uint160(ALICE))), + receiver: RECIPIENT, + inputAsset: bytes32(uint256(uint160(address(token)))), + outputAsset: bytes32(uint256(uint160(address(token)))), + maxFee: 0, + origin: ORIGIN, + destinations: new uint32[](1), + nonce: 1, + timestamp: uint48(block.timestamp), + ttl: 0, + amount: 100e18, + data: abi.encode(RECIPIENT, 100e18) + }); + intent.destinations[0] = DESTINATION; + + bytes32 intentId = keccak256(abi.encode(intent)); - // Log gas usage for analysis (adjust threshold as needed) - emit log_named_uint("Gas used for transferRemote", gasUsed); - assertTrue(gasUsed < 600000); // Reasonable gas limit (adjusted based on actual usage) + // Mock the spoke to return ADDED status + vm.mockCall( + address(everclearAdapter.spoke()), + abi.encodeWithSelector(IEverclearSpoke.status.selector, intentId), + abi.encode(IEverclear.IntentStatus.ADDED) + ); + + // Create a mock message with the intent in metadata + bytes memory metadata = abi.encode(intent); + bytes memory message = TokenMessage.format(RECIPIENT, 100e18, metadata); + + // Try to process an unsettled intent (should revert) + vm.prank(address(mailbox)); + vm.expectRevert("ETB: Intent Status != SETTLED"); + bridge.handle( + ORIGIN, + bytes32(uint256(uint160(address(bridge)))), + message + ); + + // Verify intent is not settled in our mapping + assertFalse(bridge.intentSettled(intentId)); + } +} + +contract MockEverclearTokenBridge is EverclearTokenBridge { + constructor( + address _weth, + uint256 _scale, + address _mailbox, + IEverclearAdapter _everclearAdapter + ) EverclearTokenBridge(_weth, _scale, _mailbox, _everclearAdapter) {} + + bytes public lastIntent; + function _createIntent( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal override returns (IEverclear.Intent memory) { + IEverclear.Intent memory intent = super._createIntent( + _destination, + _recipient, + _amount + ); + lastIntent = abi.encode(intent); + return intent; } } @@ -541,7 +737,9 @@ contract EverclearTokenBridgeTest is Test { * forge-config: default.evm_version = "cancun" */ contract EverclearTokenBridgeForkTest is Test { - using TypeCasts for address; + using TypeCasts for *; + using Message for bytes; + using stdStorage for StdStorage; // Arbitrum mainnet constants uint32 internal constant ARBITRUM_DOMAIN = 42161; @@ -562,24 +760,31 @@ contract EverclearTokenBridgeForkTest is Test { // Test addresses address internal ALICE = makeAddr("alice"); - address internal constant BOB = address(0x2); - address internal constant OWNER = address(0x3); - address internal constant PROXY_ADMIN = address(0x37); + address internal BOB = makeAddr("bob"); + address internal OWNER = makeAddr("owner"); + address internal PROXY_ADMIN = makeAddr("proxyAdmin"); // Contracts IWETH internal weth; IEverclearAdapter internal everclearAdapter; - EverclearTokenBridge internal bridge; + MockEverclearTokenBridge internal bridge; // Test data bytes32 internal constant OUTPUT_ASSET = bytes32(uint256(uint160(OPTIMISM_WETH))); - bytes32 internal constant RECIPIENT = bytes32(uint256(uint160(BOB))); + bytes32 internal RECIPIENT = bytes32(uint256(uint160(BOB))); uint256 internal feeDeadline; address internal feeSigner; bytes internal feeSignature = hex"123f"; // We will create a real signature in setUp - function setUp() public { + function verify( + bytes calldata _metadata, + bytes calldata _message + ) external returns (bool) { + return true; + } + + function setUp() public virtual { // Fork Arbitrum at the latest block vm.createSelectFork("arbitrum"); @@ -591,8 +796,10 @@ contract EverclearTokenBridgeForkTest is Test { feeDeadline = block.timestamp + 3600; // 1 hour from now // Deploy bridge implementation - EverclearTokenBridge implementation = new EverclearTokenBridge( - weth, + MockEverclearTokenBridge implementation = new MockEverclearTokenBridge( + address(weth), + 1, + address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox everclearAdapter ); @@ -600,13 +807,10 @@ contract EverclearTokenBridgeForkTest is Test { TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(implementation), PROXY_ADMIN, - abi.encodeWithSelector( - EverclearTokenBridge.initialize.selector, - OWNER - ) + abi.encodeCall(EverclearTokenBridge.initialize, (address(0), OWNER)) ); - bridge = EverclearTokenBridge(address(proxy)); + bridge = MockEverclearTokenBridge(address(proxy)); // It would be great if we could mock the ecrecover function to always return the fee signer for the adapter // but we can't do that with forge. So we're going to sign the fee params with the fee signer private key @@ -638,6 +842,24 @@ contract EverclearTokenBridgeForkTest is Test { outputAsset: OUTPUT_ASSET }) ); + bridge.enrollRemoteRouter( + OPTIMISM_DOMAIN, + address(bridge).addressToBytes32() + ); + + // Handle ARB-ARB transfers as well + bridge.setOutputAsset( + OutputAssetInfo({ + destination: ARBITRUM_DOMAIN, + outputAsset: bytes32(uint256(uint160(ARBITRUM_WETH))) + }) + ); + bridge.enrollRemoteRouter( + ARBITRUM_DOMAIN, + address(bridge).addressToBytes32() + ); + // We will be the ism for this bridge + bridge.setInterchainSecurityModule(address(this)); vm.stopPrank(); // Setup allowances @@ -678,4 +900,184 @@ contract EverclearTokenBridgeForkTest is Test { // The bridge forwards all weth to the adapter, so the bridge balance should be the same assertEq(weth.balanceOf(address(bridge)), initialBridgeBalance); } + + function testFork_receiveMessage(uint256 amount) public { + amount = bound(amount, 1, 100e6 ether); + uint depositAmount = amount + FEE_AMOUNT; + vm.deal(ALICE, depositAmount); + vm.prank(ALICE); + weth.deposit{value: depositAmount}(); + + uint256 initialBalance = weth.balanceOf(ALICE); + uint256 initialBridgeBalance = weth.balanceOf(address(bridge)); + + // Replace mailbox with code from MockMailbox + MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN); + vm.etch(address(bridge.mailbox()), address(_mailbox).code); + MockMailbox mailbox = MockMailbox(address(bridge.mailbox())); + mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox); + + // Test the transfer + vm.prank(ALICE); + + // Actually sending message to arbitrum + bridge.transferRemote(ARBITRUM_DOMAIN, RECIPIENT, amount); + + bytes memory intent = bridge.lastIntent(); + bytes32 intentId = keccak256(intent); + + // Settle the created intent via direct storage write + stdstore + .target(address(bridge.everclearSpoke())) + .sig(bridge.everclearSpoke().status.selector) + .with_key(intentId) + .checked_write(uint8(IEverclear.IntentStatus.SETTLED)); + + assertEq( + uint(bridge.everclearSpoke().status(intentId)), + uint(IEverclear.IntentStatus.SETTLED) + ); + + // Give the bridge some WETH + vm.deal(address(bridge), amount); + vm.prank(address(bridge)); + weth.deposit{value: amount}(); + + // Process the hyperlane message -> call handle directly + // Deliver the message to the recipient. + mailbox.processNextInboundMessage(); + + // Funds should be sent to actual recipient + assertEq(weth.balanceOf(BOB), amount); + } +} + +/** + * @notice Fork test contract for EverclearEthBridge on Arbitrum + * @dev Tests the ETH bridge using real Arbitrum state and contracts with ETH transfers to Optimism + * @dev Inherits from EverclearTokenBridgeForkTest to reuse setup logic + * @dev We're running the cancun evm version, to avoid `NotActivated` errors + * forge-config: default.evm_version = "cancun" + */ +contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { + using TypeCasts for address; + + // ETH bridge contract + EverclearEthBridge internal ethBridge; + + function setUp() public override { + // Call parent setUp to initialize fork and all base contracts + super.setUp(); + + // Deploy ETH bridge implementation + EverclearEthBridge implementation = new EverclearEthBridge( + IWETH(ARBITRUM_WETH), + 1, + address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox + everclearAdapter + ); + + // Deploy proxy + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN, + abi.encodeCall( + EverclearTokenBridge.initialize, + (address(new TestPostDispatchHook()), OWNER) + ) + ); + + ethBridge = EverclearEthBridge(payable(address(proxy))); + + // Configure the ETH bridge using existing fee params and signature + vm.startPrank(OWNER); + ethBridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + ethBridge.setOutputAsset( + OutputAssetInfo({ + destination: OPTIMISM_DOMAIN, + outputAsset: OUTPUT_ASSET + }) + ); + ethBridge.enrollRemoteRouter(OPTIMISM_DOMAIN, RECIPIENT); + vm.stopPrank(); + } + + function testFuzz_EthBridgeTransferRemote(uint256 amount) public { + // Bound the amount to reasonable values + amount = bound(amount, 1e15, 10e18); // 0.001 ETH to 10 ETH + uint256 totalAmount = amount + FEE_AMOUNT; + + // Give Alice enough ETH + vm.deal(ALICE, totalAmount); + + uint256 initialAliceBalance = ALICE.balance; + uint256 initialBridgeBalance = weth.balanceOf(address(ethBridge)); + + // Test the transfer - expect IntentWithFeesAdded event + vm.prank(ALICE); + vm.expectEmit(false, true, true, true); + emit IEverclearAdapter.IntentWithFeesAdded({ + _intentId: bytes32(0), + _initiator: address(ethBridge).addressToBytes32(), + _tokenFee: FEE_AMOUNT, + _nativeFee: 0 + }); + ethBridge.transferRemote{value: totalAmount}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + + // Verify the balance changes + // Alice should have lost the total ETH amount (amount + fee) + assertEq(ALICE.balance, initialAliceBalance - totalAmount); + // The bridge should not hold any WETH (it forwards to adapter) + assertEq(weth.balanceOf(address(ethBridge)), initialBridgeBalance); + } + + function testEthBridgeTransferRemoteInsufficientETH() public { + uint256 amount = 1e18; // 1 ETH + uint256 totalAmount = amount + FEE_AMOUNT; + + // Give Alice less ETH than needed + vm.deal(ALICE, totalAmount - 1); + + vm.prank(ALICE); + vm.expectRevert("EEB: ETH amount mismatch"); + ethBridge.transferRemote{value: totalAmount - 1}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + } + + function testEthBridgeQuoteTransferRemote() public { + uint256 amount = 1e18; // 1 ETH + + Quote[] memory quotes = ethBridge.quoteTransferRemote( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + + assertEq(quotes.length, 1); + assertEq(quotes[0].token, address(0)); + assertEq(quotes[0].amount, amount + FEE_AMOUNT); + } + + function testEthBridgeConstructor() public { + EverclearEthBridge newBridge = new EverclearEthBridge( + IWETH(ARBITRUM_WETH), + 1, + address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox + everclearAdapter + ); + + assertEq(address(newBridge.wrappedToken()), address(weth)); + assertEq( + address(newBridge.everclearAdapter()), + address(everclearAdapter) + ); + assertEq(address(newBridge.token()), address(weth)); + } } From 7a41068f7cd932c154acd874dfbf3da0a4c1c9fa Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Fri, 18 Jul 2025 14:41:26 -0400 Subject: [PATCH 14/36] fix: cctp v2 burn amount (#6748) --- .changeset/angry-pandas-swim.md | 5 + .../contracts/token/TokenBridgeCctpBase.sol | 5 +- .../contracts/token/TokenBridgeCctpV1.sol | 2 - .../contracts/token/TokenBridgeCctpV2.sol | 12 +- solidity/test/token/TokenBridgeCctp.t.sol | 156 +++++++++++++----- typescript/sdk/src/token/deploy.ts | 2 - 6 files changed, 128 insertions(+), 54 deletions(-) create mode 100644 .changeset/angry-pandas-swim.md diff --git a/.changeset/angry-pandas-swim.md b/.changeset/angry-pandas-swim.md new file mode 100644 index 0000000000..a590752860 --- /dev/null +++ b/.changeset/angry-pandas-swim.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Fix CCTP v2 transferRemote amount diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 19d2481150..51c1418029 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -38,6 +38,8 @@ abstract contract TokenBridgeCctpBase is using TypeCasts for bytes32; using SafeERC20 for IERC20; + uint256 private constant _SCALE = 1; + IERC20 public immutable wrappedToken; // @notice CCTP message transmitter contract @@ -64,11 +66,10 @@ abstract contract TokenBridgeCctpBase is constructor( address _erc20, - uint256 _scale, address _mailbox, IMessageTransmitter _messageTransmitter, ITokenMessenger _tokenMessenger - ) FungibleTokenRouter(_scale, _mailbox) { + ) FungibleTokenRouter(_SCALE, _mailbox) { require( _messageTransmitter.version() == _getCCTPVersion(), "Invalid messageTransmitter CCTP version" diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 26011b0293..3e906e8563 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -26,14 +26,12 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { constructor( address _erc20, - uint256 _scale, address _mailbox, IMessageTransmitter _messageTransmitter, ITokenMessengerV1 _tokenMessenger ) TokenBridgeCctpBase( _erc20, - _scale, _mailbox, _messageTransmitter, _tokenMessenger diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index fe285964cd..fb166aa48b 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -29,7 +29,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { constructor( address _erc20, - uint256 _scale, address _mailbox, IMessageTransmitterV2 _messageTransmitter, ITokenMessengerV2 _tokenMessenger, @@ -38,7 +37,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { ) TokenBridgeCctpBase( _erc20, - _scale, _mailbox, _messageTransmitter, _tokenMessenger @@ -173,13 +171,15 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { override returns (uint256 dispatchValue, bytes memory message) { - uint256 fastFee = _feeAmount(destination, recipient, amount); - _transferFromSender(amount + fastFee); + uint256 burnAmount = amount + + _feeAmount(destination, recipient, amount); + + _transferFromSender(burnAmount); uint32 circleDomain = hyperlaneDomainToCircleDomain(destination); ITokenMessengerV2(address(tokenMessenger)).depositForBurn( - amount, + burnAmount, circleDomain, recipient, address(wrappedToken), @@ -189,7 +189,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { ); dispatchValue = msg.value; - message = TokenMessage.format(recipient, _outboundAmount(amount)); + message = TokenMessage.format(recipient, burnAmount); _validateTokenMessageLength(message); } } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 55b3d1d633..ec2ca1ba65 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -40,7 +40,6 @@ contract TokenBridgeCctpV1Test is Test { uint32 internal constant CCTP_VERSION_1 = 0; uint32 internal constant CCTP_VERSION_2 = 1; - uint256 internal constant scale = 1; uint32 internal constant origin = 1; uint32 internal constant destination = 2; uint32 internal constant cctpOrigin = 0; @@ -110,7 +109,6 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 originImplementation = new TokenBridgeCctpV1( address(tokenOrigin), - scale, address(mailboxOrigin), messageTransmitterOrigin, tokenMessengerOrigin @@ -131,7 +129,6 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 destinationImplementation = new TokenBridgeCctpV1( address(tokenDestination), - scale, address(mailboxDestination), messageTransmitterDestination, tokenMessengerDestination @@ -338,7 +335,6 @@ contract TokenBridgeCctpV1Test is Test { function _upgrade(TokenBridgeCctpBase bridge) internal virtual { TokenBridgeCctpV1 newImplementation = new TokenBridgeCctpV1( address(bridge.wrappedToken()), - bridge.scale(), address(bridge.mailbox()), bridge.messageTransmitter(), ITokenMessengerV1(address(bridge.tokenMessenger())) @@ -508,7 +504,6 @@ contract TokenBridgeCctpV1Test is Test { vm.expectRevert(bytes("Invalid TokenMessenger CCTP version")); TokenBridgeCctpV1 v1 = new TokenBridgeCctpV1( address(tokenOrigin), - scale, address(mailboxOrigin), messageTransmitterOrigin, tokenMessengerOrigin @@ -518,7 +513,6 @@ contract TokenBridgeCctpV1Test is Test { vm.expectRevert(bytes("Invalid messageTransmitter CCTP version")); v1 = new TokenBridgeCctpV1( address(tokenOrigin), - scale, address(mailboxOrigin), messageTransmitterOrigin, tokenMessengerOrigin @@ -856,7 +850,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { TokenBridgeCctpV2 originImplementation = new TokenBridgeCctpV2( address(tokenOrigin), - scale, address(mailboxOrigin), messageTransmitterOrigin, tokenMessengerOrigin, @@ -879,7 +872,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { TokenBridgeCctpV2 destinationImplementation = new TokenBridgeCctpV2( address(tokenDestination), - scale, address(mailboxDestination), messageTransmitterDestination, tokenMessengerDestination, @@ -898,53 +890,69 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { _setupTokenBridgesCctp(tbOrigin, tbDestination); } + function _setNonce(bytes memory cctpMessage, bytes32 nonce) internal view { + // length + NONCE_INDEX + uint256 nonceOffset = 32 + 12; + assembly { + mstore(add(cctpMessage, nonceOffset), nonce) + } + } + function _encodeCctpBurnMessage( uint64 nonce, uint32 sourceDomain, bytes32 recipient, uint256 amount, address sender - ) internal view override returns (bytes memory) { + ) internal view override returns (bytes memory cctpMessage) { bytes memory burnMessage = BurnMessageV2._formatMessageForRelay( version, address(tokenOrigin).addressToBytes32(), recipient, - amount, + amount + (amount * maxFee) / 10_000, sender.addressToBytes32(), maxFee, bytes("") ); - return - CctpMessageV2._formatMessageForRelay( - version, - sourceDomain, - cctpDestination, - address(tokenMessengerOrigin).addressToBytes32(), - address(tokenMessengerDestination).addressToBytes32(), - bytes32(0), - minFinalityThreshold, - burnMessage - ); + cctpMessage = CctpMessageV2._formatMessageForRelay( + version, + sourceDomain, + cctpDestination, + address(tokenMessengerOrigin).addressToBytes32(), + address(tokenMessengerDestination).addressToBytes32(), + bytes32(0), + minFinalityThreshold, + burnMessage + ); + // pseudo random + bytes32 nonceBytes = keccak256( + abi.encode(nonce, sender, recipient, amount) + ); + _setNonce(cctpMessage, nonceBytes); } function _encodeCctpHookMessage( bytes32 sender, bytes32 recipient, bytes memory message - ) internal view override returns (bytes memory) { - return - CctpMessageV2._formatMessageForRelay( - version, - cctpOrigin, - cctpDestination, - sender, - recipient, - bytes32(0), - minFinalityThreshold, - message - ); + ) internal view override returns (bytes memory cctpMessage) { + cctpMessage = CctpMessageV2._formatMessageForRelay( + version, + cctpOrigin, + cctpDestination, + sender, + recipient, + bytes32(0), + minFinalityThreshold, + message + ); + // pseudo random nonce + bytes32 nonce = keccak256(abi.encode(sender, recipient, message)); + _setNonce(cctpMessage, nonce); } + address constant usdc = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + function _deploy() internal returns (TokenBridgeCctpV2) { ITokenMessengerV2 tokenMessenger = ITokenMessengerV2( address(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d) @@ -955,8 +963,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); TokenBridgeCctpV2 implementation = new TokenBridgeCctpV2( - 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, - 1, + usdc, 0xeA87ae93Fa0019a82A727bfd3eBd1cFCa8f64f1D, messageTransmitter, tokenMessenger, @@ -1027,7 +1034,8 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { TokenBridgeCctpV2 router = _deploy(); uint32 destination = 1; // ethereum - router.addDomain(destination, 0); + uint32 circleDestination = 0; + router.addDomain(destination, circleDestination); router.enrollRemoteRouter(destination, ism); Quote[] memory quotes = router.quoteTransferRemote( @@ -1036,8 +1044,27 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { amount ); - deal(quotes[1].token, address(this), quotes[1].amount); - IERC20(quotes[1].token).approve(address(router), quotes[1].amount); + assertEq(quotes[1].token, usdc); + uint256 usdcQuote = quotes[1].amount; + + deal(usdc, address(this), usdcQuote); + IERC20(usdc).approve(address(router), usdcQuote); + + vm.expectEmit(true, true, true, true, address(router.tokenMessenger())); + emit ITokenMessengerV2.DepositForBurn( + usdc, + usdcQuote, + address(router), + recipient, + circleDestination, + bytes32( + 0x00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d + ), // tokenMessengerDestination + bytes32(0), // destinationCaller + maxFee, + minFinalityThreshold, + bytes("") + ); router.transferRemote{value: quotes[0].amount}( destination, @@ -1046,6 +1073,52 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); } + event MintAndWithdraw( + address indexed mintRecipient, + uint256 amount, + address indexed mintToken, + uint256 feeCollected + ); + + function testFork_verify_tokenMessage() public { + vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); + + TokenBridgeCctpV2 ism = _deploy(); + + bytes32 hook = deployer.addressToBytes32(); + + uint32 origin = 10; // optimism + uint32 circleOrigin = 2; + ism.addDomain(origin, circleOrigin); + ism.enrollRemoteRouter(origin, hook); + + // https://optimistic.etherscan.io/tx/0x4a8c5aef605bd1a79d7e4ab7b1852d246a05859a168db2b4791563877f2f3325 + uint256 amount = 2; + uint256 fee = 1; + bytes + memory cctpMessage = hex"0000000100000002000000069abb52aa4e37d2ee3e521f9bc92e97581a68dadcd826fd2abaa5150de95db90e00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000003e8000003e8000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000001f825d8"; + + // https://iris-api.circle.com/v2/messages/2?transactionHash=0x4a8c5aef605bd1a79d7e4ab7b1852d246a05859a168db2b4791563877f2f3325 + bytes + memory attestation = hex"f75d61f667685827a63a857fcfae06fd9c42860c9a94175a2041a98941c874303aa44a973bf28b447ecc39e25d81a869584e9379d41f669dc526bf3b6810a0161c278bcd556ac5dd462095094af97a8773b33c00788a362c424d5569bdb4c2fb853ab4aefab3839bc8128e280f16fc09c6cfb11361061e527f5804fa6c6b130dc91b"; + + bytes memory metadata = abi.encode(cctpMessage, attestation); + + bytes memory message = abi.encodePacked( + uint8(3), + uint32(0), + origin, + hook, + ism.localDomain(), + address(ism).addressToBytes32(), + abi.encode(hook, amount) + ); + + vm.expectEmit(true, true, true, true, address(ism.tokenMessenger())); + emit MintAndWithdraw(deployer, amount - fee, usdc, fee); + ism.verify(metadata, message); + } + function testFork_postDispatch( bytes32 recipient, bytes calldata body @@ -1103,15 +1176,16 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { amount ); + uint256 tokenQuote = quote[1].amount; vm.startPrank(user); - tokenOrigin.approve(address(tbOrigin), quote[1].amount); + tokenOrigin.approve(address(tbOrigin), tokenQuote); vm.expectCall( address(tokenMessengerOrigin), abi.encodeCall( ITokenMessengerV2.depositForBurn, ( - amount, + tokenQuote, cctpDestination, user.addressToBytes32(), address(tokenOrigin), @@ -1174,7 +1248,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { vm.expectRevert(bytes("Invalid TokenMessenger CCTP version")); TokenBridgeCctpV2 v2 = new TokenBridgeCctpV2( address(tokenOrigin), - scale, address(mailboxOrigin), messageTransmitterOrigin, tokenMessengerOrigin, @@ -1186,7 +1259,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { vm.expectRevert(bytes("Invalid messageTransmitter CCTP version")); v2 = new TokenBridgeCctpV2( address(tokenOrigin), - scale, address(mailboxOrigin), messageTransmitterOrigin, tokenMessengerOrigin, diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index f6590630fb..b9d3fdd9f6 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -152,7 +152,6 @@ abstract class TokenDeployer< case 'V1': return [ config.token, - scale, config.mailbox, config.messageTransmitter, config.tokenMessenger, @@ -160,7 +159,6 @@ abstract class TokenDeployer< case 'V2': return [ config.token, - scale, config.mailbox, config.messageTransmitter, config.tokenMessenger, From b6342352602fe607a2c7f74836b69997820c9492 Mon Sep 17 00:00:00 2001 From: Nam Chu Hoai Date: Fri, 19 Sep 2025 12:07:08 -0400 Subject: [PATCH 15/36] chore: Remove old Liquidity layer code (#6829) --- .../ILiquidityLayerMessageRecipient.sol | 12 - .../interfaces/ILiquidityLayerRouter.sol | 13 - .../liquidity-layer/LiquidityLayerRouter.sol | 138 -------- .../adapters/CircleBridgeAdapter.sol | 242 ------------- .../adapters/PortalAdapter.sol | 214 ------------ .../interfaces/ILiquidityLayerAdapter.sol | 18 - .../circle/ICircleMessageTransmitter.sol | 39 --- .../interfaces/circle/ITokenMessenger.sol | 59 ---- .../interfaces/portal/IPortalTokenBridge.sol | 86 ----- solidity/contracts/mock/MockPortalBridge.sol | 86 ----- .../TestLiquidityLayerMessageRecipient.sol | 24 -- .../contracts/test/TestTokenRecipient.sol | 44 --- solidity/test/LiquidityLayerRouter.t.sol | 290 ---------------- .../liquidity-layer/PortalAdapter.t.sol | 135 -------- .../config/environments/mainnet3/index.ts | 5 - .../environments/mainnet3/liquidityLayer.ts | 52 --- .../middleware/liquidity-layer/addresses.json | 12 - .../liquidity-layer/verification.json | 42 --- .../environments/mainnet3/token-bridge.ts | 22 -- .../infra/config/environments/test/index.ts | 2 +- .../middleware/liquidity-layer/addresses.json | 15 - .../liquidity-layer/verification.json | 62 ---- .../config/environments/testnet4/index.ts | 6 - .../environments/testnet4/liquidityLayer.ts | 57 ---- .../environments/testnet4/middleware.ts | 13 - .../middleware/liquidity-layer/addresses.json | 18 - .../liquidity-layer/verification.json | 50 --- .../environments/testnet4/token-bridge.ts | 57 ---- .../helm/liquidity-layer-relayers/Chart.yaml | 24 -- .../templates/_helpers.tpl | 42 --- .../templates/circle-relayer-deployment.yaml | 26 -- .../templates/env-var-external-secret.yaml | 45 --- .../templates/portal-relayer-deployment.yaml | 26 -- .../helm/liquidity-layer-relayers/values.yaml | 9 - typescript/infra/scripts/deploy.ts | 19 -- .../scripts/middleware/circle-relayer.ts | 63 ---- .../scripts/middleware/deploy-relayers.ts | 28 -- .../scripts/middleware/portal-relayer.ts | 77 ----- typescript/infra/src/config/environment.ts | 6 - typescript/infra/src/config/middleware.ts | 7 - .../src/middleware/liquidity-layer-relayer.ts | 69 ---- typescript/sdk/src/index.ts | 10 - .../liquidity-layer/LiquidityLayerApp.ts | 309 ----------------- .../LiquidityLayerRouterDeployer.ts | 319 ------------------ .../middleware/liquidity-layer/contracts.ts | 16 - .../liquidity-layer.hardhat-test.ts | 192 ----------- 46 files changed, 1 insertion(+), 3099 deletions(-) delete mode 100644 solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol delete mode 100644 solidity/contracts/interfaces/ILiquidityLayerRouter.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol delete mode 100644 solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol delete mode 100644 solidity/contracts/mock/MockPortalBridge.sol delete mode 100644 solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol delete mode 100644 solidity/contracts/test/TestTokenRecipient.sol delete mode 100644 solidity/test/LiquidityLayerRouter.t.sol delete mode 100644 solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol delete mode 100644 typescript/infra/config/environments/mainnet3/liquidityLayer.ts delete mode 100644 typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json delete mode 100644 typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json delete mode 100644 typescript/infra/config/environments/mainnet3/token-bridge.ts delete mode 100644 typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json delete mode 100644 typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json delete mode 100644 typescript/infra/config/environments/testnet4/liquidityLayer.ts delete mode 100644 typescript/infra/config/environments/testnet4/middleware.ts delete mode 100644 typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json delete mode 100644 typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json delete mode 100644 typescript/infra/config/environments/testnet4/token-bridge.ts delete mode 100644 typescript/infra/helm/liquidity-layer-relayers/Chart.yaml delete mode 100644 typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl delete mode 100644 typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml delete mode 100644 typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml delete mode 100644 typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml delete mode 100644 typescript/infra/helm/liquidity-layer-relayers/values.yaml delete mode 100644 typescript/infra/scripts/middleware/circle-relayer.ts delete mode 100644 typescript/infra/scripts/middleware/deploy-relayers.ts delete mode 100644 typescript/infra/scripts/middleware/portal-relayer.ts delete mode 100644 typescript/infra/src/config/middleware.ts delete mode 100644 typescript/infra/src/middleware/liquidity-layer-relayer.ts delete mode 100644 typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts delete mode 100644 typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts delete mode 100644 typescript/sdk/src/middleware/liquidity-layer/contracts.ts delete mode 100644 typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts diff --git a/solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol b/solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol deleted file mode 100644 index 1fc03e334a..0000000000 --- a/solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -interface ILiquidityLayerMessageRecipient { - function handleWithTokens( - uint32 _origin, - bytes32 _sender, - bytes calldata _message, - address _token, - uint256 _amount - ) external; -} diff --git a/solidity/contracts/interfaces/ILiquidityLayerRouter.sol b/solidity/contracts/interfaces/ILiquidityLayerRouter.sol deleted file mode 100644 index db9c2549d0..0000000000 --- a/solidity/contracts/interfaces/ILiquidityLayerRouter.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity >=0.6.11; - -interface ILiquidityLayerRouter { - function dispatchWithTokens( - uint32 _destinationDomain, - bytes32 _recipientAddress, - address _token, - uint256 _amount, - string calldata _bridge, - bytes calldata _messageBody - ) external returns (bytes32); -} diff --git a/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol b/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol deleted file mode 100644 index 9b6271006a..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity >=0.8.0; - -import {Router} from "../../client/Router.sol"; - -import {TypeCasts} from "../../libs/TypeCasts.sol"; - -import {ILiquidityLayerRouter} from "../../interfaces/ILiquidityLayerRouter.sol"; -import {ILiquidityLayerAdapter} from "./interfaces/ILiquidityLayerAdapter.sol"; -import {ILiquidityLayerMessageRecipient} from "../../interfaces/ILiquidityLayerMessageRecipient.sol"; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -contract LiquidityLayerRouter is Router, ILiquidityLayerRouter { - using SafeERC20 for IERC20; - - // Token bridge => adapter address - mapping(string bridge => address adapter) public liquidityLayerAdapters; - - event LiquidityLayerAdapterSet(string indexed bridge, address adapter); - - constructor(address _mailbox) Router(_mailbox) {} - - /** - * @notice Initializes the Router contract with Hyperlane core contracts and the address of the interchain security module. - * @param _interchainGasPaymaster The address of the interchain gas paymaster contract. - * @param _interchainSecurityModule The address of the interchain security module contract. - * @param _owner The address with owner privileges. - */ - function initialize( - address _interchainGasPaymaster, - address _interchainSecurityModule, - address _owner - ) external initializer { - _MailboxClient_initialize( - _interchainGasPaymaster, - _interchainSecurityModule, - _owner - ); - } - - function dispatchWithTokens( - uint32 _destinationDomain, - bytes32 _recipientAddress, - address _token, - uint256 _amount, - string calldata _bridge, - bytes calldata _messageBody - ) external returns (bytes32) { - ILiquidityLayerAdapter _adapter = _getAdapter(_bridge); - - // Transfer the tokens to the adapter - IERC20(_token).safeTransferFrom(msg.sender, address(_adapter), _amount); - - // Reverts if the bridge was unsuccessful. - // Gets adapter-specific data that is encoded into the message - // ultimately sent via Hyperlane. - bytes memory _adapterData = _adapter.sendTokens( - _destinationDomain, - _recipientAddress, - _token, - _amount - ); - - // The user's message "wrapped" with metadata required by this middleware - bytes memory _messageWithMetadata = abi.encode( - TypeCasts.addressToBytes32(msg.sender), - _recipientAddress, // The "user" recipient - _amount, // The amount of the tokens sent over the bridge - _bridge, // The destination token bridge ID - _adapterData, // The adapter-specific data - _messageBody // The "user" message - ); - - // Dispatch the _messageWithMetadata to the destination's LiquidityLayerRouter. - return _dispatch(_destinationDomain, _messageWithMetadata); - } - - // Handles a message from an enrolled remote LiquidityLayerRouter - function _handle( - uint32 _origin, - bytes32, // _sender, unused - bytes calldata _message - ) internal override { - // Decode the message with metadata, "unwrapping" the user's message body - ( - bytes32 _originalSender, - bytes32 _userRecipientAddress, - uint256 _amount, - string memory _bridge, - bytes memory _adapterData, - bytes memory _userMessageBody - ) = abi.decode( - _message, - (bytes32, bytes32, uint256, string, bytes, bytes) - ); - - ILiquidityLayerMessageRecipient _userRecipient = ILiquidityLayerMessageRecipient( - TypeCasts.bytes32ToAddress(_userRecipientAddress) - ); - - // Reverts if the adapter hasn't received the bridged tokens yet - (address _token, uint256 _receivedAmount) = _getAdapter(_bridge) - .receiveTokens( - _origin, - address(_userRecipient), - _amount, - _adapterData - ); - - if (_userMessageBody.length > 0) { - _userRecipient.handleWithTokens( - _origin, - _originalSender, - _userMessageBody, - _token, - _receivedAmount - ); - } - } - - function setLiquidityLayerAdapter( - string calldata _bridge, - address _adapter - ) external onlyOwner { - liquidityLayerAdapters[_bridge] = _adapter; - emit LiquidityLayerAdapterSet(_bridge, _adapter); - } - - function _getAdapter( - string memory _bridge - ) internal view returns (ILiquidityLayerAdapter _adapter) { - _adapter = ILiquidityLayerAdapter(liquidityLayerAdapters[_bridge]); - // Require the adapter to have been set - require(address(_adapter) != address(0), "No adapter found for bridge"); - } -} diff --git a/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol b/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol deleted file mode 100644 index fa3ebfe9d5..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -import {Router} from "../../../client/Router.sol"; - -import {ITokenMessenger} from "../interfaces/circle/ITokenMessenger.sol"; -import {ICircleMessageTransmitter} from "../interfaces/circle/ICircleMessageTransmitter.sol"; -import {ILiquidityLayerAdapter} from "../interfaces/ILiquidityLayerAdapter.sol"; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -contract CircleBridgeAdapter is ILiquidityLayerAdapter, Router { - using SafeERC20 for IERC20; - - /// @notice The TokenMessenger contract. - ITokenMessenger public tokenMessenger; - - /// @notice The Circle MessageTransmitter contract. - ICircleMessageTransmitter public circleMessageTransmitter; - - /// @notice The LiquidityLayerRouter contract. - address public liquidityLayerRouter; - - /// @notice Hyperlane domain => Circle domain. - /// ATM, known Circle domains are Ethereum = 0 and Avalanche = 1. - /// Note this could result in ambiguity between the Circle domain being - /// Ethereum or unknown. - mapping(uint32 hyperlaneDomain => uint32 circleDomain) - public hyperlaneDomainToCircleDomain; - - /// @notice Token symbol => address of token on local chain. - mapping(string tokenSymbol => IERC20 token) public tokenSymbolToAddress; - - /// @notice Local chain token address => token symbol. - mapping(address token => string tokenSymbol) public tokenAddressToSymbol; - - /** - * @notice Emits the nonce of the Circle message when a token is bridged. - * @param nonce The nonce of the Circle message. - */ - event BridgedToken(uint64 nonce); - - /** - * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. - * @param hyperlaneDomain The Hyperlane domain. - * @param circleDomain The Circle domain. - */ - event DomainAdded(uint32 indexed hyperlaneDomain, uint32 circleDomain); - - /** - * @notice Emitted when a local token and its token symbol have been added. - */ - event TokenAdded(address indexed token, string indexed symbol); - - /** - * @notice Emitted when a local token and its token symbol have been removed. - */ - event TokenRemoved(address indexed token, string indexed symbol); - - modifier onlyLiquidityLayerRouter() { - require(msg.sender == liquidityLayerRouter, "!liquidityLayerRouter"); - _; - } - - constructor(address _mailbox) Router(_mailbox) {} - - /** - * @param _owner The new owner. - * @param _tokenMessenger The TokenMessenger contract. - * @param _circleMessageTransmitter The Circle MessageTransmitter contract. - * @param _liquidityLayerRouter The LiquidityLayerRouter contract. - */ - function initialize( - address _owner, - address _tokenMessenger, - address _circleMessageTransmitter, - address _liquidityLayerRouter - ) external initializer { - __Ownable_init(); - _transferOwnership(_owner); - - tokenMessenger = ITokenMessenger(_tokenMessenger); - circleMessageTransmitter = ICircleMessageTransmitter( - _circleMessageTransmitter - ); - liquidityLayerRouter = _liquidityLayerRouter; - } - - function sendTokens( - uint32 _destinationDomain, - bytes32, // _recipientAddress, unused - address _token, - uint256 _amount - ) external onlyLiquidityLayerRouter returns (bytes memory) { - string memory _tokenSymbol = tokenAddressToSymbol[_token]; - require( - bytes(_tokenSymbol).length > 0, - "CircleBridgeAdapter: Unknown token" - ); - - uint32 _circleDomain = hyperlaneDomainToCircleDomain[ - _destinationDomain - ]; - bytes32 _remoteRouter = _mustHaveRemoteRouter(_destinationDomain); - - // Approve the token to Circle. We assume that the LiquidityLayerRouter - // has already transferred the token to this contract. - require( - IERC20(_token).approve(address(tokenMessenger), _amount), - "!approval" - ); - - uint64 _nonce = tokenMessenger.depositForBurn( - _amount, - _circleDomain, - _remoteRouter, // Mint to the remote router - _token - ); - - emit BridgedToken(_nonce); - return abi.encode(_nonce, _tokenSymbol); - } - - // Returns the token and amount sent - function receiveTokens( - uint32 _originDomain, // Hyperlane domain - address _recipient, - uint256 _amount, - bytes calldata _adapterData // The adapter data from the message - ) external onlyLiquidityLayerRouter returns (address, uint256) { - _mustHaveRemoteRouter(_originDomain); - // The origin Circle domain - uint32 _originCircleDomain = hyperlaneDomainToCircleDomain[ - _originDomain - ]; - // Get the token symbol and nonce of the transfer from the _adapterData - (uint64 _nonce, string memory _tokenSymbol) = abi.decode( - _adapterData, - (uint64, string) - ); - - // Require the circle message to have been processed - bytes32 _nonceId = _circleNonceId(_originCircleDomain, _nonce); - require( - circleMessageTransmitter.usedNonces(_nonceId), - "Circle message not processed yet" - ); - - IERC20 _token = tokenSymbolToAddress[_tokenSymbol]; - require( - address(_token) != address(0), - "CircleBridgeAdapter: Unknown token" - ); - - // Transfer the token out to the recipient - // Circle doesn't charge any fee, so we can safely transfer out the - // exact amount that was bridged over. - _token.safeTransfer(_recipient, _amount); - - return (address(_token), _amount); - } - - // This contract is only a Router to be aware of remote router addresses, - // and doesn't actually send/handle Hyperlane messages directly - function _handle( - uint32, // origin - bytes32, // sender - bytes calldata // message - ) internal pure override { - revert("No messages expected"); - } - - function addDomain( - uint32 _hyperlaneDomain, - uint32 _circleDomain - ) external onlyOwner { - hyperlaneDomainToCircleDomain[_hyperlaneDomain] = _circleDomain; - - emit DomainAdded(_hyperlaneDomain, _circleDomain); - } - - function addToken( - address _token, - string calldata _tokenSymbol - ) external onlyOwner { - require( - _token != address(0) && bytes(_tokenSymbol).length > 0, - "Cannot add default values" - ); - - // Require the token and token symbol to be unset. - address _existingToken = address(tokenSymbolToAddress[_tokenSymbol]); - require(_existingToken == address(0), "token symbol already has token"); - - string memory _existingSymbol = tokenAddressToSymbol[_token]; - require( - bytes(_existingSymbol).length == 0, - "token already has token symbol" - ); - - tokenAddressToSymbol[_token] = _tokenSymbol; - tokenSymbolToAddress[_tokenSymbol] = IERC20(_token); - - emit TokenAdded(_token, _tokenSymbol); - } - - function removeToken( - address _token, - string calldata _tokenSymbol - ) external onlyOwner { - // Require the provided token and token symbols match what's in storage. - address _existingToken = address(tokenSymbolToAddress[_tokenSymbol]); - require(_existingToken == _token, "Token mismatch"); - - string memory _existingSymbol = tokenAddressToSymbol[_token]; - require( - keccak256(bytes(_existingSymbol)) == keccak256(bytes(_tokenSymbol)), - "Token symbol mismatch" - ); - - // Delete them from storage. - delete tokenSymbolToAddress[_tokenSymbol]; - delete tokenAddressToSymbol[_token]; - - emit TokenRemoved(_token, _tokenSymbol); - } - - /** - * @notice Gets the Circle nonce ID by hashing _originCircleDomain and _nonce. - * @param _originCircleDomain Domain of chain where the transfer originated - * @param _nonce The unique identifier for the message from source to - destination - * @return hash of source and nonce - */ - function _circleNonceId( - uint32 _originCircleDomain, - uint64 _nonce - ) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(_originCircleDomain, _nonce)); - } -} diff --git a/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol b/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol deleted file mode 100644 index de1c403438..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol +++ /dev/null @@ -1,214 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -import {Router} from "../../../client/Router.sol"; - -import {IPortalTokenBridge} from "../interfaces/portal/IPortalTokenBridge.sol"; -import {ILiquidityLayerAdapter} from "../interfaces/ILiquidityLayerAdapter.sol"; -import {TypeCasts} from "../../../libs/TypeCasts.sol"; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -contract PortalAdapter is ILiquidityLayerAdapter, Router { - /// @notice The Portal TokenBridge contract. - IPortalTokenBridge public portalTokenBridge; - - /// @notice The LiquidityLayerRouter contract. - address public liquidityLayerRouter; - - /// @notice Hyperlane domain => Wormhole domain. - mapping(uint32 hyperlaneDomain => uint16 wormholeDomain) - public hyperlaneDomainToWormholeDomain; - /// @notice transferId => token address - mapping(bytes32 transferId => address token) - public portalTransfersProcessed; - - // We could technically use Portal's sequence number here but it doesn't - // get passed through, so we would have to parse the VAA twice - // 224 bits should be large enough and allows us to pack into a single slot - // with a Hyperlane domain - uint224 public nonce = 0; - - constructor(address _mailbox) Router(_mailbox) {} - - /** - * @notice Emits the nonce of the Portal message when a token is bridged. - * @param nonce The nonce of the Portal message. - * @param portalSequence The sequence of the Portal message. - * @param destination The hyperlane domain of the destination - */ - event BridgedToken( - uint256 nonce, - uint64 portalSequence, - uint32 destination - ); - - /** - * @notice Emitted when the Hyperlane domain to Wormhole domain mapping is updated. - * @param hyperlaneDomain The Hyperlane domain. - * @param wormholeDomain The Wormhole domain. - */ - event DomainAdded(uint32 indexed hyperlaneDomain, uint32 wormholeDomain); - - modifier onlyLiquidityLayerRouter() { - require(msg.sender == liquidityLayerRouter, "!liquidityLayerRouter"); - _; - } - - /** - * @param _owner The new owner. - * @param _portalTokenBridge The Portal TokenBridge contract. - * @param _liquidityLayerRouter The LiquidityLayerRouter contract. - */ - function initialize( - address _owner, - address _portalTokenBridge, - address _liquidityLayerRouter - ) public initializer { - // Transfer ownership of the contract to deployer - _transferOwnership(_owner); - - portalTokenBridge = IPortalTokenBridge(_portalTokenBridge); - liquidityLayerRouter = _liquidityLayerRouter; - } - - /** - * Sends tokens as requested by the router - * @param _destinationDomain The hyperlane domain of the destination - * @param _token The token address - * @param _amount The amount of tokens to send - */ - function sendTokens( - uint32 _destinationDomain, - bytes32, // _recipientAddress, unused - address _token, - uint256 _amount - ) external onlyLiquidityLayerRouter returns (bytes memory) { - nonce = nonce + 1; - uint16 _wormholeDomain = hyperlaneDomainToWormholeDomain[ - _destinationDomain - ]; - - bytes32 _remoteRouter = _mustHaveRemoteRouter(_destinationDomain); - - // Approve the token to Portal. We assume that the LiquidityLayerRouter - // has already transferred the token to this contract. - require( - IERC20(_token).approve(address(portalTokenBridge), _amount), - "!approval" - ); - - uint64 _portalSequence = portalTokenBridge.transferTokensWithPayload( - _token, - _amount, - _wormholeDomain, - _remoteRouter, - // Nonce for grouping Portal messages in the same tx, not relevant for us - // https://book.wormhole.com/technical/evm/coreLayer.html#emitting-a-vaa - 0, - // Portal Payload used in completeTransfer - abi.encode(localDomain, nonce) - ); - - emit BridgedToken(nonce, _portalSequence, _destinationDomain); - return abi.encode(nonce); - } - - /** - * Sends the tokens to the recipient as requested by the router - * @param _originDomain The hyperlane domain of the origin - * @param _recipient The address of the recipient - * @param _amount The amount of tokens to send - * @param _adapterData The adapter data from the origin chain, containing the nonce - */ - function receiveTokens( - uint32 _originDomain, // Hyperlane domain - address _recipient, - uint256 _amount, - bytes calldata _adapterData // The adapter data from the message - ) external onlyLiquidityLayerRouter returns (address, uint256) { - // Get the nonce information from the adapterData - uint224 _nonce = abi.decode(_adapterData, (uint224)); - - address _tokenAddress = portalTransfersProcessed[ - transferId(_originDomain, _nonce) - ]; - - require( - _tokenAddress != address(0x0), - "Portal Transfer has not yet been completed" - ); - - IERC20 _token = IERC20(_tokenAddress); - - // Transfer the token out to the recipient - // TODO: use safeTransfer - // Portal doesn't charge any fee, so we can safely transfer out the - // exact amount that was bridged over. - require(_token.transfer(_recipient, _amount), "!transfer out"); - return (_tokenAddress, _amount); - } - - /** - * Completes the Portal transfer which sends the funds to this adapter. - * The router can call receiveTokens to move those funds to the ultimate recipient. - * @param encodedVm The VAA from the Wormhole Guardians - */ - function completeTransfer(bytes memory encodedVm) public { - bytes memory _tokenBridgeTransferWithPayload = portalTokenBridge - .completeTransferWithPayload(encodedVm); - IPortalTokenBridge.TransferWithPayload - memory _transfer = portalTokenBridge.parseTransferWithPayload( - _tokenBridgeTransferWithPayload - ); - - (uint32 _originDomain, uint224 _nonce) = abi.decode( - _transfer.payload, - (uint32, uint224) - ); - - // Logic taken from here https://github.com/wormhole-foundation/wormhole/blob/dev.v2/ethereum/contracts/bridge/Bridge.sol#L503 - address tokenAddress = _transfer.tokenChain == - hyperlaneDomainToWormholeDomain[localDomain] - ? TypeCasts.bytes32ToAddress(_transfer.tokenAddress) - : portalTokenBridge.wrappedAsset( - _transfer.tokenChain, - _transfer.tokenAddress - ); - - portalTransfersProcessed[ - transferId(_originDomain, _nonce) - ] = tokenAddress; - } - - // This contract is only a Router to be aware of remote router addresses, - // and doesn't actually send/handle Hyperlane messages directly - function _handle( - uint32, // origin - bytes32, // sender - bytes calldata // message - ) internal pure override { - revert("No messages expected"); - } - - function addDomain( - uint32 _hyperlaneDomain, - uint16 _wormholeDomain - ) external onlyOwner { - hyperlaneDomainToWormholeDomain[_hyperlaneDomain] = _wormholeDomain; - - emit DomainAdded(_hyperlaneDomain, _wormholeDomain); - } - - /** - * The key that is used to track fulfilled Portal transfers - * @param _hyperlaneDomain The hyperlane of the origin - * @param _nonce The nonce of the adapter on the origin - */ - function transferId( - uint32 _hyperlaneDomain, - uint224 _nonce - ) public pure returns (bytes32) { - return bytes32(abi.encodePacked(_hyperlaneDomain, _nonce)); - } -} diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol deleted file mode 100644 index 95b97f6c40..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -interface ILiquidityLayerAdapter { - function sendTokens( - uint32 _destinationDomain, - bytes32 _recipientAddress, - address _token, - uint256 _amount - ) external returns (bytes memory _adapterData); - - function receiveTokens( - uint32 _originDomain, // Hyperlane domain - address _recipientAddress, - uint256 _amount, - bytes calldata _adapterData // The adapter data from the message - ) external returns (address, uint256); -} diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol deleted file mode 100644 index 9b0c03d321..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -interface ICircleMessageTransmitter { - /** - * @notice Receive a message. Messages with a given nonce - * can only be broadcast once for a (sourceDomain, destinationDomain) - * pair. The message body of a valid message is passed to the - * specified recipient for further processing. - * - * @dev Attestation format: - * A valid attestation is the concatenated 65-byte signature(s) of exactly - * `thresholdSignature` signatures, in increasing order of attester address. - * ***If the attester addresses recovered from signatures are not in - * increasing order, signature verification will fail.*** - * If incorrect number of signatures or duplicate signatures are supplied, - * signature verification will fail. - * - * Message format: - * Field Bytes Type Index - * version 4 uint32 0 - * sourceDomain 4 uint32 4 - * destinationDomain 4 uint32 8 - * nonce 8 uint64 12 - * sender 32 bytes32 20 - * recipient 32 bytes32 52 - * messageBody dynamic bytes 84 - * @param _message Message bytes - * @param _attestation Concatenated 65-byte signature(s) of `_message`, in increasing order - * of the attester address recovered from signatures. - * @return success bool, true if successful - */ - function receiveMessage( - bytes memory _message, - bytes calldata _attestation - ) external returns (bool success); - - function usedNonces(bytes32 _nonceId) external view returns (bool); -} diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol deleted file mode 100644 index 4eb9fa58fe..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -interface ITokenMessenger { - event MessageSent(bytes message); - - /** - * @notice Deposits and burns tokens from sender to be minted on destination domain. - * Emits a `DepositForBurn` event. - * @dev reverts if: - * - given burnToken is not supported - * - given destinationDomain has no TokenMessenger registered - * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance - * to this contract is less than `amount`. - * - burn() reverts. For example, if `amount` is 0. - * - MessageTransmitter returns false or reverts. - * @param _amount amount of tokens to burn - * @param _destinationDomain destination domain (ETH = 0, AVAX = 1) - * @param _mintRecipient address of mint recipient on destination domain - * @param _burnToken address of contract to burn deposited tokens, on local domain - * @return _nonce unique nonce reserved by message - */ - function depositForBurn( - uint256 _amount, - uint32 _destinationDomain, - bytes32 _mintRecipient, - address _burnToken - ) external returns (uint64 _nonce); - - /** - * @notice Deposits and burns tokens from sender to be minted on destination domain. The mint - * on the destination domain must be called by `_destinationCaller`. - * WARNING: if the `_destinationCaller` does not represent a valid address as bytes32, then it will not be possible - * to broadcast the message on the destination domain. This is an advanced feature, and the standard - * depositForBurn() should be preferred for use cases where a specific destination caller is not required. - * Emits a `DepositForBurn` event. - * @dev reverts if: - * - given destinationCaller is zero address - * - given burnToken is not supported - * - given destinationDomain has no TokenMessenger registered - * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance - * to this contract is less than `amount`. - * - burn() reverts. For example, if `amount` is 0. - * - MessageTransmitter returns false or reverts. - * @param _amount amount of tokens to burn - * @param _destinationDomain destination domain - * @param _mintRecipient address of mint recipient on destination domain - * @param _burnToken address of contract to burn deposited tokens, on local domain - * @param _destinationCaller caller on the destination domain, as bytes32 - * @return _nonce unique nonce reserved by message - */ - function depositForBurnWithCaller( - uint256 _amount, - uint32 _destinationDomain, - bytes32 _mintRecipient, - address _burnToken, - bytes32 _destinationCaller - ) external returns (uint64 _nonce); -} diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol deleted file mode 100644 index aafb594cea..0000000000 --- a/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -// Portal's interface from their docs -interface IPortalTokenBridge { - struct Transfer { - uint8 payloadID; - uint256 amount; - bytes32 tokenAddress; - uint16 tokenChain; - bytes32 to; - uint16 toChain; - uint256 fee; - } - - struct TransferWithPayload { - uint8 payloadID; - uint256 amount; - bytes32 tokenAddress; - uint16 tokenChain; - bytes32 to; - uint16 toChain; - bytes32 fromAddress; - bytes payload; - } - - struct AssetMeta { - uint8 payloadID; - bytes32 tokenAddress; - uint16 tokenChain; - uint8 decimals; - bytes32 symbol; - bytes32 name; - } - - struct RegisterChain { - bytes32 module; - uint8 action; - uint16 chainId; - uint16 emitterChainID; - bytes32 emitterAddress; - } - - struct UpgradeContract { - bytes32 module; - uint8 action; - uint16 chainId; - bytes32 newContract; - } - - struct RecoverChainId { - bytes32 module; - uint8 action; - uint256 evmChainId; - uint16 newChainId; - } - - event ContractUpgraded( - address indexed oldContract, - address indexed newContract - ); - - function transferTokensWithPayload( - address token, - uint256 amount, - uint16 recipientChain, - bytes32 recipient, - uint32 nonce, - bytes memory payload - ) external payable returns (uint64 sequence); - - function completeTransferWithPayload( - bytes memory encodedVm - ) external returns (bytes memory); - - function parseTransferWithPayload( - bytes memory encoded - ) external pure returns (TransferWithPayload memory transfer); - - function wrappedAsset( - uint16 tokenChainId, - bytes32 tokenAddress - ) external view returns (address); - - function isWrappedAsset(address token) external view returns (bool); -} diff --git a/solidity/contracts/mock/MockPortalBridge.sol b/solidity/contracts/mock/MockPortalBridge.sol deleted file mode 100644 index 142b5fa54e..0000000000 --- a/solidity/contracts/mock/MockPortalBridge.sol +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -import {IPortalTokenBridge} from "../middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol"; -import {MockToken} from "./MockToken.sol"; -import {TypeCasts} from "../libs/TypeCasts.sol"; - -contract MockPortalBridge is IPortalTokenBridge { - uint256 nextNonce = 0; - MockToken token; - - constructor(MockToken _token) { - token = _token; - } - - function transferTokensWithPayload( - address, - uint256 amount, - uint16, - bytes32, - uint32, - bytes memory - ) external payable returns (uint64 sequence) { - nextNonce = nextNonce + 1; - token.transferFrom(msg.sender, address(this), amount); - token.burn(amount); - return uint64(nextNonce); - } - - function wrappedAsset(uint16, bytes32) external view returns (address) { - return address(token); - } - - function isWrappedAsset(address) external pure returns (bool) { - return true; - } - - function completeTransferWithPayload( - bytes memory encodedVm - ) external returns (bytes memory) { - (uint32 _originDomain, uint224 _nonce, uint256 _amount) = abi.decode( - encodedVm, - (uint32, uint224, uint256) - ); - - token.mint(msg.sender, _amount); - // Format it so that parseTransferWithPayload returns the desired payload - return - abi.encode( - TypeCasts.addressToBytes32(address(token)), - adapterData(_originDomain, _nonce, address(token)) - ); - } - - function parseTransferWithPayload( - bytes memory encoded - ) external pure returns (TransferWithPayload memory transfer) { - (bytes32 tokenAddress, bytes memory payload) = abi.decode( - encoded, - (bytes32, bytes) - ); - transfer.payload = payload; - transfer.tokenAddress = tokenAddress; - } - - function adapterData( - uint32 _originDomain, - uint224 _nonce, - address _token - ) public pure returns (bytes memory) { - return - abi.encode( - _originDomain, - _nonce, - TypeCasts.addressToBytes32(_token) - ); - } - - function mockPortalVaa( - uint32 _originDomain, - uint224 _nonce, - uint256 _amount - ) public pure returns (bytes memory) { - return abi.encode(_originDomain, _nonce, _amount); - } -} diff --git a/solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol b/solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol deleted file mode 100644 index 71d64d9316..0000000000 --- a/solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -import {ILiquidityLayerMessageRecipient} from "../interfaces/ILiquidityLayerMessageRecipient.sol"; - -contract TestLiquidityLayerMessageRecipient is ILiquidityLayerMessageRecipient { - event HandledWithTokens( - uint32 origin, - bytes32 sender, - bytes message, - address token, - uint256 amount - ); - - function handleWithTokens( - uint32 _origin, - bytes32 _sender, - bytes calldata _message, - address _token, - uint256 _amount - ) external { - emit HandledWithTokens(_origin, _sender, _message, _token, _amount); - } -} diff --git a/solidity/contracts/test/TestTokenRecipient.sol b/solidity/contracts/test/TestTokenRecipient.sol deleted file mode 100644 index cf6c926f0a..0000000000 --- a/solidity/contracts/test/TestTokenRecipient.sol +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity >=0.8.0; - -import {ILiquidityLayerMessageRecipient} from "../interfaces/ILiquidityLayerMessageRecipient.sol"; - -contract TestTokenRecipient is ILiquidityLayerMessageRecipient { - bytes32 public lastSender; - bytes public lastData; - address public lastToken; - uint256 public lastAmount; - - address public lastCaller; - string public lastCallMessage; - - event ReceivedMessage( - uint32 indexed origin, - bytes32 indexed sender, - string message, - address token, - uint256 amount - ); - - event ReceivedCall(address indexed caller, uint256 amount, string message); - - function handleWithTokens( - uint32 _origin, - bytes32 _sender, - bytes calldata _data, - address _token, - uint256 _amount - ) external override { - emit ReceivedMessage(_origin, _sender, string(_data), _token, _amount); - lastSender = _sender; - lastData = _data; - lastToken = _token; - lastAmount = _amount; - } - - function fooBar(uint256 amount, string calldata message) external { - emit ReceivedCall(msg.sender, amount, message); - lastCaller = msg.sender; - lastCallMessage = message; - } -} diff --git a/solidity/test/LiquidityLayerRouter.t.sol b/solidity/test/LiquidityLayerRouter.t.sol deleted file mode 100644 index 2756efa1b0..0000000000 --- a/solidity/test/LiquidityLayerRouter.t.sol +++ /dev/null @@ -1,290 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.13; - -import "forge-std/Test.sol"; -import {LiquidityLayerRouter} from "../contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol"; -import {CircleBridgeAdapter} from "../contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol"; -import {MockToken} from "../contracts/mock/MockToken.sol"; -import {TestTokenRecipient} from "../contracts/test/TestTokenRecipient.sol"; -import {TestRecipient} from "../contracts/test/TestRecipient.sol"; -import {MockCircleMessageTransmitter} from "../contracts/mock/MockCircleMessageTransmitter.sol"; -import {MockCircleTokenMessenger} from "../contracts/mock/MockCircleTokenMessenger.sol"; -import {MockHyperlaneEnvironment} from "../contracts/mock/MockHyperlaneEnvironment.sol"; - -import {TypeCasts} from "../contracts/libs/TypeCasts.sol"; - -contract LiquidityLayerRouterTest is Test { - MockHyperlaneEnvironment testEnvironment; - - LiquidityLayerRouter originLiquidityLayerRouter; - LiquidityLayerRouter destinationLiquidityLayerRouter; - - MockCircleMessageTransmitter messageTransmitter; - MockCircleTokenMessenger tokenMessenger; - CircleBridgeAdapter originBridgeAdapter; - CircleBridgeAdapter destinationBridgeAdapter; - - string bridge = "FooBridge"; - - uint32 originDomain = 123; - uint32 destinationDomain = 321; - - TestTokenRecipient recipient; - MockToken token; - bytes messageBody = hex"beefdead"; - uint256 amount = 420000; - - event LiquidityLayerAdapterSet(string indexed bridge, address adapter); - - function setUp() public { - token = new MockToken(); - - tokenMessenger = new MockCircleTokenMessenger(token); - messageTransmitter = new MockCircleMessageTransmitter(token); - - recipient = new TestTokenRecipient(); - - testEnvironment = new MockHyperlaneEnvironment( - originDomain, - destinationDomain - ); - - address originMailbox = address( - testEnvironment.mailboxes(originDomain) - ); - address destinationMailbox = address( - testEnvironment.mailboxes(destinationDomain) - ); - - originBridgeAdapter = new CircleBridgeAdapter(originMailbox); - destinationBridgeAdapter = new CircleBridgeAdapter(destinationMailbox); - - originLiquidityLayerRouter = new LiquidityLayerRouter(originMailbox); - destinationLiquidityLayerRouter = new LiquidityLayerRouter( - destinationMailbox - ); - - address owner = address(this); - originLiquidityLayerRouter.enrollRemoteRouter( - destinationDomain, - TypeCasts.addressToBytes32(address(destinationLiquidityLayerRouter)) - ); - destinationLiquidityLayerRouter.enrollRemoteRouter( - originDomain, - TypeCasts.addressToBytes32(address(originLiquidityLayerRouter)) - ); - - originBridgeAdapter.initialize( - owner, - address(tokenMessenger), - address(messageTransmitter), - address(originLiquidityLayerRouter) - ); - - destinationBridgeAdapter.initialize( - owner, - address(tokenMessenger), - address(messageTransmitter), - address(destinationLiquidityLayerRouter) - ); - - originBridgeAdapter.addToken(address(token), "USDC"); - destinationBridgeAdapter.addToken(address(token), "USDC"); - - originBridgeAdapter.enrollRemoteRouter( - destinationDomain, - TypeCasts.addressToBytes32(address(destinationBridgeAdapter)) - ); - destinationBridgeAdapter.enrollRemoteRouter( - originDomain, - TypeCasts.addressToBytes32(address(originBridgeAdapter)) - ); - - originLiquidityLayerRouter.setLiquidityLayerAdapter( - bridge, - address(originBridgeAdapter) - ); - - destinationLiquidityLayerRouter.setLiquidityLayerAdapter( - bridge, - address(destinationBridgeAdapter) - ); - - token.mint(address(this), amount); - } - - function testSetLiquidityLayerAdapter() public { - // Expect the LiquidityLayerAdapterSet event. - // Expect topic0 & data to match - vm.expectEmit(true, false, false, true); - emit LiquidityLayerAdapterSet(bridge, address(originBridgeAdapter)); - - // Set the token bridge adapter - originLiquidityLayerRouter.setLiquidityLayerAdapter( - bridge, - address(originBridgeAdapter) - ); - - // Expect the bridge adapter to have been set - assertEq( - originLiquidityLayerRouter.liquidityLayerAdapters(bridge), - address(originBridgeAdapter) - ); - } - - // ==== dispatchWithTokens ==== - - function testDispatchWithTokensRevertsWithUnkownBridgeAdapter() public { - vm.expectRevert("No adapter found for bridge"); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount, - "BazBridge", // some unknown bridge name, - messageBody - ); - } - - function testDispatchWithTokensRevertsWithFailedTransferIn() public { - vm.expectRevert("ERC20: insufficient allowance"); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount, - bridge, - messageBody - ); - } - - function testDispatchWithTokenTransfersMovesTokens() public { - token.approve(address(originLiquidityLayerRouter), amount); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount, - bridge, - messageBody - ); - } - - function testDispatchWithTokensCallsAdapter() public { - vm.expectCall( - address(originBridgeAdapter), - abi.encodeWithSelector( - originBridgeAdapter.sendTokens.selector, - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount - ) - ); - token.approve(address(originLiquidityLayerRouter), amount); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount, - bridge, - messageBody - ); - } - - function testProcessingRevertsIfBridgeAdapterReverts() public { - token.approve(address(originLiquidityLayerRouter), amount); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount, - bridge, - messageBody - ); - - vm.expectRevert("Circle message not processed yet"); - testEnvironment.processNextPendingMessage(); - } - - function testDispatchWithTokensTransfersOnDestination() public { - token.approve(address(originLiquidityLayerRouter), amount); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount, - bridge, - messageBody - ); - - bytes32 nonceId = messageTransmitter.hashSourceAndNonce( - destinationBridgeAdapter.hyperlaneDomainToCircleDomain( - originDomain - ), - tokenMessenger.nextNonce() - 1 - ); - - messageTransmitter.process( - nonceId, - address(destinationBridgeAdapter), - amount - ); - testEnvironment.processNextPendingMessage(); - assertEq(recipient.lastData(), messageBody); - assertEq(token.balanceOf(address(recipient)), amount); - } - - function testCannotSendToRecipientWithoutHandle() public { - token.approve(address(originLiquidityLayerRouter), amount); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(this)), - address(token), - amount, - bridge, - messageBody - ); - bytes32 nonceId = messageTransmitter.hashSourceAndNonce( - destinationBridgeAdapter.hyperlaneDomainToCircleDomain( - originDomain - ), - tokenMessenger.nextNonce() - 1 - ); - messageTransmitter.process( - nonceId, - address(destinationBridgeAdapter), - amount - ); - - vm.expectRevert(); - testEnvironment.processNextPendingMessage(); - } - - function testSendToRecipientWithoutHandleWhenSpecifyingNoMessage() public { - TestRecipient noHandleRecipient = new TestRecipient(); - token.approve(address(originLiquidityLayerRouter), amount); - originLiquidityLayerRouter.dispatchWithTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(noHandleRecipient)), - address(token), - amount, - bridge, - "" - ); - bytes32 nonceId = messageTransmitter.hashSourceAndNonce( - destinationBridgeAdapter.hyperlaneDomainToCircleDomain( - originDomain - ), - tokenMessenger.nextNonce() - 1 - ); - messageTransmitter.process( - nonceId, - address(destinationBridgeAdapter), - amount - ); - - testEnvironment.processNextPendingMessage(); - assertEq(token.balanceOf(address(noHandleRecipient)), amount); - } -} diff --git a/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol b/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol deleted file mode 100644 index de13a86e00..0000000000 --- a/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import "forge-std/Test.sol"; -import {TypeCasts} from "../../../contracts/libs/TypeCasts.sol"; -import {IPortalTokenBridge} from "../../../contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol"; -import {PortalAdapter} from "../../../contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol"; -import {TestTokenRecipient} from "../../../contracts/test/TestTokenRecipient.sol"; -import {MockToken} from "../../../contracts/mock/MockToken.sol"; -import {MockPortalBridge} from "../../../contracts/mock/MockPortalBridge.sol"; -import {MockMailbox} from "../../../contracts/mock/MockMailbox.sol"; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -contract PortalAdapterTest is Test { - PortalAdapter originAdapter; - PortalAdapter destinationAdapter; - - MockPortalBridge portalBridge; - - uint32 originDomain = 123; - uint32 destinationDomain = 321; - - TestTokenRecipient recipient; - MockToken token; - - function setUp() public { - token = new MockToken(); - recipient = new TestTokenRecipient(); - - MockMailbox originMailbox = new MockMailbox(originDomain); - MockMailbox destinationMailbox = new MockMailbox(destinationDomain); - - originAdapter = new PortalAdapter(address(originMailbox)); - destinationAdapter = new PortalAdapter(address(destinationMailbox)); - - portalBridge = new MockPortalBridge(token); - - originAdapter.initialize( - address(this), - address(portalBridge), - address(this) - ); - destinationAdapter.initialize( - address(this), - address(portalBridge), - address(this) - ); - - originAdapter.enrollRemoteRouter( - destinationDomain, - TypeCasts.addressToBytes32(address(destinationAdapter)) - ); - destinationAdapter.enrollRemoteRouter( - destinationDomain, - TypeCasts.addressToBytes32(address(originAdapter)) - ); - } - - function testAdapter(uint256 amount) public { - // Transfers of 0 are invalid - vm.assume(amount > 0); - // Calls MockPortalBridge with the right parameters - vm.expectCall( - address(portalBridge), - abi.encodeCall( - portalBridge.transferTokensWithPayload, - ( - address(token), - amount, - 0, - TypeCasts.addressToBytes32(address(destinationAdapter)), - 0, - abi.encode(originDomain, originAdapter.nonce() + 1) - ) - ) - ); - token.mint(address(originAdapter), amount); - originAdapter.sendTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount - ); - } - - function testReceivingRevertsWithoutTransferCompletion( - uint256 amount - ) public { - // Transfers of 0 are invalid - vm.assume(amount > 0); - token.mint(address(originAdapter), amount); - bytes memory adapterData = originAdapter.sendTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount - ); - - vm.expectRevert("Portal Transfer has not yet been completed"); - - destinationAdapter.receiveTokens( - originDomain, - address(recipient), - amount, - adapterData - ); - } - - function testReceivingWorks(uint256 amount) public { - // Transfers of 0 are invalid - vm.assume(amount > 0); - token.mint(address(originAdapter), amount); - bytes memory adapterData = originAdapter.sendTokens( - destinationDomain, - TypeCasts.addressToBytes32(address(recipient)), - address(token), - amount - ); - destinationAdapter.completeTransfer( - portalBridge.mockPortalVaa( - originDomain, - originAdapter.nonce(), - amount - ) - ); - - destinationAdapter.receiveTokens( - originDomain, - address(recipient), - amount, - adapterData - ); - } -} diff --git a/typescript/infra/config/environments/mainnet3/index.ts b/typescript/infra/config/environments/mainnet3/index.ts index ca7806f443..1c322fdffd 100644 --- a/typescript/infra/config/environments/mainnet3/index.ts +++ b/typescript/infra/config/environments/mainnet3/index.ts @@ -16,7 +16,6 @@ import { keyFunderConfig } from './funding.js'; import { helloWorld } from './helloworld.js'; import { igp } from './igp.js'; import { infrastructure } from './infrastructure.js'; -import { bridgeAdapterConfigs, relayerConfig } from './liquidityLayer.js'; import { chainOwners } from './owners.js'; import { supportedChainNames } from './supportedChainNames.js'; import { checkWarpDeployConfig } from './warp/checkWarpDeploy.js'; @@ -56,8 +55,4 @@ export const environment: EnvironmentConfig = { helloWorld, keyFunderConfig, checkWarpDeployConfig, - liquidityLayerConfig: { - bridgeAdapters: bridgeAdapterConfigs, - relayer: relayerConfig, - }, }; diff --git a/typescript/infra/config/environments/mainnet3/liquidityLayer.ts b/typescript/infra/config/environments/mainnet3/liquidityLayer.ts deleted file mode 100644 index 0f3a6d2879..0000000000 --- a/typescript/infra/config/environments/mainnet3/liquidityLayer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - BridgeAdapterConfig, - BridgeAdapterType, - ChainMap, -} from '@hyperlane-xyz/sdk'; - -import { LiquidityLayerRelayerConfig } from '../../../src/config/middleware.js'; -import { getDomainId } from '../../registry.js'; - -import { environment } from './chains.js'; - -const circleDomainMapping = [ - { - hyperlaneDomain: getDomainId('ethereum'), - circleDomain: 0, - }, - { - hyperlaneDomain: getDomainId('avalanche'), - circleDomain: 1, - }, -]; - -export const bridgeAdapterConfigs: ChainMap = { - ethereum: { - circle: { - type: BridgeAdapterType.Circle, - tokenMessengerAddress: '0xBd3fa81B58Ba92a82136038B25aDec7066af3155', - messageTransmitterAddress: '0x0a992d191DEeC32aFe36203Ad87D7d289a738F81', - usdcAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - circleDomainMapping, - }, - }, - avalanche: { - circle: { - type: BridgeAdapterType.Circle, - tokenMessengerAddress: '0x6B25532e1060CE10cc3B0A99e5683b91BFDe6982', - messageTransmitterAddress: '0x8186359af5f57fbb40c6b14a588d2a59c0c29880', - usdcAddress: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', - circleDomainMapping, - }, - }, -}; - -export const relayerConfig: LiquidityLayerRelayerConfig = { - docker: { - repo: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', - tag: '59410cd-20230420-091923', - }, - namespace: environment, - prometheusPushGateway: - 'http://prometheus-prometheus-pushgateway.monitoring.svc.cluster.local:9091', -}; diff --git a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json deleted file mode 100644 index b562fe5991..0000000000 --- a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "ethereum": { - "proxyAdmin": "0x75EE15Ee1B4A75Fa3e2fDF5DF3253c25599cc659", - "liquidityLayerRouter": "0x9954A0d5C9ac7e4a3687f9B08c0FF272f9d0dc71", - "circleBridgeAdapter": "0xf7Cb9e767247144D89bcf36614D56C33FD4Db562" - }, - "avalanche": { - "proxyAdmin": "0xd7CF8c05fd81b8cA7CfF8E6C49B08a9D63265c9B", - "liquidityLayerRouter": "0xEff8C988b9F9f606059c436F5C1Cc431571C8B03", - "circleBridgeAdapter": "0x0BFf79f395A73817df1d3c80D78bb3C57Fbbc2Ed" - } -} diff --git a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json deleted file mode 100644 index 67184b0996..0000000000 --- a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "ethereum": [ - { - "name": "LiquidityLayerRouter", - "address": "0x9954A0d5C9ac7e4a3687f9B08c0FF272f9d0dc71", - "constructorArguments": "", - "isProxy": false - }, - { - "name": "TransparentUpgradeableProxy", - "address": "0x75FE1c9cf9CD1f49bD655F4a173FE5CA7C22D8E1", - "constructorArguments": "0000000000000000000000009954a0d5c9ac7e4a3687f9b08c0ff272f9d0dc7100000000000000000000000075ee15ee1b4a75fa3e2fdf5df3253c25599cc65900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000084f8c8765e00000000000000000000000035231d4c2d8b8adcb5617a638a0c4548684c7c7000000000000000000000000056f52c0a1ddcd557285f7cbc782d3d83096ce1cc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba00000000000000000000000000000000000000000000000000000000", - "isProxy": true - }, - { - "name": "CircleBridgeAdapter", - "address": "0xf7Cb9e767247144D89bcf36614D56C33FD4Db562", - "constructorArguments": "", - "isProxy": false - } - ], - "avalanche": [ - { - "name": "LiquidityLayerRouter", - "address": "0xDc68A5829F7Edfe2954EEe1bff23C3C994197596", - "constructorArguments": "", - "isProxy": false - }, - { - "name": "TransparentUpgradeableProxy", - "address": "0xEff8C988b9F9f606059c436F5C1Cc431571C8B03", - "constructorArguments": "000000000000000000000000dc68a5829f7edfe2954eee1bff23c3c994197596000000000000000000000000d7cf8c05fd81b8ca7cff8e6c49b08a9d63265c9b00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000084f8c8765e00000000000000000000000035231d4c2d8b8adcb5617a638a0c4548684c7c7000000000000000000000000056f52c0a1ddcd557285f7cbc782d3d83096ce1cc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba00000000000000000000000000000000000000000000000000000000", - "isProxy": true - }, - { - "name": "CircleBridgeAdapter", - "address": "0x0BFf79f395A73817df1d3c80D78bb3C57Fbbc2Ed", - "constructorArguments": "", - "isProxy": false - } - ] -} diff --git a/typescript/infra/config/environments/mainnet3/token-bridge.ts b/typescript/infra/config/environments/mainnet3/token-bridge.ts deleted file mode 100644 index f516455025..0000000000 --- a/typescript/infra/config/environments/mainnet3/token-bridge.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - BridgeAdapterType, - ChainMap, - CircleBridgeAdapterConfig, -} from '@hyperlane-xyz/sdk'; - -import { getDomainId } from '../../registry.js'; - -const circleDomainMapping = [ - { hyperlaneDomain: getDomainId('fuji'), circleDomain: 1 }, -]; - -// Circle deployed contracts -export const circleBridgeAdapterConfig: ChainMap = { - fuji: { - type: BridgeAdapterType.Circle, - tokenMessengerAddress: '0x0fc1103927af27af808d03135214718bcedbe9ad', - messageTransmitterAddress: '0x52fffb3ee8fa7838e9858a2d5e454007b9027c3c', - usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', - circleDomainMapping, - }, -}; diff --git a/typescript/infra/config/environments/test/index.ts b/typescript/infra/config/environments/test/index.ts index cdde17fe39..c6c571bf5f 100644 --- a/typescript/infra/config/environments/test/index.ts +++ b/typescript/infra/config/environments/test/index.ts @@ -1,6 +1,6 @@ import { JsonRpcProvider } from '@ethersproject/providers'; -import { MultiProvider, testChainMetadata } from '@hyperlane-xyz/sdk'; +import { MultiProvider } from '@hyperlane-xyz/sdk'; import { EnvironmentConfig } from '../../../src/config/environment.js'; diff --git a/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json deleted file mode 100644 index a115479d5b..0000000000 --- a/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "fuji": { - "circleBridgeAdapter": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D", - "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", - "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" - }, - "bsctestnet": { - "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", - "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" - }, - "alfajores": { - "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", - "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" - } -} diff --git a/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json deleted file mode 100644 index edd97a31d3..0000000000 --- a/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "fuji": [ - { - "name": "LiquidityLayerRouter", - "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", - "isProxy": false, - "constructorArguments": "" - }, - { - "name": "CircleBridgeAdapter", - "address": "0xb54AD7AE42B7c505100594365CdBC4b28Ef51FE6", - "isProxy": false, - "constructorArguments": "" - }, - { - "name": "PortalAdapter", - "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", - "isProxy": false, - "constructorArguments": "" - }, - { - "name": "CircleBridgeAdapter", - "address": "0x54FCA26E5FF828847D8caF471e44cD5727C73B0d", - "isProxy": false, - "constructorArguments": "" - }, - { - "name": "CircleBridgeAdapter", - "address": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D", - "isProxy": false, - "constructorArguments": "" - } - ], - "bsctestnet": [ - { - "name": "LiquidityLayerRouter", - "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", - "isProxy": false, - "constructorArguments": "" - }, - { - "name": "PortalAdapter", - "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", - "isProxy": false, - "constructorArguments": "" - } - ], - "alfajores": [ - { - "name": "LiquidityLayerRouter", - "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", - "isProxy": false, - "constructorArguments": "" - }, - { - "name": "PortalAdapter", - "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", - "isProxy": false, - "constructorArguments": "" - } - ] -} diff --git a/typescript/infra/config/environments/testnet4/index.ts b/typescript/infra/config/environments/testnet4/index.ts index 03430bbbe4..59739766fc 100644 --- a/typescript/infra/config/environments/testnet4/index.ts +++ b/typescript/infra/config/environments/testnet4/index.ts @@ -21,8 +21,6 @@ import { keyFunderConfig } from './funding.js'; import { helloWorld } from './helloworld.js'; import { igp } from './igp.js'; import { infrastructure } from './infrastructure.js'; -import { bridgeAdapterConfigs } from './liquidityLayer.js'; -import { liquidityLayerRelayerConfig } from './middleware.js'; import { owners } from './owners.js'; import { supportedChainNames } from './supportedChainNames.js'; @@ -71,8 +69,4 @@ export const environment: EnvironmentConfig = { helloWorld, owners, keyFunderConfig, - liquidityLayerConfig: { - bridgeAdapters: bridgeAdapterConfigs, - relayer: liquidityLayerRelayerConfig, - }, }; diff --git a/typescript/infra/config/environments/testnet4/liquidityLayer.ts b/typescript/infra/config/environments/testnet4/liquidityLayer.ts deleted file mode 100644 index 0057b68e82..0000000000 --- a/typescript/infra/config/environments/testnet4/liquidityLayer.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - BridgeAdapterConfig, - BridgeAdapterType, - ChainMap, -} from '@hyperlane-xyz/sdk'; - -import { getDomainId } from '../../registry.js'; - -const circleDomainMapping = [ - { hyperlaneDomain: getDomainId('fuji'), circleDomain: 1 }, -]; - -const wormholeDomainMapping = [ - { - hyperlaneDomain: getDomainId('fuji'), - wormholeDomain: 6, - }, - { - hyperlaneDomain: getDomainId('bsctestnet'), - wormholeDomain: 4, - }, - { - hyperlaneDomain: getDomainId('alfajores'), - wormholeDomain: 14, - }, -]; - -export const bridgeAdapterConfigs: ChainMap = { - fuji: { - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: '0x61E44E506Ca5659E6c0bba9b678586fA2d729756', - wormholeDomainMapping, - }, - circle: { - type: BridgeAdapterType.Circle, - tokenMessengerAddress: '0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0', - messageTransmitterAddress: '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79', - usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', - circleDomainMapping, - }, - }, - bsctestnet: { - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: '0x9dcF9D205C9De35334D646BeE44b2D2859712A09', - wormholeDomainMapping, - }, - }, - alfajores: { - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: '0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153', - wormholeDomainMapping, - }, - }, -}; diff --git a/typescript/infra/config/environments/testnet4/middleware.ts b/typescript/infra/config/environments/testnet4/middleware.ts deleted file mode 100644 index cf3d2782cb..0000000000 --- a/typescript/infra/config/environments/testnet4/middleware.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { LiquidityLayerRelayerConfig } from '../../../src/config/middleware.js'; - -import { environment } from './chains.js'; - -export const liquidityLayerRelayerConfig: LiquidityLayerRelayerConfig = { - docker: { - repo: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', - tag: 'sha-437f701', - }, - namespace: environment, - prometheusPushGateway: - 'http://prometheus-prometheus-pushgateway.monitoring.svc.cluster.local:9091', -}; diff --git a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json deleted file mode 100644 index 794ffffdba..0000000000 --- a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "fuji": { - "circleBridgeAdapter": "0xfe9d88aA85c5917822C804b949BcEDE832C02ce2", - "portalAdapter": "0x68D753982e89CC083917863F6dc9738448B91ef9", - "proxyAdmin": "0x13474f85b808034C911B7697dee60B7d8d50ee36", - "liquidityLayerRouter": "0x2abe0860D81FB4242C748132bD69D125D88eaE26" - }, - "bsctestnet": { - "portalAdapter": "0x68D753982e89CC083917863F6dc9738448B91ef9", - "proxyAdmin": "0xfB149BC17dD3FE858fA64D678bA0c706DEac61eE", - "liquidityLayerRouter": "0x2abe0860D81FB4242C748132bD69D125D88eaE26" - }, - "alfajores": { - "portalAdapter": "0x68D753982e89CC083917863F6dc9738448B91ef9", - "liquidityLayerRouter": "0x2abe0860D81FB4242C748132bD69D125D88eaE26", - "proxyAdmin": "0x4e4D563e2cBFC35c4BC16003685443Fae2FA702f" - } -} diff --git a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json deleted file mode 100644 index 67ffcccfab..0000000000 --- a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "fuji": [ - { - "name": "LiquidityLayerRouter", - "address": "0x2abe0860D81FB4242C748132bD69D125D88eaE26", - "isProxy": false, - "constructorArguments": "0x" - }, - { - "name": "CircleBridgeAdapter", - "address": "0xfe9d88aA85c5917822C804b949BcEDE832C02ce2", - "isProxy": false, - "constructorArguments": "0x" - }, - { - "name": "PortalAdapter", - "address": "0x68D753982e89CC083917863F6dc9738448B91ef9", - "isProxy": false, - "constructorArguments": "0x" - } - ], - "bsctestnet": [ - { - "name": "LiquidityLayerRouter", - "address": "0x2abe0860D81FB4242C748132bD69D125D88eaE26", - "isProxy": false, - "constructorArguments": "0x" - }, - { - "name": "PortalAdapter", - "address": "0x68D753982e89CC083917863F6dc9738448B91ef9", - "isProxy": false, - "constructorArguments": "0x" - } - ], - "alfajores": [ - { - "name": "LiquidityLayerRouter", - "address": "0x2abe0860D81FB4242C748132bD69D125D88eaE26", - "isProxy": false, - "constructorArguments": "0x" - }, - { - "name": "PortalAdapter", - "address": "0x68D753982e89CC083917863F6dc9738448B91ef9", - "isProxy": false, - "constructorArguments": "0x" - } - ] -} diff --git a/typescript/infra/config/environments/testnet4/token-bridge.ts b/typescript/infra/config/environments/testnet4/token-bridge.ts deleted file mode 100644 index 0057b68e82..0000000000 --- a/typescript/infra/config/environments/testnet4/token-bridge.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - BridgeAdapterConfig, - BridgeAdapterType, - ChainMap, -} from '@hyperlane-xyz/sdk'; - -import { getDomainId } from '../../registry.js'; - -const circleDomainMapping = [ - { hyperlaneDomain: getDomainId('fuji'), circleDomain: 1 }, -]; - -const wormholeDomainMapping = [ - { - hyperlaneDomain: getDomainId('fuji'), - wormholeDomain: 6, - }, - { - hyperlaneDomain: getDomainId('bsctestnet'), - wormholeDomain: 4, - }, - { - hyperlaneDomain: getDomainId('alfajores'), - wormholeDomain: 14, - }, -]; - -export const bridgeAdapterConfigs: ChainMap = { - fuji: { - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: '0x61E44E506Ca5659E6c0bba9b678586fA2d729756', - wormholeDomainMapping, - }, - circle: { - type: BridgeAdapterType.Circle, - tokenMessengerAddress: '0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0', - messageTransmitterAddress: '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79', - usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', - circleDomainMapping, - }, - }, - bsctestnet: { - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: '0x9dcF9D205C9De35334D646BeE44b2D2859712A09', - wormholeDomainMapping, - }, - }, - alfajores: { - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: '0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153', - wormholeDomainMapping, - }, - }, -}; diff --git a/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml b/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml deleted file mode 100644 index ef2f1888ab..0000000000 --- a/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: v2 -name: liquidity-layer-relayers -description: Liquidity Layer Relayers - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.0 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: '1.16.0' diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl b/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl deleted file mode 100644 index f0752fcf92..0000000000 --- a/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl +++ /dev/null @@ -1,42 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "hyperlane.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "hyperlane.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "hyperlane.labels" -}} -helm.sh/chart: {{ include "hyperlane.chart" . }} -hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} -hyperlane/context: "hyperlane" -{{ include "hyperlane.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "hyperlane.selectorLabels" -}} -app.kubernetes.io/name: {{ include "hyperlane.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -The name of the ClusterSecretStore -*/}} -{{- define "hyperlane.cluster-secret-store.name" -}} -{{- default "external-secrets-gcp-cluster-secret-store" .Values.externalSecrets.clusterSecretStore }} -{{- end }} diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml deleted file mode 100644 index a41589924f..0000000000 --- a/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: circle-relayer -spec: - replicas: 1 - selector: - matchLabels: - name: circle-relayer - template: - metadata: - labels: - name: circle-relayer - spec: - containers: - - name: circle-relayer - image: {{ .Values.image.repository }}:{{ .Values.image.tag }} - imagePullPolicy: IfNotPresent - command: - - ./node_modules/.bin/tsx - - ./typescript/infra/scripts/middleware/circle-relayer.ts - - -e - - {{ .Values.hyperlane.runEnv }} - envFrom: - - secretRef: - name: liquidity-layer-env-var-secret diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml deleted file mode 100644 index 62b3117120..0000000000 --- a/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: external-secrets.io/v1beta1 -kind: ExternalSecret -metadata: - name: liquidity-layer-env-var-external-secret - labels: - {{- include "hyperlane.labels" . | nindent 4 }} -spec: - secretStoreRef: - name: {{ include "hyperlane.cluster-secret-store.name" . }} - kind: ClusterSecretStore - refreshInterval: "1h" - # The secret that will be created - target: - name: liquidity-layer-env-var-secret - template: - type: Opaque - metadata: - labels: - {{- include "hyperlane.labels" . | nindent 10 }} - annotations: - update-on-redeploy: "{{ now }}" - data: - GCP_SECRET_OVERRIDES_ENABLED: "true" - GCP_SECRET_OVERRIDE_HYPERLANE_{{ .Values.hyperlane.runEnv | upper }}_KEY_DEPLOYER: {{ print "'{{ .deployer_key | toString }}'" }} -{{/* - * For each network, create an environment variable with the RPC endpoint. - * The templating of external-secrets will use the data section below to know how - * to replace the correct value in the created secret. - */}} - {{- range .Values.hyperlane.chains }} - GCP_SECRET_OVERRIDE_{{ $.Values.hyperlane.runEnv | upper }}_RPC_ENDPOINTS_{{ . | upper }}: {{ printf "'{{ .%s_rpcs | toString }}'" . }} - {{- end }} - data: - - secretKey: deployer_key - remoteRef: - key: {{ printf "hyperlane-%s-key-deployer" .Values.hyperlane.runEnv }} -{{/* - * For each network, load the secret in GCP secret manager with the form: environment-rpc-endpoint-network, - * and associate it with the secret key networkname_rpc. - */}} - {{- range .Values.hyperlane.chains }} - - secretKey: {{ printf "%s_rpcs" . }} - remoteRef: - key: {{ printf "%s-rpc-endpoints-%s" $.Values.hyperlane.runEnv . }} - {{- end }} diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml deleted file mode 100644 index 933210d8d8..0000000000 --- a/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: portal-relayer -spec: - replicas: 1 - selector: - matchLabels: - name: portal-relayer - template: - metadata: - labels: - name: portal-relayer - spec: - containers: - - name: portal-relayer - image: {{ .Values.image.repository }}:{{ .Values.image.tag }} - imagePullPolicy: IfNotPresent - command: - - ./node_modules/.bin/tsx - - ./typescript/infra/scripts/middleware/portal-relayer.ts - - -e - - {{ .Values.hyperlane.runEnv }} - envFrom: - - secretRef: - name: liquidity-layer-env-var-secret diff --git a/typescript/infra/helm/liquidity-layer-relayers/values.yaml b/typescript/infra/helm/liquidity-layer-relayers/values.yaml deleted file mode 100644 index 95651f852a..0000000000 --- a/typescript/infra/helm/liquidity-layer-relayers/values.yaml +++ /dev/null @@ -1,9 +0,0 @@ -image: - repository: gcr.io/abacus-labs-dev/hyperlane-monorepo - tag: -abacus: - runEnv: testnet4 - # Used for fetching secrets - chains: [] -externalSecrets: - clusterSecretStore: diff --git a/typescript/infra/scripts/deploy.ts b/typescript/infra/scripts/deploy.ts index 22245d13b0..a4207cdc61 100644 --- a/typescript/infra/scripts/deploy.ts +++ b/typescript/infra/scripts/deploy.ts @@ -20,7 +20,6 @@ import { InterchainAccount, InterchainAccountDeployer, InterchainQueryDeployer, - LiquidityLayerDeployer, TestRecipientDeployer, } from '@hyperlane-xyz/sdk'; import { inCIMode, objFilter, objMap } from '@hyperlane-xyz/utils'; @@ -176,24 +175,6 @@ async function main() { contractVerifier, concurrentDeploy, ); - } else if (module === Modules.LIQUIDITY_LAYER) { - const { core } = await getHyperlaneCore(environment, multiProvider); - const routerConfig = core.getRouterConfig(envConfig.owners); - if (!envConfig.liquidityLayerConfig) { - throw new Error(`No liquidity layer config for ${environment}`); - } - config = objMap( - envConfig.liquidityLayerConfig.bridgeAdapters, - (chain, conf) => ({ - ...conf, - ...routerConfig[chain], - }), - ); - deployer = new LiquidityLayerDeployer( - multiProvider, - contractVerifier, - concurrentDeploy, - ); } else if (module === Modules.TEST_RECIPIENT) { const addresses = getAddresses(environment, Modules.CORE); diff --git a/typescript/infra/scripts/middleware/circle-relayer.ts b/typescript/infra/scripts/middleware/circle-relayer.ts deleted file mode 100644 index b125a2eeb4..0000000000 --- a/typescript/infra/scripts/middleware/circle-relayer.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { join } from 'path'; - -import { - LiquidityLayerApp, - LiquidityLayerConfig, - attachContractsMap, - liquidityLayerFactories, -} from '@hyperlane-xyz/sdk'; -import { objFilter, sleep } from '@hyperlane-xyz/utils'; - -import { getInfraPath, readJSON } from '../../src/utils/utils.js'; -import { getArgs, getEnvironmentDirectory } from '../agent-utils.js'; -import { getEnvironmentConfig } from '../core-utils.js'; - -async function check() { - const { environment } = await getArgs().argv; - const config = getEnvironmentConfig(environment); - - if (config.liquidityLayerConfig === undefined) { - throw new Error(`No liquidity layer config found for ${environment}`); - } - - const multiProvider = await config.getMultiProvider(); - const dir = join( - getInfraPath(), - getEnvironmentDirectory(environment), - 'middleware/liquidity-layer', - ); - const addresses = readJSON(dir, 'addresses.json'); - const contracts = attachContractsMap(addresses, liquidityLayerFactories); - - const app = new LiquidityLayerApp( - contracts, - multiProvider, - config.liquidityLayerConfig.bridgeAdapters, - ); - - while (true) { - for (const chain of Object.keys( - objFilter( - config.liquidityLayerConfig.bridgeAdapters, - (_, config): config is LiquidityLayerConfig => !!config.circle, - ), - )) { - const txHashes = await app.fetchCircleMessageTransactions(chain); - - const circleDispatches = ( - await Promise.all( - txHashes.map((txHash) => app.parseCircleMessages(chain, txHash)), - ) - ).flat(); - - // Poll for attestation data and submit - for (const message of circleDispatches) { - await app.attemptCircleAttestationSubmission(message); - } - - await sleep(6000); - } - } -} - -check().then(console.log).catch(console.error); diff --git a/typescript/infra/scripts/middleware/deploy-relayers.ts b/typescript/infra/scripts/middleware/deploy-relayers.ts deleted file mode 100644 index 1dc38f23c3..0000000000 --- a/typescript/infra/scripts/middleware/deploy-relayers.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Contexts } from '../../config/contexts.js'; -import { - getLiquidityLayerRelayerConfig, - runLiquidityLayerRelayerHelmCommand, -} from '../../src/middleware/liquidity-layer-relayer.js'; -import { HelmCommand } from '../../src/utils/helm.js'; -import { assertCorrectKubeContext } from '../agent-utils.js'; -import { getConfigsBasedOnArgs } from '../core-utils.js'; - -async function main() { - const { agentConfig, envConfig, context } = await getConfigsBasedOnArgs(); - if (context != Contexts.Hyperlane) - throw new Error(`Context must be ${Contexts.Hyperlane}, but is ${context}`); - - await assertCorrectKubeContext(envConfig); - - const liquidityLayerRelayerConfig = getLiquidityLayerRelayerConfig(envConfig); - - await runLiquidityLayerRelayerHelmCommand( - HelmCommand.InstallOrUpgrade, - agentConfig, - liquidityLayerRelayerConfig, - ); -} - -main() - .then(() => console.log('Deploy successful!')) - .catch(console.error); diff --git a/typescript/infra/scripts/middleware/portal-relayer.ts b/typescript/infra/scripts/middleware/portal-relayer.ts deleted file mode 100644 index 27e327fcec..0000000000 --- a/typescript/infra/scripts/middleware/portal-relayer.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { join } from 'path'; - -import { - LiquidityLayerApp, - attachContractsMap, - liquidityLayerFactories, -} from '@hyperlane-xyz/sdk'; -import { rootLogger, sleep } from '@hyperlane-xyz/utils'; - -import { bridgeAdapterConfigs } from '../../config/environments/testnet4/token-bridge.js'; -import { getInfraPath, readJSON } from '../../src/utils/utils.js'; -import { getArgs, getEnvironmentDirectory } from '../agent-utils.js'; -import { getEnvironmentConfig } from '../core-utils.js'; - -const logger = rootLogger.child({ module: 'portal-relayer' }); - -async function relayPortalTransfers() { - const { environment } = await getArgs().argv; - const config = getEnvironmentConfig(environment); - const multiProvider = await config.getMultiProvider(); - const dir = join( - getInfraPath(), - getEnvironmentDirectory(environment), - 'middleware/liquidity-layer', - ); - const addresses = readJSON(dir, 'addresses.json'); - const contracts = attachContractsMap(addresses, liquidityLayerFactories); - const app = new LiquidityLayerApp( - contracts, - multiProvider, - bridgeAdapterConfigs, - ); - - const tick = async () => { - for (const chain of Object.keys(bridgeAdapterConfigs)) { - logger.info('Processing chain', { - chain, - }); - - const txHashes = await app.fetchPortalBridgeTransactions(chain); - const portalMessages = ( - await Promise.all( - txHashes.map((txHash) => app.parsePortalMessages(chain, txHash)), - ) - ).flat(); - - logger.info('Portal messages', { - portalMessages, - }); - - // Poll for attestation data and submit - for (const message of portalMessages) { - try { - await app.attemptPortalTransferCompletion(message); - } catch (err) { - logger.error('Error attempting portal transfer', { - message, - err, - }); - } - } - await sleep(10000); - } - }; - - while (true) { - try { - await tick(); - } catch (err) { - logger.error('Error processing chains in tick', { - err, - }); - } - } -} - -relayPortalTransfers().then(console.log).catch(console.error); diff --git a/typescript/infra/src/config/environment.ts b/typescript/infra/src/config/environment.ts index 4166cdb5ea..8743526a72 100644 --- a/typescript/infra/src/config/environment.ts +++ b/typescript/infra/src/config/environment.ts @@ -1,6 +1,5 @@ import { IRegistry } from '@hyperlane-xyz/registry'; import { - BridgeAdapterConfig, ChainMap, ChainName, CoreConfig, @@ -28,7 +27,6 @@ import { RootAgentConfig } from './agent/agent.js'; import { CheckWarpDeployConfig, KeyFunderConfig } from './funding.js'; import { HelloWorldConfig } from './helloworld/types.js'; import { InfrastructureConfig } from './infrastructure.js'; -import { LiquidityLayerRelayerConfig } from './middleware.js'; export type DeployEnvironment = keyof typeof environments; export type EnvironmentChain = Extract< @@ -70,10 +68,6 @@ export type EnvironmentConfig = { helloWorld?: Partial>; keyFunderConfig?: KeyFunderConfig; checkWarpDeployConfig?: CheckWarpDeployConfig; - liquidityLayerConfig?: { - bridgeAdapters: ChainMap; - relayer: LiquidityLayerRelayerConfig; - }; }; export function assertEnvironment(env: string): DeployEnvironment { diff --git a/typescript/infra/src/config/middleware.ts b/typescript/infra/src/config/middleware.ts deleted file mode 100644 index 19fc3d8481..0000000000 --- a/typescript/infra/src/config/middleware.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DockerConfig } from './agent/agent.js'; - -export interface LiquidityLayerRelayerConfig { - docker: DockerConfig; - namespace: string; - prometheusPushGateway: string; -} diff --git a/typescript/infra/src/middleware/liquidity-layer-relayer.ts b/typescript/infra/src/middleware/liquidity-layer-relayer.ts deleted file mode 100644 index 4a1c3d7a17..0000000000 --- a/typescript/infra/src/middleware/liquidity-layer-relayer.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { AgentContextConfig } from '../config/agent/agent.js'; -import { EnvironmentConfig } from '../config/environment.js'; -import { LiquidityLayerRelayerConfig } from '../config/middleware.js'; -import { HelmCommand, helmifyValues } from '../utils/helm.js'; -import { execCmd } from '../utils/utils.js'; - -export async function runLiquidityLayerRelayerHelmCommand( - helmCommand: HelmCommand, - agentConfig: AgentContextConfig, - relayerConfig: LiquidityLayerRelayerConfig, -) { - const values = getLiquidityLayerRelayerHelmValues(agentConfig, relayerConfig); - - if (helmCommand === HelmCommand.InstallOrUpgrade) { - // Delete secrets to avoid them being stale - try { - await execCmd( - `kubectl delete secrets --namespace ${agentConfig.namespace} --selector app.kubernetes.io/instance=liquidity-layer-relayers`, - {}, - false, - false, - ); - } catch (e) { - console.error(e); - } - } - - return execCmd( - `helm ${helmCommand} liquidity-layer-relayers ./helm/liquidity-layer-relayers --namespace ${ - relayerConfig.namespace - } ${values.join(' ')}`, - {}, - false, - true, - ); -} - -function getLiquidityLayerRelayerHelmValues( - agentConfig: AgentContextConfig, - relayerConfig: LiquidityLayerRelayerConfig, -) { - const values = { - hyperlane: { - runEnv: agentConfig.runEnv, - // Only used for fetching RPC urls as env vars - chains: agentConfig.contextChainNames, - }, - image: { - repository: relayerConfig.docker.repo, - tag: relayerConfig.docker.tag, - }, - infra: { - prometheusPushGateway: relayerConfig.prometheusPushGateway, - }, - }; - return helmifyValues(values); -} - -export function getLiquidityLayerRelayerConfig( - coreConfig: EnvironmentConfig, -): LiquidityLayerRelayerConfig { - const relayerConfig = coreConfig.liquidityLayerConfig?.relayer; - if (!relayerConfig) { - throw new Error( - `Environment ${coreConfig.environment} does not have a LiquidityLayerRelayerConfig config`, - ); - } - return relayerConfig; -} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index d6557eb061..d3a77e7bd5 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -318,16 +318,6 @@ export { GetCallRemoteSettings, GetCallRemoteSettingsSchema, } from './middleware/account/types.js'; -export { liquidityLayerFactories } from './middleware/liquidity-layer/contracts.js'; -export { LiquidityLayerApp } from './middleware/liquidity-layer/LiquidityLayerApp.js'; -export { - BridgeAdapterConfig, - BridgeAdapterType, - CircleBridgeAdapterConfig, - LiquidityLayerConfig, - LiquidityLayerDeployer, - PortalAdapterConfig, -} from './middleware/liquidity-layer/LiquidityLayerRouterDeployer.js'; export { interchainQueryFactories } from './middleware/query/contracts.js'; export { InterchainQuery } from './middleware/query/InterchainQuery.js'; export { InterchainQueryChecker } from './middleware/query/InterchainQueryChecker.js'; diff --git a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts b/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts deleted file mode 100644 index 9fbcc838f7..0000000000 --- a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { ethers } from 'ethers'; - -import { - CircleBridgeAdapter__factory, - ICircleMessageTransmitter__factory, - ITokenMessenger__factory, - Mailbox__factory, - PortalAdapter__factory, -} from '@hyperlane-xyz/core'; -import { - addressToBytes32, - ensure0x, - eqAddress, - rootLogger, - strip0x, -} from '@hyperlane-xyz/utils'; - -import { HyperlaneApp } from '../../app/HyperlaneApp.js'; -import { HyperlaneContracts } from '../../contracts/types.js'; -import { MultiProvider } from '../../providers/MultiProvider.js'; -import { ChainMap, ChainName } from '../../types.js'; -import { fetchWithTimeout } from '../../utils/fetch.js'; - -import { BridgeAdapterConfig } from './LiquidityLayerRouterDeployer.js'; -import { liquidityLayerFactories } from './contracts.js'; - -const logger = rootLogger.child({ module: 'LiquidityLayerApp' }); - -const PORTAL_VAA_SERVICE_TESTNET_BASE_URL = - 'https://wormhole-v2-testnet-api.certus.one/v1/signed_vaa/'; -const CIRCLE_ATTESTATIONS_TESTNET_BASE_URL = - 'https://iris-api-sandbox.circle.com/attestations/'; -const CIRCLE_ATTESTATIONS_MAINNET_BASE_URL = - 'https://iris-api.circle.com/attestations/'; - -const PORTAL_VAA_SERVICE_SUCCESS_CODE = 5; - -const TokenMessengerInterface = ITokenMessenger__factory.createInterface(); -const CircleBridgeAdapterInterface = - CircleBridgeAdapter__factory.createInterface(); -const PortalAdapterInterface = PortalAdapter__factory.createInterface(); -const MailboxInterface = Mailbox__factory.createInterface(); - -const BridgedTokenTopic = CircleBridgeAdapterInterface.getEventTopic( - CircleBridgeAdapterInterface.getEvent('BridgedToken'), -); - -const PortalBridgedTokenTopic = PortalAdapterInterface.getEventTopic( - PortalAdapterInterface.getEvent('BridgedToken'), -); - -interface CircleBridgeMessage { - chain: ChainName; - remoteChain: ChainName; - txHash: string; - message: string; - nonce: number; - domain: number; - nonceHash: string; -} - -interface PortalBridgeMessage { - origin: ChainName; - nonce: number; - portalSequence: number; - destination: ChainName; -} - -export class LiquidityLayerApp extends HyperlaneApp< - typeof liquidityLayerFactories -> { - constructor( - public readonly contractsMap: ChainMap< - HyperlaneContracts - >, - public readonly multiProvider: MultiProvider, - public readonly config: ChainMap, - ) { - super(contractsMap, multiProvider); - } - - async fetchCircleMessageTransactions(chain: ChainName): Promise { - logger.info(`Fetch circle messages for ${chain}`); - const url = new URL(this.multiProvider.getExplorerApiUrl(chain)); - url.searchParams.set('module', 'logs'); - url.searchParams.set('action', 'getLogs'); - url.searchParams.set( - 'address', - this.getContracts(chain).circleBridgeAdapter!.address, - ); - url.searchParams.set('topic0', BridgedTokenTopic); - const req = await fetchWithTimeout(url); - const response = await req.json(); - - return response.result.map((tx: any) => tx.transactionHash).flat(); - } - - async fetchPortalBridgeTransactions(chain: ChainName): Promise { - const url = new URL(this.multiProvider.getExplorerApiUrl(chain)); - url.searchParams.set('module', 'logs'); - url.searchParams.set('action', 'getLogs'); - url.searchParams.set( - 'address', - this.getContracts(chain).portalAdapter!.address, - ); - url.searchParams.set('topic0', PortalBridgedTokenTopic); - const req = await fetchWithTimeout(url); - const response = await req.json(); - - if (!response.result) { - throw Error(`Expected result in response: ${response}`); - } - - return response.result.map((tx: any) => tx.transactionHash).flat(); - } - - async parsePortalMessages( - chain: ChainName, - txHash: string, - ): Promise { - const provider = this.multiProvider.getProvider(chain); - const receipt = await provider.getTransactionReceipt(txHash); - const matchingLogs = receipt.logs - .map((log) => { - try { - return [PortalAdapterInterface.parseLog(log)]; - } catch { - return []; - } - }) - .flat(); - if (matchingLogs.length == 0) return []; - - const event = matchingLogs.find((log) => log!.name === 'BridgedToken')!; - const portalSequence = event.args.portalSequence.toNumber(); - const nonce = event.args.nonce.toNumber(); - const destination = this.multiProvider.getChainName(event.args.destination); - - return [{ origin: chain, nonce, portalSequence, destination }]; - } - - async parseCircleMessages( - chain: ChainName, - txHash: string, - ): Promise { - logger.debug(`Parse Circle messages for chain ${chain} ${txHash}`); - const provider = this.multiProvider.getProvider(chain); - const receipt = await provider.getTransactionReceipt(txHash); - const matchingLogs = receipt.logs - .map((log) => { - try { - return [TokenMessengerInterface.parseLog(log)]; - } catch { - try { - return [CircleBridgeAdapterInterface.parseLog(log)]; - } catch { - try { - return [MailboxInterface.parseLog(log)]; - } catch { - return []; - } - } - } - }) - .flat(); - - if (matchingLogs.length == 0) return []; - const message = matchingLogs.find((log) => log!.name === 'MessageSent')! - .args.message; - const nonce = matchingLogs.find((log) => log!.name === 'BridgedToken')!.args - .nonce; - - const destinationDomain = matchingLogs.find( - (log) => log!.name === 'Dispatch', - )!.args.destination; - - const remoteChain = this.multiProvider.getChainName(destinationDomain); - const domain = this.config[chain].circle!.circleDomainMapping.find( - (mapping) => - mapping.hyperlaneDomain === this.multiProvider.getDomainId(chain), - )!.circleDomain; - return [ - { - chain, - remoteChain, - txHash, - message, - nonce, - domain, - nonceHash: ethers.utils.solidityKeccak256( - ['uint32', 'uint64'], - [domain, nonce], - ), - }, - ]; - } - - async attemptPortalTransferCompletion( - message: PortalBridgeMessage, - ): Promise { - const destinationPortalAdapter = this.getContracts( - message.destination, - ).portalAdapter!; - - const transferId = await destinationPortalAdapter.transferId( - this.multiProvider.getDomainId(message.origin), - message.nonce, - ); - - const transferTokenAddress = - await destinationPortalAdapter.portalTransfersProcessed(transferId); - - if (!eqAddress(transferTokenAddress, ethers.constants.AddressZero)) { - logger.info( - `Transfer with nonce ${message.nonce} from ${message.origin} to ${message.destination} already processed`, - ); - return; - } - - const wormholeOriginDomain = this.config[ - message.destination - ].portal!.wormholeDomainMapping.find( - (mapping) => - mapping.hyperlaneDomain === - this.multiProvider.getDomainId(message.origin), - )?.wormholeDomain; - const emitter = strip0x( - addressToBytes32(this.config[message.origin].portal!.portalBridgeAddress), - ); - - const vaa = await fetchWithTimeout( - `${PORTAL_VAA_SERVICE_TESTNET_BASE_URL}${wormholeOriginDomain}/${emitter}/${message.portalSequence}`, - ).then((response) => response.json()); - - if (vaa.code && vaa.code === PORTAL_VAA_SERVICE_SUCCESS_CODE) { - logger.info(`VAA not yet found for nonce ${message.nonce}`); - return; - } - - logger.debug( - `Complete portal transfer for nonce ${message.nonce} on ${message.destination}`, - ); - - try { - await this.multiProvider.handleTx( - message.destination, - destinationPortalAdapter.completeTransfer( - ensure0x(Buffer.from(vaa.vaaBytes, 'base64').toString('hex')), - ), - ); - } catch (error: any) { - if (error?.error?.reason?.includes('no wrapper for this token')) { - logger.info( - 'No wrapper for this token, you should register the token at https://wormhole-foundation.github.io/example-token-bridge-ui/#/register', - ); - logger.info(message); - return; - } - throw error; - } - } - - async attemptCircleAttestationSubmission( - message: CircleBridgeMessage, - ): Promise { - const signer = this.multiProvider.getSigner(message.remoteChain); - const transmitter = ICircleMessageTransmitter__factory.connect( - this.config[message.remoteChain].circle!.messageTransmitterAddress, - signer, - ); - - const alreadyProcessed = await transmitter.usedNonces(message.nonceHash); - - if (alreadyProcessed) { - logger.info(`Message sent on ${message.txHash} was already processed`); - return; - } - - logger.info(`Attempt Circle message delivery`, JSON.stringify(message)); - - const messageHash = ethers.utils.keccak256(message.message); - const baseurl = this.multiProvider.getChainMetadata(message.chain).isTestnet - ? CIRCLE_ATTESTATIONS_TESTNET_BASE_URL - : CIRCLE_ATTESTATIONS_MAINNET_BASE_URL; - const attestationsB = await fetchWithTimeout(`${baseurl}${messageHash}`); - const attestations = await attestationsB.json(); - - if (attestations.status !== 'complete') { - logger.info( - `Attestations not available for message nonce ${message.nonce} on ${message.txHash}`, - ); - return; - } - logger.info(`Ready to submit attestations for message ${message.nonce}`); - - const tx = await transmitter.receiveMessage( - message.message, - attestations.attestation, - ); - - logger.info( - `Submitted attestations in ${this.multiProvider.tryGetExplorerTxUrl( - message.remoteChain, - tx, - )}`, - ); - await this.multiProvider.handleTx(message.remoteChain, tx); - } -} diff --git a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts b/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts deleted file mode 100644 index ba1733dbd6..0000000000 --- a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { ethers } from 'ethers'; - -import { - CircleBridgeAdapter, - LiquidityLayerRouter, - PortalAdapter, - Router, -} from '@hyperlane-xyz/core'; -import { Address, eqAddress, objFilter, objMap } from '@hyperlane-xyz/utils'; - -import { - HyperlaneContracts, - HyperlaneContractsMap, -} from '../../contracts/types.js'; -import { ContractVerifier } from '../../deploy/verify/ContractVerifier.js'; -import { MultiProvider } from '../../providers/MultiProvider.js'; -import { ProxiedRouterDeployer } from '../../router/ProxiedRouterDeployer.js'; -import { RouterConfig } from '../../router/types.js'; -import { ChainMap, ChainName } from '../../types.js'; - -import { - LiquidityLayerFactories, - liquidityLayerFactories, -} from './contracts.js'; - -export enum BridgeAdapterType { - Circle = 'Circle', - Portal = 'Portal', -} - -export interface CircleBridgeAdapterConfig { - type: BridgeAdapterType.Circle; - tokenMessengerAddress: string; - messageTransmitterAddress: string; - usdcAddress: string; - circleDomainMapping: { - hyperlaneDomain: number; - circleDomain: number; - }[]; -} - -export interface PortalAdapterConfig { - type: BridgeAdapterType.Portal; - portalBridgeAddress: string; - wormholeDomainMapping: { - hyperlaneDomain: number; - wormholeDomain: number; - }[]; -} - -export type BridgeAdapterConfig = { - circle?: CircleBridgeAdapterConfig; - portal?: PortalAdapterConfig; -}; - -export type LiquidityLayerConfig = RouterConfig & BridgeAdapterConfig; - -export class LiquidityLayerDeployer extends ProxiedRouterDeployer< - LiquidityLayerConfig, - LiquidityLayerFactories -> { - constructor( - multiProvider: MultiProvider, - contractVerifier?: ContractVerifier, - concurrentDeploy = false, - ) { - super(multiProvider, liquidityLayerFactories, { - contractVerifier, - concurrentDeploy, - }); - } - - routerContractName(): string { - return 'LiquidityLayerRouter'; - } - - routerContractKey( - _: RouterConfig, - ): K { - return 'liquidityLayerRouter' as K; - } - - router(contracts: HyperlaneContracts): Router { - return contracts.liquidityLayerRouter; - } - - async constructorArgs( - _: string, - config: LiquidityLayerConfig, - ): Promise> { - return [config.mailbox] as any; - } - - async initializeArgs( - chain: string, - config: LiquidityLayerConfig, - ): Promise { - const owner = await this.multiProvider.getSignerAddress(chain); - if (typeof config.interchainSecurityModule === 'object') { - throw new Error('ISM as object unimplemented'); - } - return [ - config.hook ?? ethers.constants.AddressZero, - config.interchainSecurityModule ?? ethers.constants.AddressZero, - owner, - ]; - } - - async enrollRemoteRouters( - contractsMap: HyperlaneContractsMap, - configMap: ChainMap, - foreignRouters: ChainMap
, - ): Promise { - this.logger.debug(`Enroll LiquidityLayerRouters with each other`); - await super.enrollRemoteRouters(contractsMap, configMap, foreignRouters); - - this.logger.debug(`Enroll CircleBridgeAdapters with each other`); - // Hack to allow use of super.enrollRemoteRouters - await super.enrollRemoteRouters( - objMap( - objFilter( - contractsMap, - (_, c): c is HyperlaneContracts => - !!c.circleBridgeAdapter, - ), - (_, contracts) => ({ - liquidityLayerRouter: contracts.circleBridgeAdapter, - }), - ) as unknown as HyperlaneContractsMap, - configMap, - foreignRouters, - ); - - this.logger.debug(`Enroll PortalAdapters with each other`); - // Hack to allow use of super.enrollRemoteRouters - await super.enrollRemoteRouters( - objMap( - objFilter( - contractsMap, - (_, c): c is HyperlaneContracts => - !!c.portalAdapter, - ), - (_, contracts) => ({ - liquidityLayerRouter: contracts.portalAdapter, - }), - ) as unknown as HyperlaneContractsMap, - configMap, - foreignRouters, - ); - } - - // Custom contract deployment logic can go here - // If no custom logic is needed, call deployContract for the router - async deployContracts( - chain: ChainName, - config: LiquidityLayerConfig, - ): Promise> { - // This is just the temp owner for contracts, and HyperlaneRouterDeployer#transferOwnership actually sets the configured owner - const deployer = await this.multiProvider.getSignerAddress(chain); - - const routerContracts = await super.deployContracts(chain, config); - - const bridgeAdapters: Partial< - HyperlaneContracts - > = {}; - - if (config.circle) { - bridgeAdapters.circleBridgeAdapter = await this.deployCircleBridgeAdapter( - chain, - config.circle, - deployer, - routerContracts.liquidityLayerRouter, - ); - } - if (config.portal) { - bridgeAdapters.portalAdapter = await this.deployPortalAdapter( - chain, - config.portal, - deployer, - routerContracts.liquidityLayerRouter, - ); - } - - return { - ...routerContracts, - ...bridgeAdapters, - }; - } - - async deployPortalAdapter( - chain: ChainName, - adapterConfig: PortalAdapterConfig, - owner: string, - router: LiquidityLayerRouter, - ): Promise { - const mailbox = await router.mailbox(); - const portalAdapter = await this.deployContract( - chain, - 'portalAdapter', - [mailbox], - [owner, adapterConfig.portalBridgeAddress, router.address], - ); - - for (const { - wormholeDomain, - hyperlaneDomain, - } of adapterConfig.wormholeDomainMapping) { - const expectedCircleDomain = - await portalAdapter.hyperlaneDomainToWormholeDomain(hyperlaneDomain); - if (expectedCircleDomain === wormholeDomain) continue; - - this.logger.debug( - `Set wormhole domain ${wormholeDomain} for hyperlane domain ${hyperlaneDomain}`, - ); - await this.runIfOwner(chain, portalAdapter, () => - this.multiProvider.handleTx( - chain, - portalAdapter.addDomain(hyperlaneDomain, wormholeDomain), - ), - ); - } - - if ( - !eqAddress( - await router.liquidityLayerAdapters('Portal'), - portalAdapter.address, - ) - ) { - this.logger.debug('Set Portal as LiquidityLayerAdapter on Router'); - await this.runIfOwner(chain, portalAdapter, () => - this.multiProvider.handleTx( - chain, - router.setLiquidityLayerAdapter( - adapterConfig.type, - portalAdapter.address, - ), - ), - ); - } - - return portalAdapter; - } - - async deployCircleBridgeAdapter( - chain: ChainName, - adapterConfig: CircleBridgeAdapterConfig, - owner: string, - router: LiquidityLayerRouter, - ): Promise { - const mailbox = await router.mailbox(); - const circleBridgeAdapter = await this.deployContract( - chain, - 'circleBridgeAdapter', - [mailbox], - [ - owner, - adapterConfig.tokenMessengerAddress, - adapterConfig.messageTransmitterAddress, - router.address, - ], - ); - - if ( - !eqAddress( - await circleBridgeAdapter.tokenSymbolToAddress('USDC'), - adapterConfig.usdcAddress, - ) - ) { - this.logger.debug(`Set USDC token contract`); - await this.runIfOwner(chain, circleBridgeAdapter, () => - this.multiProvider.handleTx( - chain, - circleBridgeAdapter.addToken(adapterConfig.usdcAddress, 'USDC'), - ), - ); - } - // Set domain mappings - for (const { - circleDomain, - hyperlaneDomain, - } of adapterConfig.circleDomainMapping) { - const expectedCircleDomain = - await circleBridgeAdapter.hyperlaneDomainToCircleDomain( - hyperlaneDomain, - ); - if (expectedCircleDomain === circleDomain) continue; - - this.logger.debug( - `Set circle domain ${circleDomain} for hyperlane domain ${hyperlaneDomain}`, - ); - await this.runIfOwner(chain, circleBridgeAdapter, () => - this.multiProvider.handleTx( - chain, - circleBridgeAdapter.addDomain(hyperlaneDomain, circleDomain), - ), - ); - } - - if ( - !eqAddress( - await router.liquidityLayerAdapters('Circle'), - circleBridgeAdapter.address, - ) - ) { - this.logger.debug('Set Circle as LiquidityLayerAdapter on Router'); - await this.runIfOwner(chain, circleBridgeAdapter, () => - this.multiProvider.handleTx( - chain, - router.setLiquidityLayerAdapter( - adapterConfig.type, - circleBridgeAdapter.address, - ), - ), - ); - } - - return circleBridgeAdapter; - } -} diff --git a/typescript/sdk/src/middleware/liquidity-layer/contracts.ts b/typescript/sdk/src/middleware/liquidity-layer/contracts.ts deleted file mode 100644 index ef88008b94..0000000000 --- a/typescript/sdk/src/middleware/liquidity-layer/contracts.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - CircleBridgeAdapter__factory, - LiquidityLayerRouter__factory, - PortalAdapter__factory, -} from '@hyperlane-xyz/core'; - -import { proxiedFactories } from '../../router/types.js'; - -export const liquidityLayerFactories = { - circleBridgeAdapter: new CircleBridgeAdapter__factory(), - portalAdapter: new PortalAdapter__factory(), - liquidityLayerRouter: new LiquidityLayerRouter__factory(), - ...proxiedFactories, -}; - -export type LiquidityLayerFactories = typeof liquidityLayerFactories; diff --git a/typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts b/typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts deleted file mode 100644 index cf7bdde70c..0000000000 --- a/typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; -import { expect } from 'chai'; -import hre from 'hardhat'; - -import { - LiquidityLayerRouter, - MockCircleMessageTransmitter, - MockCircleMessageTransmitter__factory, - MockCircleTokenMessenger, - MockCircleTokenMessenger__factory, - MockPortalBridge, - MockPortalBridge__factory, - MockToken, - MockToken__factory, - TestLiquidityLayerMessageRecipient__factory, -} from '@hyperlane-xyz/core'; -import { addressToBytes32, objMap } from '@hyperlane-xyz/utils'; - -import { TestChainName, test1, test2 } from '../../consts/testChains.js'; -import { TestCoreApp } from '../../core/TestCoreApp.js'; -import { TestCoreDeployer } from '../../core/TestCoreDeployer.js'; -import { HyperlaneProxyFactoryDeployer } from '../../deploy/HyperlaneProxyFactoryDeployer.js'; -import { HyperlaneIsmFactory } from '../../ism/HyperlaneIsmFactory.js'; -import { MultiProvider } from '../../providers/MultiProvider.js'; -import { ChainMap } from '../../types.js'; - -import { LiquidityLayerApp } from './LiquidityLayerApp.js'; -import { - BridgeAdapterType, - CircleBridgeAdapterConfig, - LiquidityLayerConfig, - LiquidityLayerDeployer, - PortalAdapterConfig, -} from './LiquidityLayerRouterDeployer.js'; - -// eslint-disable-next-line jest/no-disabled-tests -describe.skip('LiquidityLayerRouter', async () => { - const localChain = TestChainName.test1; - const remoteChain = TestChainName.test2; - const localDomain = test1.domainId!; - const remoteDomain = test2.domainId!; - - let signer: SignerWithAddress; - let local: LiquidityLayerRouter; - let multiProvider: MultiProvider; - let coreApp: TestCoreApp; - - let liquidityLayerApp: LiquidityLayerApp; - let config: ChainMap; - let mockToken: MockToken; - let circleTokenMessenger: MockCircleTokenMessenger; - let portalBridge: MockPortalBridge; - let messageTransmitter: MockCircleMessageTransmitter; - - before(async () => { - [signer] = await hre.ethers.getSigners(); - multiProvider = MultiProvider.createTestMultiProvider({ signer }); - const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); - const ismFactory = new HyperlaneIsmFactory( - await ismFactoryDeployer.deploy(multiProvider.mapKnownChains(() => ({}))), - multiProvider, - ); - coreApp = await new TestCoreDeployer(multiProvider, ismFactory).deployApp(); - const routerConfig = coreApp.getRouterConfig(signer.address); - - const mockTokenF = new MockToken__factory(signer); - mockToken = await mockTokenF.deploy(); - const portalBridgeF = new MockPortalBridge__factory(signer); - const circleTokenMessengerF = new MockCircleTokenMessenger__factory(signer); - circleTokenMessenger = await circleTokenMessengerF.deploy( - mockToken.address, - ); - portalBridge = await portalBridgeF.deploy(mockToken.address); - const messageTransmitterF = new MockCircleMessageTransmitter__factory( - signer, - ); - messageTransmitter = await messageTransmitterF.deploy(mockToken.address); - - config = objMap(routerConfig, (chain, config) => { - return { - ...config, - circle: { - type: BridgeAdapterType.Circle, - tokenMessengerAddress: circleTokenMessenger.address, - messageTransmitterAddress: messageTransmitter.address, - usdcAddress: mockToken.address, - circleDomainMapping: [ - { - hyperlaneDomain: localDomain, - circleDomain: localDomain, - }, - { - hyperlaneDomain: remoteDomain, - circleDomain: remoteDomain, - }, - ], - } as CircleBridgeAdapterConfig, - portal: { - type: BridgeAdapterType.Portal, - portalBridgeAddress: portalBridge.address, - wormholeDomainMapping: [ - { - hyperlaneDomain: localDomain, - wormholeDomain: localDomain, - }, - { - hyperlaneDomain: remoteDomain, - wormholeDomain: remoteDomain, - }, - ], - } as PortalAdapterConfig, - }; - }); - }); - - beforeEach(async () => { - const LiquidityLayer = new LiquidityLayerDeployer(multiProvider); - const contracts = await LiquidityLayer.deploy(config); - - liquidityLayerApp = new LiquidityLayerApp(contracts, multiProvider, config); - - local = liquidityLayerApp.getContracts(localChain).liquidityLayerRouter; - }); - - it('can transfer tokens via Circle', async () => { - const recipientF = new TestLiquidityLayerMessageRecipient__factory(signer); - const recipient = await recipientF.deploy(); - - const amount = 1000; - await mockToken.mint(signer.address, amount); - await mockToken.approve(local.address, amount); - await local.dispatchWithTokens( - remoteDomain, - addressToBytes32(recipient.address), - mockToken.address, - amount, - BridgeAdapterType.Circle, - '0x01', - ); - - const transferNonce = await circleTokenMessenger.nextNonce(); - const nonceId = await messageTransmitter.hashSourceAndNonce( - localDomain, - transferNonce, - ); - - await messageTransmitter.process( - nonceId, - liquidityLayerApp.getContracts(remoteChain).circleBridgeAdapter!.address, - amount, - ); - await coreApp.processMessages(); - - expect((await mockToken.balanceOf(recipient.address)).toNumber()).to.eql( - amount, - ); - }); - - it('can transfer tokens via Portal', async () => { - const recipientF = new TestLiquidityLayerMessageRecipient__factory(signer); - const recipient = await recipientF.deploy(); - - const amount = 1000; - await mockToken.mint(signer.address, amount); - await mockToken.approve(local.address, amount); - await local.dispatchWithTokens( - remoteDomain, - addressToBytes32(recipient.address), - mockToken.address, - amount, - BridgeAdapterType.Portal, - '0x01', - ); - - const originAdapter = - liquidityLayerApp.getContracts(localChain).portalAdapter!; - const destinationAdapter = - liquidityLayerApp.getContracts(remoteChain).portalAdapter!; - await destinationAdapter.completeTransfer( - await portalBridge.mockPortalVaa( - localDomain, - await originAdapter.nonce(), - amount, - ), - ); - await coreApp.processMessages(); - - expect((await mockToken.balanceOf(recipient.address)).toNumber()).to.eql( - amount, - ); - }); -}); From 9fef23d978ce48150a684e57b927a924ba405300 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 23 Sep 2025 13:40:53 -0400 Subject: [PATCH 16/36] fix: storage layout compatibility (#7078) Co-authored-by: Cursor Agent --- .github/workflows/storage-analysis.yml | 26 ++++++++++++++++++++++---- solidity/storage.sh | 21 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.github/workflows/storage-analysis.yml b/.github/workflows/storage-analysis.yml index 6a9d8409b8..941ae13143 100644 --- a/.github/workflows/storage-analysis.yml +++ b/.github/workflows/storage-analysis.yml @@ -2,7 +2,8 @@ name: Check Storage Layout Changes on: pull_request: - branches: [main] + branches: + - '*' paths: - 'solidity/**' workflow_dispatch: @@ -53,13 +54,30 @@ jobs: env: BASE_REF: ${{ github.event.inputs.base || github.event.pull_request.base.sha }} run: | + # Fetch the base reference git fetch origin $BASE_REF - git checkout $BASE_REF -- solidity/contracts + # Check if BASE_REF is a commit SHA (40 hex characters) or a branch name + if [[ "$BASE_REF" =~ ^[0-9a-f]{40}$ ]]; then + # For commit SHAs, checkout directly without origin/ prefix + git checkout $BASE_REF -- solidity/contracts + else + # For branch names, use origin/ prefix + git checkout origin/$BASE_REF -- solidity/contracts + fi # Run the command on the target branch - name: Run command on target branch run: yarn workspace @hyperlane-xyz/core storage base-storage # Compare outputs - - name: Compare outputs - run: diff --unified solidity/base-storage solidity/HEAD-storage + - name: Compare outputs (fail on removals only) + run: | + DIFF_OUTPUT=$(diff --unified solidity/base-storage solidity/HEAD-storage || true) + echo "$DIFF_OUTPUT" + # Fail only if there are removal lines in diff hunks (lines starting with '-' but not '---') + if echo "$DIFF_OUTPUT" | grep -E '^-([^-])' >/dev/null; then + echo "Detected storage removals in diff. Failing job." + exit 1 + else + echo "No storage removals detected." + fi diff --git a/solidity/storage.sh b/solidity/storage.sh index 54b2851018..296e9e4e86 100755 --- a/solidity/storage.sh +++ b/solidity/storage.sh @@ -15,7 +15,22 @@ do continue fi - contract=$(basename "$file" .sol) - echo "Generating storage layout of $contract" - forge inspect "$contract" storage > "$OUTPUT_PATH/$contract.md" + # Skip files that don't end in .sol + if [[ ! "$file" =~ \.sol$ ]]; then + continue + fi + + # Extract all contract names from the file + contracts=$(grep -o '^contract [A-Za-z0-9_][A-Za-z0-9_]*' "$file" | sed 's/^contract //') + + if [ -z "$contracts" ]; then + continue + fi + + # Process each contract found in the file + for contract in $contracts; do + echo "Generating storage layout of $contract" + echo "slot offset label" > "$OUTPUT_PATH/$contract-layout.tsv" + forge inspect "$contract" storage --json | jq -r '.storage .[] | "\(.slot)\t\(.offset)\t\(.label)"' >> "$OUTPUT_PATH/$contract-layout.tsv" + done done From 5b17b0f37803636de9d65ddd9ef15b7f11aad5a1 Mon Sep 17 00:00:00 2001 From: larryob Date: Wed, 24 Sep 2025 11:57:44 -0400 Subject: [PATCH 17/36] refactor: Send funds directly to recipient for ERC20 intents (#6750) --- .changeset/gold-islands-agree.md | 5 + solidity/contracts/mock/MockMailbox.sol | 34 ++ .../token/bridge/EverclearEthBridge.sol | 51 ++ .../token/bridge/EverclearTokenBridge.sol | 133 +++-- solidity/foundry.toml | 2 +- .../test/token/EverclearTokenBridge.t.sol | 483 ++++++++++-------- 6 files changed, 459 insertions(+), 249 deletions(-) create mode 100644 .changeset/gold-islands-agree.md diff --git a/.changeset/gold-islands-agree.md b/.changeset/gold-islands-agree.md new file mode 100644 index 0000000000..ae91d65472 --- /dev/null +++ b/.changeset/gold-islands-agree.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": minor +--- + +Add Everclear bridges for ETH and ERC20 tokens. diff --git a/solidity/contracts/mock/MockMailbox.sol b/solidity/contracts/mock/MockMailbox.sol index 761d26cd05..7f616480f8 100644 --- a/solidity/contracts/mock/MockMailbox.sol +++ b/solidity/contracts/mock/MockMailbox.sol @@ -14,6 +14,7 @@ import {TestPostDispatchHook} from "../test/TestPostDispatchHook.sol"; contract MockMailbox is Mailbox { using Message for bytes; + using TypeCasts for address; uint32 public inboundUnprocessedNonce = 0; uint32 public inboundProcessedNonce = 0; @@ -92,4 +93,37 @@ contract MockMailbox is Mailbox { function addInboundMetadata(uint32 _nonce, bytes memory metadata) public { inboundMetadata[_nonce] = metadata; } + + function buildMessage( + address sender, + uint32 destinationDomain, + bytes32 recipientAddress, + bytes calldata messageBody + ) external view returns (bytes memory) { + return + _buildMessage( + sender, + destinationDomain, + recipientAddress, + messageBody + ); + } + + function _buildMessage( + address sender, + uint32 destinationDomain, + bytes32 recipientAddress, + bytes calldata messageBody + ) internal view returns (bytes memory) { + return + Message.formatMessage( + VERSION, + nonce, + localDomain, + sender.addressToBytes32(), + destinationDomain, + recipientAddress, + messageBody + ); + } } diff --git a/solidity/contracts/token/bridge/EverclearEthBridge.sol b/solidity/contracts/token/bridge/EverclearEthBridge.sol index cd9e78fbbe..a02d1cf0a9 100644 --- a/solidity/contracts/token/bridge/EverclearEthBridge.sol +++ b/solidity/contracts/token/bridge/EverclearEthBridge.sol @@ -25,6 +25,9 @@ contract EverclearEthBridge is EverclearTokenBridge { /** * @notice Constructor to initialize the Everclear ETH bridge + * @param _weth The WETH contract address for wrapping/unwrapping ETH + * @param _scale The scaling factor for token amounts (typically 1 for 18-decimal tokens) + * @param _mailbox The address of the Hyperlane mailbox contract * @param _everclearAdapter The address of the Everclear adapter contract */ constructor( @@ -41,6 +44,27 @@ contract EverclearEthBridge is EverclearTokenBridge { ) {} + /** + * @notice Gets the receiver address for an ETH transfer intent + * @dev Overrides parent to use the remote router instead of direct recipient + * @param _destination The destination domain ID + * @return receiver The remote router address that will handle the ETH transfer + */ + function _getReceiver( + uint32 _destination, + bytes32 /* _recipient */ + ) internal view override returns (bytes32 receiver) { + return _mustHaveRemoteRouter(_destination); + } + + /** + * @notice Provides a quote for transferring ETH to a remote chain + * @dev Overrides parent to return a single quote for ETH (including transfer amount, fees, and gas) + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of ETH to transfer + * @return quotes Array containing a single quote with total ETH amount needed + */ function quoteTransferRemote( uint32 _destination, bytes32 _recipient, @@ -57,6 +81,8 @@ contract EverclearEthBridge is EverclearTokenBridge { /** * @notice Transfers ETH from sender, wrapping to WETH + * @dev Requires msg.value to be at least the specified amount, then wraps ETH to WETH + * @param _amount The amount of ETH to wrap to WETH (includes transfer amount and fees) */ function _transferFromSender(uint256 _amount) internal override { // The `_amount` here will be amount + fee where amount is what the user wants to send, @@ -66,6 +92,12 @@ contract EverclearEthBridge is EverclearTokenBridge { IWETH(address(wrappedToken)).deposit{value: _amount}(); } + /** + * @notice Transfers ETH to a recipient by unwrapping WETH and sending native ETH + * @dev Unwraps WETH to ETH and uses Address.sendValue for safe ETH transfer + * @param _recipient The address to receive the ETH + * @param _amount The amount of ETH to transfer + */ function _transferTo( address _recipient, uint256 _amount @@ -77,6 +109,14 @@ contract EverclearEthBridge is EverclearTokenBridge { payable(_recipient).sendValue(_amount); } + /** + * @notice Charges the sender for ETH transfer including all fees + * @dev Overrides parent to handle ETH-specific charging logic with fee calculation and distribution + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of ETH to transfer (excluding fees) + * @return dispatchValue The remaining ETH value to include with the Hyperlane message dispatch + */ function _chargeSender( uint32 _destination, bytes32 _recipient, @@ -92,4 +132,15 @@ contract EverclearEthBridge is EverclearTokenBridge { } return dispatchValue; } + + /** + * @notice Allows the contract to receive ETH + * @dev Required for WETH unwrapping functionality + */ + receive() external payable { + require( + msg.sender == address(wrappedToken), + "EEB: Only WETH can send ETH" + ); + } } diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index 2a7eace37d..62e1f14125 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -11,6 +11,7 @@ import {PackageVersioned} from "../../PackageVersioned.sol"; import {IWETH} from "../interfaces/IWETH.sol"; import {TokenMessage} from "../libs/TokenMessage.sol"; import {TypeCasts} from "../../libs/TypeCasts.sol"; +import {FungibleTokenRouter} from "../libs/FungibleTokenRouter.sol"; /** * @notice Information about an output asset for a destination domain @@ -68,6 +69,9 @@ contract EverclearTokenBridge is HypERC20Collateral { /** * @notice Constructor to initialize the Everclear token bridge + * @param _erc20 The address of the ERC20 token to be bridged + * @param _scale The scaling factor for token amounts (typically 1 for 18-decimal tokens) + * @param _mailbox The address of the Hyperlane mailbox contract * @param _everclearAdapter The address of the Everclear adapter contract */ constructor( @@ -81,8 +85,10 @@ contract EverclearTokenBridge is HypERC20Collateral { } /** - * @notice Initializes the proxy contract. - * @dev Approves the Everclear adapter to spend tokens + * @notice Initializes the proxy contract + * @dev Approves the Everclear adapter to spend tokens and calls parent initialization + * @param _hook The address of the post-dispatch hook (can be zero address) + * @param _owner The address that will own this contract */ function initialize(address _hook, address _owner) public initializer { _HypERC20_initialize(_hook, address(0), _owner); @@ -109,6 +115,11 @@ contract EverclearTokenBridge is HypERC20Collateral { emit FeeParamsUpdated(_fee, _deadline); } + /** + * @notice Internal function to set the output asset for a destination domain + * @dev Emits OutputAssetSet event when successful + * @param _outputAssetInfo The output asset information containing destination and asset address + */ function _setOutputAsset( OutputAssetInfo calldata _outputAssetInfo ) internal { @@ -172,27 +183,13 @@ contract EverclearTokenBridge is HypERC20Collateral { }); } - /// @dev We can't use _feeAmount here because Everclear wants to pull tokens from this contract - /// and the amount from _feeAmount is sent to the fee recipient. - function _chargeSender( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal virtual override returns (uint256 dispatchValue) { - return - super._chargeSender( - _destination, - _recipient, - _amount + feeParams.fee - ); - } - /** * @notice Creates an Everclear intent for cross-chain token transfer * @dev Internal function to handle intent creation with Everclear adapter * @param _destination The destination domain ID * @param _recipient The recipient address on the destination chain * @param _amount The amount of tokens to transfer + * @return The created Everclear intent struct containing all transfer details */ function _createIntent( uint32 _destination, @@ -209,16 +206,15 @@ contract EverclearTokenBridge is HypERC20Collateral { destinations[0] = _destination; // Create intent - // We always send the funds to the remote router, which will then send them to the recipient in _handle (, IEverclear.Intent memory intent) = everclearAdapter.newIntent({ _destinations: destinations, - _receiver: _mustHaveRemoteRouter(_destination), + _receiver: _getReceiver(_destination, _recipient), _inputAsset: address(wrappedToken), _outputAsset: outputAssets[_destination], // We load this from storage again to avoid stack too deep _amount: _amount, _maxFee: 0, _ttl: 0, - _data: _getIntentCalldata(_recipient, _amount), + _data: "", _feeParams: feeParams }); @@ -226,23 +222,56 @@ contract EverclearTokenBridge is HypERC20Collateral { } /** - * @notice Gets the calldata for the intent that will unwrap WETH to ETH on destination - * @dev Overrides parent to return calldata for unwrapping WETH to ETH - * @return The encoded calldata for the unwrap and send operation + * @notice Gets the receiver address for an intent + * @dev Virtual function that can be overridden by derived contracts + * @param _destination The destination domain ID + * @param _recipient The intended recipient address + * @return receiver The receiver address to use in the intent (typically the recipient for token bridge) */ - function _getIntentCalldata( + function _getReceiver( + uint32 _destination, + bytes32 _recipient + ) internal view virtual returns (bytes32) { + return _recipient; + } + + /** + * @notice Charges the sender for the transfer including Everclear fees + * @dev We can't use _feeAmount here because Everclear wants to pull tokens from this contract + * and the amount from _feeAmount is sent to the fee recipient. + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of tokens to transfer (excluding fees) + * @return dispatchValue The ETH value to include with the Hyperlane message dispatch + */ + function _chargeSender( + uint32 _destination, bytes32 _recipient, uint256 _amount - ) internal view returns (bytes memory) { - return abi.encode(_recipient, _amount); + ) internal virtual override returns (uint256 dispatchValue) { + return + super._chargeSender( + _destination, + _recipient, + _amount + feeParams.fee + ); } + /** + * @notice Handles pre-dispatch logic including charging sender and creating Everclear intent + * @dev Overrides parent function to integrate with Everclear's intent system + * @param _destination The destination domain ID + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of tokens to transfer + * @return dispatchValue The ETH value to include with the message dispatch + * @return message The encoded message containing transfer details and intent + */ function _beforeDispatch( uint32 _destination, bytes32 _recipient, uint256 _amount ) internal virtual override returns (uint256, bytes memory) { - (uint256 _dispatchValue, bytes memory _msg) = super._beforeDispatch( + uint256 dispatchValue = _chargeSender( _destination, _recipient, _amount @@ -254,45 +283,51 @@ contract EverclearTokenBridge is HypERC20Collateral { _amount ); - // Add the intent to the `TokenMessage` as metadata - // The original `_msg` is abi.encodePacked(_recipient, _amount) - // We need can't use abi.encodePacked because the intent is a struct - _msg = bytes.concat(_msg, abi.encode(intent)); + bytes memory message = TokenMessage.format( + _recipient, + _outboundAmount(_amount), + abi.encode(intent) + ); - return (_dispatchValue, _msg); + return (dispatchValue, message); } + /** + * @dev No-op, the funds are transferred directly to `_recipient` via Everclear + * @param _recipient The address to receive the tokens + * @param _amount The amount of tokens to transfer + */ + function _transferTo( + address _recipient, + uint256 _amount + ) internal virtual override { + // No-op, the funds are transferred directly to `_recipient` via Everclear + } + + /** + * @notice Handles incoming messages from remote chains + * @dev For the base token bridge, this is a no-op since funds are transferred via Everclear + * @param _origin The origin domain ID where the message was sent from + * @param _message The message payload (unused in base implementation) + */ function _handle( uint32 _origin, - bytes32 /* sender */, + bytes32 _sender, bytes calldata _message ) internal virtual override { // Get intent from hyperlane message bytes memory metadata = _message.metadata(); - IEverclear.Intent memory intent = abi.decode( - metadata, - (IEverclear.Intent) - ); + bytes32 intentId = keccak256(metadata); - /* CHECKS */ - // Check that intent is settled - bytes32 intentId = keccak256(abi.encode(intent)); + // Check Everclear intent status require( everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED, "ETB: Intent Status != SETTLED" ); // Check that we have not processed this intent before require(!intentSettled[intentId], "ETB: Intent already processed"); - (bytes32 _recipient, uint256 _amount) = abi.decode( - intent.data, - (bytes32, uint256) - ); - /* EFFECTS */ intentSettled[intentId] = true; - emit ReceivedTransferRemote(_origin, _recipient, _amount); - - /* INTERACTIONS */ - _transferTo(_recipient.bytes32ToAddress(), _amount); + super._handle(_origin, _sender, _message); } } diff --git a/solidity/foundry.toml b/solidity/foundry.toml index c276b9423c..711987773e 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -8,7 +8,7 @@ cache_path = 'forge-cache' allow_paths = ["../node_modules"] solc_version = '0.8.22' evm_version= 'paris' -optimizer = true +# optimizer = true optimizer_runs = 999_999 fs_permissions = [ { access = "read", path = "./script/avs/"}, diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 952b9216bc..0baaeba180 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -387,10 +387,7 @@ contract EverclearTokenBridgeTest is Test { assertEq(everclearAdapter.lastAmount(), TRANSFER_AMT); assertEq(everclearAdapter.lastMaxFee(), 0); assertEq(everclearAdapter.lastTtl(), 0); - assertEq( - everclearAdapter.lastData(), - abi.encode(RECIPIENT, TRANSFER_AMT) - ); + assertEq(everclearAdapter.lastData(), ""); // Check fee params (uint256 fee, uint256 deadline, bytes memory sig) = everclearAdapter @@ -559,151 +556,6 @@ contract EverclearTokenBridgeTest is Test { // Verify intent is not initially settled assertFalse(bridge.intentSettled(intentId)); } - - function testIntentSettledAfterProcessing() public { - // Create a mock intent - IEverclear.Intent memory intent = IEverclear.Intent({ - initiator: bytes32(uint256(uint160(ALICE))), - receiver: RECIPIENT, - inputAsset: bytes32(uint256(uint160(address(token)))), - outputAsset: bytes32(uint256(uint160(address(token)))), - maxFee: 0, - origin: ORIGIN, - destinations: new uint32[](1), - nonce: 1, - timestamp: uint48(block.timestamp), - ttl: 0, - amount: 100e18, - data: abi.encode(RECIPIENT, 100e18) - }); - intent.destinations[0] = DESTINATION; - - bytes32 intentId = keccak256(abi.encode(intent)); - - // Mock the spoke to return SETTLED status - vm.mockCall( - address(everclearAdapter.spoke()), - abi.encodeWithSelector(IEverclearSpoke.status.selector, intentId), - abi.encode(IEverclear.IntentStatus.SETTLED) - ); - - // Give the bridge some tokens to transfer - token.mintTo(address(bridge), 100e18); - - // Create a mock message with the intent in metadata - bytes memory metadata = abi.encode(intent); - bytes memory message = TokenMessage.format(RECIPIENT, 100e18, metadata); - - // Simulate receiving the message (this should process the intent) - vm.prank(address(mailbox)); - bridge.handle( - ORIGIN, - bytes32(uint256(uint160(address(bridge)))), - message - ); - - // Verify intent is now settled - assertTrue(bridge.intentSettled(intentId)); - } - - function testIntentAlreadyProcessedReverts() public { - // Create a mock intent - IEverclear.Intent memory intent = IEverclear.Intent({ - initiator: bytes32(uint256(uint160(ALICE))), - receiver: RECIPIENT, - inputAsset: bytes32(uint256(uint160(address(token)))), - outputAsset: bytes32(uint256(uint160(address(token)))), - maxFee: 0, - origin: ORIGIN, - destinations: new uint32[](1), - nonce: 1, - timestamp: uint48(block.timestamp), - ttl: 0, - amount: 100e18, - data: abi.encode(RECIPIENT, 100e18) - }); - intent.destinations[0] = DESTINATION; - - bytes32 intentId = keccak256(abi.encode(intent)); - - // Mock the spoke to return SETTLED status - vm.mockCall( - address(everclearAdapter.spoke()), - abi.encodeWithSelector(IEverclearSpoke.status.selector, intentId), - abi.encode(IEverclear.IntentStatus.SETTLED) - ); - - // Give the bridge some tokens to transfer - token.mintTo(address(bridge), 200e18); - - // Create a mock message with the intent in metadata - bytes memory metadata = abi.encode(intent); - bytes memory message = TokenMessage.format(RECIPIENT, 100e18, metadata); - - // Process the intent first time (should succeed) - vm.prank(address(mailbox)); - bridge.handle( - ORIGIN, - bytes32(uint256(uint160(address(bridge)))), - message - ); - - // Verify intent is settled - assertTrue(bridge.intentSettled(intentId)); - - // Try to process the same intent again (should revert) - vm.prank(address(mailbox)); - vm.expectRevert("ETB: Intent already processed"); - bridge.handle( - ORIGIN, - bytes32(uint256(uint160(address(bridge)))), - message - ); - } - - function testIntentNotSettledReverts() public { - // Create a mock intent - IEverclear.Intent memory intent = IEverclear.Intent({ - initiator: bytes32(uint256(uint160(ALICE))), - receiver: RECIPIENT, - inputAsset: bytes32(uint256(uint160(address(token)))), - outputAsset: bytes32(uint256(uint160(address(token)))), - maxFee: 0, - origin: ORIGIN, - destinations: new uint32[](1), - nonce: 1, - timestamp: uint48(block.timestamp), - ttl: 0, - amount: 100e18, - data: abi.encode(RECIPIENT, 100e18) - }); - intent.destinations[0] = DESTINATION; - - bytes32 intentId = keccak256(abi.encode(intent)); - - // Mock the spoke to return ADDED status - vm.mockCall( - address(everclearAdapter.spoke()), - abi.encodeWithSelector(IEverclearSpoke.status.selector, intentId), - abi.encode(IEverclear.IntentStatus.ADDED) - ); - - // Create a mock message with the intent in metadata - bytes memory metadata = abi.encode(intent); - bytes memory message = TokenMessage.format(RECIPIENT, 100e18, metadata); - - // Try to process an unsettled intent (should revert) - vm.prank(address(mailbox)); - vm.expectRevert("ETB: Intent Status != SETTLED"); - bridge.handle( - ORIGIN, - bytes32(uint256(uint160(address(bridge)))), - message - ); - - // Verify intent is not settled in our mapping - assertFalse(bridge.intentSettled(intentId)); - } } contract MockEverclearTokenBridge is EverclearTokenBridge { @@ -900,58 +752,31 @@ contract EverclearTokenBridgeForkTest is Test { // The bridge forwards all weth to the adapter, so the bridge balance should be the same assertEq(weth.balanceOf(address(bridge)), initialBridgeBalance); } +} - function testFork_receiveMessage(uint256 amount) public { - amount = bound(amount, 1, 100e6 ether); - uint depositAmount = amount + FEE_AMOUNT; - vm.deal(ALICE, depositAmount); - vm.prank(ALICE); - weth.deposit{value: depositAmount}(); - - uint256 initialBalance = weth.balanceOf(ALICE); - uint256 initialBridgeBalance = weth.balanceOf(address(bridge)); - - // Replace mailbox with code from MockMailbox - MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN); - vm.etch(address(bridge.mailbox()), address(_mailbox).code); - MockMailbox mailbox = MockMailbox(address(bridge.mailbox())); - mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox); - - // Test the transfer - vm.prank(ALICE); - - // Actually sending message to arbitrum - bridge.transferRemote(ARBITRUM_DOMAIN, RECIPIENT, amount); - - bytes memory intent = bridge.lastIntent(); - bytes32 intentId = keccak256(intent); - - // Settle the created intent via direct storage write - stdstore - .target(address(bridge.everclearSpoke())) - .sig(bridge.everclearSpoke().status.selector) - .with_key(intentId) - .checked_write(uint8(IEverclear.IntentStatus.SETTLED)); +contract MockEverclearEthBridge is EverclearEthBridge { + constructor( + IWETH _weth, + uint256 _scale, + address _mailbox, + IEverclearAdapter _everclearAdapter + ) EverclearEthBridge(_weth, _scale, _mailbox, _everclearAdapter) {} - assertEq( - uint(bridge.everclearSpoke().status(intentId)), - uint(IEverclear.IntentStatus.SETTLED) + bytes public lastIntent; + function _createIntent( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal override returns (IEverclear.Intent memory) { + IEverclear.Intent memory intent = super._createIntent( + _destination, + _recipient, + _amount ); - - // Give the bridge some WETH - vm.deal(address(bridge), amount); - vm.prank(address(bridge)); - weth.deposit{value: amount}(); - - // Process the hyperlane message -> call handle directly - // Deliver the message to the recipient. - mailbox.processNextInboundMessage(); - - // Funds should be sent to actual recipient - assertEq(weth.balanceOf(BOB), amount); + lastIntent = abi.encode(intent); + return intent; } } - /** * @notice Fork test contract for EverclearEthBridge on Arbitrum * @dev Tests the ETH bridge using real Arbitrum state and contracts with ETH transfers to Optimism @@ -961,16 +786,17 @@ contract EverclearTokenBridgeForkTest is Test { */ contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { using TypeCasts for address; + using stdStorage for StdStorage; // ETH bridge contract - EverclearEthBridge internal ethBridge; + MockEverclearEthBridge internal ethBridge; function setUp() public override { // Call parent setUp to initialize fork and all base contracts super.setUp(); // Deploy ETH bridge implementation - EverclearEthBridge implementation = new EverclearEthBridge( + MockEverclearEthBridge implementation = new MockEverclearEthBridge( IWETH(ARBITRUM_WETH), 1, address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox @@ -987,7 +813,7 @@ contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { ) ); - ethBridge = EverclearEthBridge(payable(address(proxy))); + ethBridge = MockEverclearEthBridge(payable(address(proxy))); // Configure the ETH bridge using existing fee params and signature vm.startPrank(OWNER); @@ -999,6 +825,20 @@ contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { }) ); ethBridge.enrollRemoteRouter(OPTIMISM_DOMAIN, RECIPIENT); + + // Handle ARB-ARB transfers as well + ethBridge.setOutputAsset( + OutputAssetInfo({ + destination: ARBITRUM_DOMAIN, + outputAsset: bytes32(uint256(uint160(ARBITRUM_WETH))) + }) + ); + ethBridge.enrollRemoteRouter( + ARBITRUM_DOMAIN, + address(ethBridge).addressToBytes32() + ); + // We will be the ism for this bridge + ethBridge.setInterchainSecurityModule(address(this)); vm.stopPrank(); } @@ -1080,4 +920,249 @@ contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { ); assertEq(address(newBridge.token()), address(weth)); } + + function testFork_receiveMessage(uint256 amount) public { + amount = bound(amount, 1, 100e6 ether); + uint depositAmount = amount + FEE_AMOUNT; + vm.deal(ALICE, depositAmount); + + // Replace mailbox with code from MockMailbox + MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN); + vm.etch(address(ethBridge.mailbox()), address(_mailbox).code); + MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox())); + mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox); + + // Actually sending message to arbitrum + vm.prank(ALICE); + ethBridge.transferRemote{value: depositAmount}( + ARBITRUM_DOMAIN, + RECIPIENT, + amount + ); + + bytes32 intentId = keccak256(ethBridge.lastIntent()); + + // Settle the created intent via direct storage write + stdstore + .target(address(ethBridge.everclearSpoke())) + .sig(ethBridge.everclearSpoke().status.selector) + .with_key(intentId) + .checked_write(uint8(IEverclear.IntentStatus.SETTLED)); + + assertEq( + uint(ethBridge.everclearSpoke().status(intentId)), + uint(IEverclear.IntentStatus.SETTLED) + ); + + // Give the bridge some WETH + vm.deal(address(ethBridge), amount); + vm.prank(address(ethBridge)); + weth.deposit{value: amount}(); + + // Process the hyperlane message -> call handle directly + // Deliver the message to the recipient. + mailbox.processNextInboundMessage(); + + // Funds should be sent to actual recipient + assertEq(BOB.balance, amount); + } + + // ============ intentSettled Mapping Tests ============ + + function testIntentSettledInitiallyFalse() public { + uint256 amount = 1e18; // 1 ETH + uint256 totalAmount = amount + FEE_AMOUNT; + + // Fund Alice and perform a transfer to generate an intent + vm.deal(ALICE, totalAmount); + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + + // Get the intent ID from the last created intent + bytes32 intentId = keccak256(ethBridge.lastIntent()); + + // Verify intent is not initially settled in our bridge + assertFalse(ethBridge.intentSettled(intentId)); + } + + function testIntentSettledAfterProcessing() public { + uint256 amount = 1e18; // 1 ETH + uint256 totalAmount = amount + FEE_AMOUNT; + vm.deal(ALICE, totalAmount); + + // Setup mock mailbox for message processing + MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN); + vm.etch(address(ethBridge.mailbox()), address(_mailbox).code); + MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox())); + mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox); + + // Perform transfer to create intent + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + ARBITRUM_DOMAIN, + RECIPIENT, + amount + ); + + bytes32 intentId = keccak256(ethBridge.lastIntent()); + + // Initially should not be settled in our bridge + assertFalse(ethBridge.intentSettled(intentId)); + + // Settle the intent in Everclear spoke via storage manipulation + stdstore + .target(address(ethBridge.everclearSpoke())) + .sig(ethBridge.everclearSpoke().status.selector) + .with_key(intentId) + .checked_write(uint8(IEverclear.IntentStatus.SETTLED)); + + // Give the bridge some WETH to process the intent + vm.deal(address(ethBridge), amount); + vm.prank(address(ethBridge)); + weth.deposit{value: amount}(); + + // Process the hyperlane message + mailbox.processNextInboundMessage(); + + // After processing, intent should be marked as settled in our bridge + assertTrue(ethBridge.intentSettled(intentId)); + } + + function testIntentSettledPreventsDuplicateProcessing() public { + uint amount = 1e18; + testFork_receiveMessage(amount); + // Try to process the same intent again - should fail because intent is already settled in our bridge + MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox())); + bytes32 _recipient = address(ethBridge).addressToBytes32(); + bytes memory _message = TokenMessage.format( + _recipient, + amount, + ethBridge.lastIntent() + ); + bytes memory message = mailbox.buildMessage( + address(ethBridge), + ARBITRUM_DOMAIN, + _recipient, + _message + ); + + mailbox.addInboundMessage(message); + vm.expectRevert("ETB: Intent already processed"); + mailbox.processNextInboundMessage(); + } + + function testFuzzIntentSettledWithVariousAmounts(uint256 amount) public { + amount = bound(amount, 1e15, 10e18); // 0.001 ETH to 10 ETH + uint256 totalAmount = amount + FEE_AMOUNT; + + vm.deal(ALICE, totalAmount); + + // Perform transfer + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + + // Get intent ID and verify initially not settled + bytes32 intentId = keccak256(ethBridge.lastIntent()); + assertFalse(ethBridge.intentSettled(intentId)); + + // Verify intent ID is deterministic for same parameters + vm.deal(ALICE, totalAmount); + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + + bytes32 secondIntentId = keccak256(ethBridge.lastIntent()); + // Different intents should have different IDs (due to nonce/timestamp differences) + assertTrue(intentId != secondIntentId); + assertFalse(ethBridge.intentSettled(secondIntentId)); + } + + function testIntentSettledWithDifferentDestinations() public { + uint256 amount = 1e18; + uint256 totalAmount = amount + FEE_AMOUNT; + + // Configure bridge for Optimism transfers + vm.prank(OWNER); + ethBridge.enrollRemoteRouter(OPTIMISM_DOMAIN, RECIPIENT); + + vm.deal(ALICE, totalAmount * 2); + + // Transfer to Arbitrum + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + ARBITRUM_DOMAIN, + RECIPIENT, + amount + ); + bytes32 arbitrumIntentId = keccak256(ethBridge.lastIntent()); + + // Transfer to Optimism + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + bytes32 optimismIntentId = keccak256(ethBridge.lastIntent()); + + // Both should be initially not settled and have different IDs + assertFalse(ethBridge.intentSettled(arbitrumIntentId)); + assertFalse(ethBridge.intentSettled(optimismIntentId)); + assertTrue(arbitrumIntentId != optimismIntentId); + } + + function testIntentSettledStatusChecking() public { + uint256 amount = 1e18; + uint256 totalAmount = amount + FEE_AMOUNT; + vm.deal(ALICE, totalAmount); + + // Setup mock mailbox + MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN); + vm.etch(address(ethBridge.mailbox()), address(_mailbox).code); + MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox())); + mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox); + + // Create intent + vm.prank(ALICE); + ethBridge.transferRemote{value: totalAmount}( + ARBITRUM_DOMAIN, + RECIPIENT, + amount + ); + + bytes32 intentId = keccak256(ethBridge.lastIntent()); + + // Try to process without settling in Everclear first - should fail + vm.deal(address(ethBridge), amount); + vm.prank(address(ethBridge)); + weth.deposit{value: amount}(); + + vm.expectRevert("ETB: Intent Status != SETTLED"); + mailbox.processNextInboundMessage(); + + // Verify still not settled in our bridge + assertFalse(ethBridge.intentSettled(intentId)); + + // Now settle in Everclear spoke + stdstore + .target(address(ethBridge.everclearSpoke())) + .sig(ethBridge.everclearSpoke().status.selector) + .with_key(intentId) + .checked_write(uint8(IEverclear.IntentStatus.SETTLED)); + + // Now processing should succeed + mailbox.processNextInboundMessage(); + assertTrue(ethBridge.intentSettled(intentId)); + } } From 53a268d6cfa30c0f91f5dc6fda3abc3c20f4324b Mon Sep 17 00:00:00 2001 From: larryob Date: Tue, 30 Sep 2025 15:21:44 -0400 Subject: [PATCH 18/36] fix: Use per destination fees for Everclear bridge (#7091) --- .../token/bridge/EverclearEthBridge.sol | 4 +- .../token/bridge/EverclearTokenBridge.sol | 49 +++++-- solidity/foundry.toml | 1 - solidity/script/EverclearTokenBridge.s.sol | 3 +- .../test/token/EverclearTokenBridge.t.sol | 130 +++++++++--------- 5 files changed, 105 insertions(+), 82 deletions(-) diff --git a/solidity/contracts/token/bridge/EverclearEthBridge.sol b/solidity/contracts/token/bridge/EverclearEthBridge.sol index a02d1cf0a9..ad9a7c3881 100644 --- a/solidity/contracts/token/bridge/EverclearEthBridge.sol +++ b/solidity/contracts/token/bridge/EverclearEthBridge.sol @@ -74,7 +74,7 @@ contract EverclearEthBridge is EverclearTokenBridge { quotes[0] = Quote({ token: address(0), amount: _amount + - feeParams.fee + + feeParams[_destination].fee + _quoteGasPayment(_destination, _recipient, _amount) }); } @@ -124,7 +124,7 @@ contract EverclearEthBridge is EverclearTokenBridge { ) internal virtual override returns (uint256 dispatchValue) { uint256 fee = _feeAmount(_destination, _recipient, _amount); - uint256 totalAmount = _amount + fee + feeParams.fee; + uint256 totalAmount = _amount + fee + feeParams[_destination].fee; _transferFromSender(totalAmount); dispatchValue = msg.value - totalAmount; if (fee > 0) { diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index 62e1f14125..f5c5182df1 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -34,9 +34,19 @@ contract EverclearTokenBridge is HypERC20Collateral { using TypeCasts for bytes32; using SafeERC20 for IERC20; + /// @notice Parameters for creating an Everclear intent + /// @dev This is used to avoid stack too deep errors + struct IntentParams { + bytes32 receiver; + address inputAsset; + bytes32 outputAsset; + uint256 amount; + IEverclearAdapter.FeeParams feeParams; + } + /// @notice The output asset for a given destination domain /// @dev Everclear needs to know the output asset address to create intents for cross-chain transfers - mapping(uint32 destination => bytes32 outputAssets) public outputAssets; + mapping(uint32 destination => bytes32 outputAsset) public outputAssets; /// @notice Whether an intent has been settled /// @dev This is used to prevent funds from being sent to a recipient that has already received them @@ -44,7 +54,8 @@ contract EverclearTokenBridge is HypERC20Collateral { /// @notice Fee parameters for the bridge operations /// @dev The signatures are produced by Everclear and stored here for re-use. We use the same fee for all transfers to all destinations - IEverclearAdapter.FeeParams public feeParams; + mapping(uint32 destination => IEverclearAdapter.FeeParams feeParams) + public feeParams; /// @notice The Everclear adapter contract interface /// @dev Immutable reference to the Everclear adapter used for creating intents @@ -58,7 +69,7 @@ contract EverclearTokenBridge is HypERC20Collateral { * @param fee The new fee amount * @param deadline The new deadline timestamp for fee validity */ - event FeeParamsUpdated(uint256 fee, uint256 deadline); + event FeeParamsUpdated(uint32 destination, uint256 fee, uint256 deadline); /** * @notice Emitted when an output asset is configured for a destination @@ -103,16 +114,17 @@ contract EverclearTokenBridge is HypERC20Collateral { * @param _sig The signature for fee validation from Everclear */ function setFeeParams( + uint32 _destination, uint256 _fee, uint256 _deadline, bytes calldata _sig ) external onlyOwner { - feeParams = IEverclearAdapter.FeeParams({ + feeParams[_destination] = IEverclearAdapter.FeeParams({ fee: _fee, deadline: _deadline, sig: _sig }); - emit FeeParamsUpdated(_fee, _deadline); + emit FeeParamsUpdated(_destination, _fee, _deadline); } /** @@ -179,7 +191,7 @@ contract EverclearTokenBridge is HypERC20Collateral { }); quotes[1] = Quote({ token: address(wrappedToken), - amount: _amount + feeParams.fee + amount: _amount + feeParams[_destination].fee }); } @@ -200,22 +212,35 @@ contract EverclearTokenBridge is HypERC20Collateral { outputAssets[_destination] != bytes32(0), "ETB: Output asset not set" ); + require( + feeParams[_destination].sig.length > 0, + "ETB: Fee params not set" + ); // Create everclear intent uint32[] memory destinations = new uint32[](1); destinations[0] = _destination; // Create intent + // Packing the intent params in a struct to avoid stack too deep errors + IntentParams memory intentParams = IntentParams({ + feeParams: feeParams[_destination], + receiver: _getReceiver(_destination, _recipient), + inputAsset: address(wrappedToken), + outputAsset: outputAssets[_destination], + amount: _amount + }); + (, IEverclear.Intent memory intent) = everclearAdapter.newIntent({ _destinations: destinations, - _receiver: _getReceiver(_destination, _recipient), - _inputAsset: address(wrappedToken), - _outputAsset: outputAssets[_destination], // We load this from storage again to avoid stack too deep - _amount: _amount, + _receiver: intentParams.receiver, + _inputAsset: intentParams.inputAsset, + _outputAsset: intentParams.outputAsset, + _amount: intentParams.amount, _maxFee: 0, _ttl: 0, _data: "", - _feeParams: feeParams + _feeParams: intentParams.feeParams }); return intent; @@ -253,7 +278,7 @@ contract EverclearTokenBridge is HypERC20Collateral { super._chargeSender( _destination, _recipient, - _amount + feeParams.fee + _amount + feeParams[_destination].fee ); } diff --git a/solidity/foundry.toml b/solidity/foundry.toml index 711987773e..29435ee1d3 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -8,7 +8,6 @@ cache_path = 'forge-cache' allow_paths = ["../node_modules"] solc_version = '0.8.22' evm_version= 'paris' -# optimizer = true optimizer_runs = 999_999 fs_permissions = [ { access = "read", path = "./script/avs/"}, diff --git a/solidity/script/EverclearTokenBridge.s.sol b/solidity/script/EverclearTokenBridge.s.sol index ee5271edb3..610a15e662 100644 --- a/solidity/script/EverclearTokenBridge.s.sol +++ b/solidity/script/EverclearTokenBridge.s.sol @@ -38,6 +38,7 @@ contract EverclearTokenBridgeScript is Script { // Set the fee params for the bridge. bridge.setFeeParams( + 10, // destination domain 1000000000000, 1751851366, hex"4edddfdeabc459e3e9df4bc6807698e26443a663b3905c9b5d0f1054b4831b4616e89ff702f57e13d650331f11986ebe925ce497621b7f488c4672189b49b8e11c" @@ -50,7 +51,7 @@ contract EverclearTokenBridgeScript is Script { EverclearTokenBridge bridge = _getBridge(); // Convert some eth to weth - (uint256 fee, , ) = bridge.feeParams(); + (uint256 fee, , ) = bridge.feeParams(10); // destination domain 10 (Optimism) uint256 amount = 0.0001 ether; uint256 totalAmount = amount + fee + 1; IWETH weth = IWETH(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1); diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 0baaeba180..8bb3073494 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -162,7 +162,7 @@ contract EverclearTokenBridgeTest is Test { bytes internal feeSignature = hex"1234567890abcdef"; // Events to test - event FeeParamsUpdated(uint256 fee, uint256 deadline); + event FeeParamsUpdated(uint32 destination, uint256 fee, uint256 deadline); event OutputAssetSet(uint32 destination, bytes32 outputAsset); function setUp() public { @@ -195,7 +195,7 @@ contract EverclearTokenBridgeTest is Test { bridge = EverclearTokenBridge(address(proxy)); // Setup initial state vm.startPrank(OWNER); - bridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + bridge.setFeeParams(DESTINATION, FEE_AMOUNT, feeDeadline, feeSignature); bridge.setOutputAsset( OutputAssetInfo({ destination: DESTINATION, @@ -255,12 +255,14 @@ contract EverclearTokenBridgeTest is Test { bytes memory newSig = hex"abcdef"; vm.expectEmit(true, true, false, true); - emit FeeParamsUpdated(newFee, newDeadline); + emit FeeParamsUpdated(DESTINATION, newFee, newDeadline); vm.prank(OWNER); - bridge.setFeeParams(newFee, newDeadline, newSig); + bridge.setFeeParams(DESTINATION, newFee, newDeadline, newSig); - (uint256 fee, uint256 deadline, bytes memory sig) = bridge.feeParams(); + (uint256 fee, uint256 deadline, bytes memory sig) = bridge.feeParams( + DESTINATION + ); assertEq(fee, newFee); assertEq(deadline, newDeadline); assertEq(sig, newSig); @@ -269,7 +271,7 @@ contract EverclearTokenBridgeTest is Test { function testSetFeeParamsOnlyOwner() public { vm.expectRevert("Ownable: caller is not the owner"); vm.prank(ALICE); - bridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + bridge.setFeeParams(DESTINATION, FEE_AMOUNT, feeDeadline, feeSignature); } // ============ setOutputAsset Tests ============ @@ -474,9 +476,11 @@ contract EverclearTokenBridgeTest is Test { ); vm.prank(OWNER); - bridge.setFeeParams(fee, deadline, feeSignature); + bridge.setFeeParams(DESTINATION, fee, deadline, feeSignature); - (uint256 storedFee, uint256 storedDeadline, ) = bridge.feeParams(); + (uint256 storedFee, uint256 storedDeadline, ) = bridge.feeParams( + DESTINATION + ); assertEq(storedFee, fee); assertEq(storedDeadline, deadline); } @@ -582,16 +586,9 @@ contract MockEverclearTokenBridge is EverclearTokenBridge { } } -/** - * @notice Fork test contract for EverclearTokenBridge on Arbitrum - * @dev Tests the bridge using real Arbitrum state and contracts with WETH transfers to Optimism - * @dev We're running the cancun evm version, to avoid `NotActivated` errors - * forge-config: default.evm_version = "cancun" - */ -contract EverclearTokenBridgeForkTest is Test { +contract BaseEverclearTokenBridgeForkTest is Test { using TypeCasts for *; using Message for bytes; - using stdStorage for StdStorage; // Arbitrum mainnet constants uint32 internal constant ARBITRUM_DOMAIN = 42161; @@ -619,7 +616,7 @@ contract EverclearTokenBridgeForkTest is Test { // Contracts IWETH internal weth; IEverclearAdapter internal everclearAdapter; - MockEverclearTokenBridge internal bridge; + EverclearTokenBridge internal bridge; // Test data bytes32 internal constant OUTPUT_ASSET = @@ -636,25 +633,13 @@ contract EverclearTokenBridgeForkTest is Test { return true; } - function setUp() public virtual { - // Fork Arbitrum at the latest block - vm.createSelectFork("arbitrum"); - - weth = IWETH(ARBITRUM_WETH); - // Get real Everclear adapter - everclearAdapter = IEverclearAdapter(EVERCLEAR_ADAPTER); - - // Set fee deadline to future - feeDeadline = block.timestamp + 3600; // 1 hour from now - - // Deploy bridge implementation + function _deployBridge() internal virtual returns (address) { MockEverclearTokenBridge implementation = new MockEverclearTokenBridge( address(weth), 1, address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox everclearAdapter ); - // Deploy proxy TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(implementation), @@ -662,7 +647,22 @@ contract EverclearTokenBridgeForkTest is Test { abi.encodeCall(EverclearTokenBridge.initialize, (address(0), OWNER)) ); - bridge = MockEverclearTokenBridge(address(proxy)); + return address(proxy); + } + + function setUp() public virtual { + // Fork Arbitrum at the latest block + vm.createSelectFork("arbitrum"); + + weth = IWETH(ARBITRUM_WETH); + // Get real Everclear adapter + everclearAdapter = IEverclearAdapter(EVERCLEAR_ADAPTER); + + // Set fee deadline to future + feeDeadline = block.timestamp + 3600; // 1 hour from now + + // Deploy bridge + bridge = EverclearTokenBridge(_deployBridge()); // It would be great if we could mock the ecrecover function to always return the fee signer for the adapter // but we can't do that with forge. So we're going to sign the fee params with the fee signer private key @@ -685,9 +685,16 @@ contract EverclearTokenBridgeForkTest is Test { ); feeSignature = abi.encodePacked(r, s, v); - // Configure the bridge + // Configure the bridge. We can send to both Optimism and Arbitrum. vm.startPrank(OWNER); - bridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); + + // Optimism + bridge.setFeeParams( + OPTIMISM_DOMAIN, + FEE_AMOUNT, + feeDeadline, + feeSignature + ); bridge.setOutputAsset( OutputAssetInfo({ destination: OPTIMISM_DOMAIN, @@ -699,7 +706,13 @@ contract EverclearTokenBridgeForkTest is Test { address(bridge).addressToBytes32() ); - // Handle ARB-ARB transfers as well + // Arbitrum + bridge.setFeeParams( + ARBITRUM_DOMAIN, + FEE_AMOUNT, + feeDeadline, + feeSignature + ); bridge.setOutputAsset( OutputAssetInfo({ destination: ARBITRUM_DOMAIN, @@ -718,6 +731,16 @@ contract EverclearTokenBridgeForkTest is Test { vm.prank(ALICE); weth.approve(address(bridge), type(uint256).max); } +} + +/** + * @notice Fork test contract for EverclearTokenBridge on Arbitrum + * @dev Tests the bridge using real Arbitrum state and contracts with WETH transfers to Optimism + * @dev We're running the cancun evm version, to avoid `NotActivated` errors + * forge-config: default.evm_version = "cancun" + */ +contract EverclearTokenBridgeForkTest is BaseEverclearTokenBridgeForkTest { + using TypeCasts for *; function testFuzz_ForkTransferRemote(uint256 amount) public { // Fund Alice with WETH by wrapping ETH @@ -784,17 +807,14 @@ contract MockEverclearEthBridge is EverclearEthBridge { * @dev We're running the cancun evm version, to avoid `NotActivated` errors * forge-config: default.evm_version = "cancun" */ -contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { +contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { using TypeCasts for address; using stdStorage for StdStorage; // ETH bridge contract MockEverclearEthBridge internal ethBridge; - function setUp() public override { - // Call parent setUp to initialize fork and all base contracts - super.setUp(); - + function _deployBridge() internal override returns (address) { // Deploy ETH bridge implementation MockEverclearEthBridge implementation = new MockEverclearEthBridge( IWETH(ARBITRUM_WETH), @@ -812,34 +832,12 @@ contract EverclearEthBridgeForkTest is EverclearTokenBridgeForkTest { (address(new TestPostDispatchHook()), OWNER) ) ); + return address(proxy); + } - ethBridge = MockEverclearEthBridge(payable(address(proxy))); - - // Configure the ETH bridge using existing fee params and signature - vm.startPrank(OWNER); - ethBridge.setFeeParams(FEE_AMOUNT, feeDeadline, feeSignature); - ethBridge.setOutputAsset( - OutputAssetInfo({ - destination: OPTIMISM_DOMAIN, - outputAsset: OUTPUT_ASSET - }) - ); - ethBridge.enrollRemoteRouter(OPTIMISM_DOMAIN, RECIPIENT); - - // Handle ARB-ARB transfers as well - ethBridge.setOutputAsset( - OutputAssetInfo({ - destination: ARBITRUM_DOMAIN, - outputAsset: bytes32(uint256(uint160(ARBITRUM_WETH))) - }) - ); - ethBridge.enrollRemoteRouter( - ARBITRUM_DOMAIN, - address(ethBridge).addressToBytes32() - ); - // We will be the ism for this bridge - ethBridge.setInterchainSecurityModule(address(this)); - vm.stopPrank(); + function setUp() public override { + super.setUp(); + ethBridge = MockEverclearEthBridge(payable(address(bridge))); } function testFuzz_EthBridgeTransferRemote(uint256 amount) public { From d33495a0a25e357c3f25698e8c97f07db2a41750 Mon Sep 17 00:00:00 2001 From: larryob Date: Tue, 30 Sep 2025 16:41:11 -0400 Subject: [PATCH 19/36] fix: Use intent data for validation when sending ETH (#7121) --- .../token/bridge/EverclearEthBridge.sol | 48 +++++++++ .../token/bridge/EverclearTokenBridge.sol | 99 ++++++++++++++----- 2 files changed, 120 insertions(+), 27 deletions(-) diff --git a/solidity/contracts/token/bridge/EverclearEthBridge.sol b/solidity/contracts/token/bridge/EverclearEthBridge.sol index ad9a7c3881..72ed3f299e 100644 --- a/solidity/contracts/token/bridge/EverclearEthBridge.sol +++ b/solidity/contracts/token/bridge/EverclearEthBridge.sol @@ -57,6 +57,20 @@ contract EverclearEthBridge is EverclearTokenBridge { return _mustHaveRemoteRouter(_destination); } + /** + * @notice Encodes the intent calldata for ETH transfers + * @dev Overrides parent to encode recipient and amount for ETH-specific intent validation + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of ETH to transfer + * @return The encoded calldata containing recipient and amount + */ + function _getIntentCalldata( + bytes32 _recipient, + uint256 _amount + ) internal pure override returns (bytes memory) { + return abi.encode(_recipient, _amount); + } + /** * @notice Provides a quote for transferring ETH to a remote chain * @dev Overrides parent to return a single quote for ETH (including transfer amount, fees, and gas) @@ -133,6 +147,40 @@ contract EverclearEthBridge is EverclearTokenBridge { return dispatchValue; } + /** + * @notice Validates the Everclear intent for ETH transfers + * @dev Overrides parent to add ETH-specific validation by checking intent data matches message + * @param _message The incoming message containing transfer details + * @return intentId The unique identifier for the validated intent + * @return intentBytes The encoded intent data + */ + function _validateIntent( + bytes calldata _message + ) internal view override returns (bytes32, bytes memory) { + (bytes32 intentId, bytes memory intentBytes) = super._validateIntent( + _message + ); + IEverclear.Intent memory intent = abi.decode( + intentBytes, + (IEverclear.Intent) + ); + (bytes32 _intentRecipient, uint256 _intentAmount) = abi.decode( + intent.data, + (bytes32, uint256) + ); + + require( + _intentRecipient == _message.recipient(), + "EEB: Intent recipient mismatch" + ); + require( + _intentAmount == _message.amount(), + "EEB: Intent amount mismatch" + ); + + return (intentId, intentBytes); + } + /** * @notice Allows the contract to receive ETH * @dev Required for WETH unwrapping functionality diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index f5c5182df1..2769df7cc6 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -34,8 +34,15 @@ contract EverclearTokenBridge is HypERC20Collateral { using TypeCasts for bytes32; using SafeERC20 for IERC20; - /// @notice Parameters for creating an Everclear intent - /// @dev This is used to avoid stack too deep errors + /** + * @notice Parameters for creating an Everclear intent + * @dev This struct is used to avoid stack too deep errors when creating intents + * @param receiver The address that will receive the tokens on the destination chain + * @param inputAsset The address of the input token on the source chain + * @param outputAsset The address of the output token on the destination chain + * @param amount The amount of tokens to transfer + * @param feeParams The fee parameters including fee amount, deadline, and signature + */ struct IntentParams { bytes32 receiver; address inputAsset; @@ -44,24 +51,35 @@ contract EverclearTokenBridge is HypERC20Collateral { IEverclearAdapter.FeeParams feeParams; } - /// @notice The output asset for a given destination domain - /// @dev Everclear needs to know the output asset address to create intents for cross-chain transfers + /** + * @notice The output asset for a given destination domain + * @dev Everclear needs to know the output asset address to create intents for cross-chain transfers + */ mapping(uint32 destination => bytes32 outputAsset) public outputAssets; - /// @notice Whether an intent has been settled - /// @dev This is used to prevent funds from being sent to a recipient that has already received them + /** + * @notice Whether an intent has been settled + * @dev This mapping prevents double-spending by tracking which intents have already been processed + */ mapping(bytes32 intentId => bool isSettled) public intentSettled; - /// @notice Fee parameters for the bridge operations - /// @dev The signatures are produced by Everclear and stored here for re-use. We use the same fee for all transfers to all destinations + /** + * @notice Fee parameters for bridge operations on each destination domain + * @dev Contains fee amount, deadline, and signature from Everclear for fee validation + */ mapping(uint32 destination => IEverclearAdapter.FeeParams feeParams) public feeParams; - /// @notice The Everclear adapter contract interface - /// @dev Immutable reference to the Everclear adapter used for creating intents + /** + * @notice The Everclear adapter contract interface + * @dev Immutable reference to the Everclear adapter used for creating and managing intents + */ IEverclearAdapter public immutable everclearAdapter; - /// @notice The Everclear spoke contract + /** + * @notice The Everclear spoke contract interface + * @dev Immutable reference used for checking intent status and settlement + */ IEverclearSpoke public immutable everclearSpoke; /** @@ -195,6 +213,20 @@ contract EverclearTokenBridge is HypERC20Collateral { }); } + /** + * @notice Encodes the intent calldata for token transfers + * @dev Virtual function that can be overridden by derived contracts to include custom data + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of tokens to transfer + * @return The encoded calldata (empty in base implementation) + */ + function _getIntentCalldata( + bytes32 _recipient, + uint256 _amount + ) internal pure virtual returns (bytes memory) { + return ""; + } + /** * @notice Creates an Everclear intent for cross-chain token transfer * @dev Internal function to handle intent creation with Everclear adapter @@ -239,7 +271,7 @@ contract EverclearTokenBridge is HypERC20Collateral { _amount: intentParams.amount, _maxFee: 0, _ttl: 0, - _data: "", + _data: _getIntentCalldata(_recipient, _amount), _feeParams: intentParams.feeParams }); @@ -318,9 +350,10 @@ contract EverclearTokenBridge is HypERC20Collateral { } /** - * @dev No-op, the funds are transferred directly to `_recipient` via Everclear - * @param _recipient The address to receive the tokens - * @param _amount The amount of tokens to transfer + * @notice Transfers tokens to the recipient (no-op in Everclear bridge) + * @dev No-op implementation since funds are transferred directly to recipient via Everclear's intent system + * @param _recipient The address to receive the tokens (unused) + * @param _amount The amount of tokens to transfer (unused) */ function _transferTo( address _recipient, @@ -330,20 +363,17 @@ contract EverclearTokenBridge is HypERC20Collateral { } /** - * @notice Handles incoming messages from remote chains - * @dev For the base token bridge, this is a no-op since funds are transferred via Everclear - * @param _origin The origin domain ID where the message was sent from - * @param _message The message payload (unused in base implementation) + * @notice Validates the Everclear intent associated with an incoming message + * @dev Checks that the intent is settled on Everclear and hasn't been processed before + * @param _message The incoming message containing intent metadata + * @return intentId The unique identifier for the validated intent + * @return intentBytes The encoded intent data from the message metadata */ - function _handle( - uint32 _origin, - bytes32 _sender, + function _validateIntent( bytes calldata _message - ) internal virtual override { - // Get intent from hyperlane message - bytes memory metadata = _message.metadata(); - bytes32 intentId = keccak256(metadata); - + ) internal view virtual returns (bytes32, bytes memory) { + bytes memory intentBytes = _message.metadata(); + bytes32 intentId = keccak256(intentBytes); // Check Everclear intent status require( everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED, @@ -351,7 +381,22 @@ contract EverclearTokenBridge is HypERC20Collateral { ); // Check that we have not processed this intent before require(!intentSettled[intentId], "ETB: Intent already processed"); + return (intentId, intentBytes); + } + /** + * @notice Handles incoming messages from remote chains + * @dev Validates the Everclear intent, marks it as settled, and delegates to parent handler + * @param _origin The origin domain ID where the message was sent from + * @param _sender The address of the sender on the origin chain + * @param _message The message payload containing transfer details and intent metadata + */ + function _handle( + uint32 _origin, + bytes32 _sender, + bytes calldata _message + ) internal override { + (bytes32 intentId, ) = _validateIntent(_message); intentSettled[intentId] = true; super._handle(_origin, _sender, _message); } From 18c32ed2b608bfb02183b5028e2406c5153a3754 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 30 Sep 2025 18:07:41 -0400 Subject: [PATCH 20/36] chore: refactor warp route inheritance tree (#7064) Co-authored-by: nambrot --- .changeset/big-papayas-grow.md | 15 + .changeset/mean-pigs-check.md | 5 + .changeset/sharp-clouds-pay.md | 6 + solidity/contracts/client/GasRouter.sol | 38 +- solidity/contracts/client/Router.sol | 43 +- .../middleware/InterchainQueryRouter.sol | 13 +- solidity/contracts/test/TestGasRouter.sol | 21 +- .../contracts/test/TestLpCollateralRouter.sol | 4 +- solidity/contracts/test/TestLpTokenRouter.sol | 25 ++ solidity/contracts/test/TestRouter.sol | 2 +- solidity/contracts/token/HypERC20.sol | 15 +- .../contracts/token/HypERC20Collateral.sol | 19 +- solidity/contracts/token/HypERC721.sol | 19 +- .../contracts/token/HypERC721Collateral.sol | 29 +- solidity/contracts/token/HypNative.sol | 59 +-- solidity/contracts/token/README.md | 58 ++- .../contracts/token/TokenBridgeCctpBase.sol | 73 +++- .../contracts/token/TokenBridgeCctpV1.sol | 33 +- .../contracts/token/TokenBridgeCctpV2.sol | 54 ++- .../token/bridge/EverclearEthBridge.sol | 194 --------- .../token/bridge/EverclearTokenBridge.sol | 371 +++++++++++------ .../contracts/token/extensions/HypERC4626.sol | 8 +- .../token/extensions/HypERC4626Collateral.sol | 118 ++++-- .../extensions/HypERC4626OwnerCollateral.sol | 10 +- .../extensions/HypERC721URICollateral.sol | 34 +- .../token/extensions/HypERC721URIStorage.sol | 31 +- .../token/extensions/HypFiatToken.sol | 44 +- .../contracts/token/extensions/HypXERC20.sol | 37 +- .../token/extensions/HypXERC20Lockbox.sol | 43 +- .../extensions/OPL2ToL1TokenBridgeNative.sol | 209 ++++++---- .../token/libs/FungibleTokenRouter.sol | 149 ------- .../token/libs/LpCollateralRouter.sol | 26 +- .../token/libs/MovableCollateralRouter.sol | 106 ++--- solidity/contracts/token/libs/Quotes.sol | 18 + .../contracts/token/libs/TokenCollateral.sol | 79 ++++ solidity/contracts/token/libs/TokenRouter.sol | 376 +++++++++++++----- solidity/script/xerc20/ezETH.s.sol | 3 +- solidity/test/GasRouter.t.sol | 12 +- .../test/token/EverclearTokenBridge.t.sol | 32 +- solidity/test/token/HypERC20.t.sol | 80 +--- .../token/HypERC20MovableCollateral.t.sol | 13 +- solidity/test/token/HypERC4626Test.t.sol | 48 +-- solidity/test/token/HypnativeMovable.t.sol | 28 +- .../test/token/MovableCollateralRouter.t.sol | 102 +++-- .../token/OPL2ToL1TokenBridgeNative.t.sol | 41 +- solidity/test/token/TokenBridgeCctp.t.sol | 138 ++++++- .../cli/src/rebalancer/core/Rebalancer.ts | 6 +- .../src/rebalancer/interfaces/IRebalancer.ts | 4 +- .../helloworld/contracts/HelloWorld.sol | 4 +- typescript/sdk/src/index.ts | 1 + .../token/EvmERC20WarpModule.hardhat-test.ts | 12 - typescript/sdk/src/token/Token.ts | 4 +- .../sdk/src/token/adapters/EvmTokenAdapter.ts | 43 +- typescript/sdk/src/token/config.ts | 4 +- 54 files changed, 1690 insertions(+), 1269 deletions(-) create mode 100644 .changeset/big-papayas-grow.md create mode 100644 .changeset/mean-pigs-check.md create mode 100644 .changeset/sharp-clouds-pay.md create mode 100644 solidity/contracts/test/TestLpTokenRouter.sol delete mode 100644 solidity/contracts/token/bridge/EverclearEthBridge.sol delete mode 100644 solidity/contracts/token/libs/FungibleTokenRouter.sol create mode 100644 solidity/contracts/token/libs/Quotes.sol create mode 100644 solidity/contracts/token/libs/TokenCollateral.sol diff --git a/.changeset/big-papayas-grow.md b/.changeset/big-papayas-grow.md new file mode 100644 index 0000000000..4d42e2f987 --- /dev/null +++ b/.changeset/big-papayas-grow.md @@ -0,0 +1,15 @@ +--- +"@hyperlane-xyz/core": major +--- + +Refactor warp route contracts for shallower inheritance tree and smaller bytecode size. + +Deprecated `Router` and `GasRouter` internal functions have been removed. + +`FungibleTokenRouter` has been removed and functionality lifted into `TokenRouter`. + +`quoteTransferRemote` and `transferRemote` can no longer be overriden with optional `hook` and `hookMetadata` for simplicity. + +`quoteTransferRemote` returns a consistent shape of `[nativeMailboxDispatchFee, internalTokenFee, externalTokenFee]`. + +`HypNative` and `HypERC20Collateral` inherit from `MovableCollateral` and `LpCollateral` but other extensions (eg `HypXERC20`) do not. Storage layouts have been preserved to ensure upgrade compatibility. diff --git a/.changeset/mean-pigs-check.md b/.changeset/mean-pigs-check.md new file mode 100644 index 0000000000..88f98ca8f8 --- /dev/null +++ b/.changeset/mean-pigs-check.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/helloworld": patch +--- + +Update HelloWorld to use new Router utils diff --git a/.changeset/sharp-clouds-pay.md b/.changeset/sharp-clouds-pay.md new file mode 100644 index 0000000000..457c99e354 --- /dev/null +++ b/.changeset/sharp-clouds-pay.md @@ -0,0 +1,6 @@ +--- +"@hyperlane-xyz/sdk": minor +"@hyperlane-xyz/cli": patch +--- + +Decouple movable collateral and hyp collateral token adapters diff --git a/solidity/contracts/client/GasRouter.sol b/solidity/contracts/client/GasRouter.sol index 0587b7c424..65cc748517 100644 --- a/solidity/contracts/client/GasRouter.sol +++ b/solidity/contracts/client/GasRouter.sol @@ -59,7 +59,13 @@ abstract contract GasRouter is Router { function quoteGasPayment( uint32 _destinationDomain ) public view virtual returns (uint256) { - return _GasRouter_quoteDispatch(_destinationDomain, "", address(hook)); + return + _Router_quoteDispatch( + _destinationDomain, + "", + _GasRouter_hookMetadata(_destinationDomain), + address(hook) + ); } function _GasRouter_hookMetadata( @@ -73,34 +79,4 @@ abstract contract GasRouter is Router { destinationGas[domain] = gas; emit GasSet(domain, gas); } - - function _GasRouter_dispatch( - uint32 _destination, - uint256 _value, - bytes memory _messageBody, - address _hook - ) internal returns (bytes32) { - return - _Router_dispatch( - _destination, - _value, - _messageBody, - _GasRouter_hookMetadata(_destination), - _hook - ); - } - - function _GasRouter_quoteDispatch( - uint32 _destination, - bytes memory _messageBody, - address _hook - ) internal view returns (uint256) { - return - _Router_quoteDispatch( - _destination, - _messageBody, - _GasRouter_hookMetadata(_destination), - _hook - ); - } } diff --git a/solidity/contracts/client/Router.sol b/solidity/contracts/client/Router.sol index 39dd5ea2a3..40f53b755c 100644 --- a/solidity/contracts/client/Router.sol +++ b/solidity/contracts/client/Router.sol @@ -169,6 +169,21 @@ abstract contract Router is MailboxClient, IMessageRecipient { ); } + function _Router_dispatch( + uint32 _destinationDomain, + uint256 _value, + bytes memory _messageBody + ) internal returns (bytes32) { + return + _Router_dispatch( + _destinationDomain, + _value, + _messageBody, + "", + address(hook) + ); + } + function _Router_dispatch( uint32 _destinationDomain, uint256 _value, @@ -187,18 +202,13 @@ abstract contract Router is MailboxClient, IMessageRecipient { ); } - /** - * DEPRECATED: Use `_Router_dispatch` instead - * @dev For backward compatibility with v2 client contracts - */ - function _dispatch( + function _Router_quoteDispatch( uint32 _destinationDomain, bytes memory _messageBody - ) internal returns (bytes32) { + ) internal view returns (uint256) { return - _Router_dispatch( + _Router_quoteDispatch( _destinationDomain, - msg.value, _messageBody, "", address(hook) @@ -221,21 +231,4 @@ abstract contract Router is MailboxClient, IMessageRecipient { IPostDispatchHook(_hook) ); } - - /** - * DEPRECATED: Use `_Router_quoteDispatch` instead - * @dev For backward compatibility with v2 client contracts - */ - function _quoteDispatch( - uint32 _destinationDomain, - bytes memory _messageBody - ) internal view returns (uint256) { - return - _Router_quoteDispatch( - _destinationDomain, - _messageBody, - "", - address(hook) - ); - } } diff --git a/solidity/contracts/middleware/InterchainQueryRouter.sol b/solidity/contracts/middleware/InterchainQueryRouter.sol index d212e38680..4fe06d82cd 100644 --- a/solidity/contracts/middleware/InterchainQueryRouter.sol +++ b/solidity/contracts/middleware/InterchainQueryRouter.sol @@ -69,11 +69,12 @@ contract InterchainQueryRouter is Router { address _to, bytes memory _data, bytes memory _callback - ) public returns (bytes32 messageId) { + ) public payable returns (bytes32 messageId) { emit QueryDispatched(_destination, msg.sender); - messageId = _dispatch( + messageId = _Router_dispatch( _destination, + msg.value, InterchainQueryMessage.encode( msg.sender.addressToBytes32(), _to, @@ -93,10 +94,11 @@ contract InterchainQueryRouter is Router { function query( uint32 _destination, CallLib.StaticCallWithCallback[] calldata calls - ) public returns (bytes32 messageId) { + ) public payable returns (bytes32 messageId) { emit QueryDispatched(_destination, msg.sender); - messageId = _dispatch( + messageId = _Router_dispatch( _destination, + msg.value, InterchainQueryMessage.encode(msg.sender.addressToBytes32(), calls) ); } @@ -121,8 +123,9 @@ contract InterchainQueryRouter is Router { callsWithCallback ); emit QueryExecuted(_origin, sender); - _dispatch( + _Router_dispatch( _origin, + msg.value, InterchainQueryMessage.encode(sender, callbacks) ); } else if (messageType == InterchainQueryMessage.MessageType.RESPONSE) { diff --git a/solidity/contracts/test/TestGasRouter.sol b/solidity/contracts/test/TestGasRouter.sol index b74bd6ac6b..d3874a3d77 100644 --- a/solidity/contracts/test/TestGasRouter.sol +++ b/solidity/contracts/test/TestGasRouter.sol @@ -7,7 +7,26 @@ contract TestGasRouter is GasRouter { constructor(address _mailbox) GasRouter(_mailbox) {} function dispatch(uint32 _destination, bytes memory _msg) external payable { - _GasRouter_dispatch(_destination, msg.value, _msg, address(hook)); + _Router_dispatch( + _destination, + msg.value, + _msg, + _GasRouter_hookMetadata(_destination), + address(hook) + ); + } + + function quoteDispatch( + uint32 _destination, + bytes memory _msg + ) external view returns (uint256) { + return + _Router_quoteDispatch( + _destination, + _msg, + _GasRouter_hookMetadata(_destination), + address(hook) + ); } function _handle(uint32, bytes32, bytes calldata) internal pure override {} diff --git a/solidity/contracts/test/TestLpCollateralRouter.sol b/solidity/contracts/test/TestLpCollateralRouter.sol index a6cfa3aa22..842e818dcc 100644 --- a/solidity/contracts/test/TestLpCollateralRouter.sol +++ b/solidity/contracts/test/TestLpCollateralRouter.sol @@ -2,13 +2,13 @@ pragma solidity >=0.8.0; import {LpCollateralRouter} from "../token/libs/LpCollateralRouter.sol"; -import {FungibleTokenRouter} from "../token/libs/FungibleTokenRouter.sol"; +import {TokenRouter} from "../token/libs/TokenRouter.sol"; contract TestLpCollateralRouter is LpCollateralRouter { constructor( uint256 _scale, address _mailbox - ) FungibleTokenRouter(_scale, _mailbox) initializer { + ) TokenRouter(_scale, _mailbox) initializer { _LpCollateralRouter_initialize(); } diff --git a/solidity/contracts/test/TestLpTokenRouter.sol b/solidity/contracts/test/TestLpTokenRouter.sol new file mode 100644 index 0000000000..842e818dcc --- /dev/null +++ b/solidity/contracts/test/TestLpTokenRouter.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {LpCollateralRouter} from "../token/libs/LpCollateralRouter.sol"; +import {TokenRouter} from "../token/libs/TokenRouter.sol"; + +contract TestLpCollateralRouter is LpCollateralRouter { + constructor( + uint256 _scale, + address _mailbox + ) TokenRouter(_scale, _mailbox) initializer { + _LpCollateralRouter_initialize(); + } + + function token() public view override returns (address) { + return address(0); + } + + function _transferFromSender(uint256 _amount) internal override {} + + function _transferTo( + address _recipient, + uint256 _amount + ) internal override {} +} diff --git a/solidity/contracts/test/TestRouter.sol b/solidity/contracts/test/TestRouter.sol index 9ded04b7fa..b55f61da66 100644 --- a/solidity/contracts/test/TestRouter.sol +++ b/solidity/contracts/test/TestRouter.sol @@ -31,6 +31,6 @@ contract TestRouter is Router { } function dispatch(uint32 _destination, bytes memory _msg) external payable { - _dispatch(_destination, _msg); + _Router_dispatch(_destination, msg.value, _msg); } } diff --git a/solidity/contracts/token/HypERC20.sol b/solidity/contracts/token/HypERC20.sol index a5ccd040ea..5c32015f72 100644 --- a/solidity/contracts/token/HypERC20.sol +++ b/solidity/contracts/token/HypERC20.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0; import {TokenRouter} from "./libs/TokenRouter.sol"; import {Quote} from "../interfaces/ITokenBridge.sol"; -import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; +import {TokenRouter} from "./libs/TokenRouter.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; @@ -12,14 +12,14 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ * @author Abacus Works * @dev Supply on each chain is not constant but the aggregate supply across all chains is. */ -contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter { +contract HypERC20 is ERC20Upgradeable, TokenRouter { uint8 private immutable _decimals; constructor( uint8 __decimals, uint256 _scale, address _mailbox - ) FungibleTokenRouter(_scale, _mailbox) { + ) TokenRouter(_scale, _mailbox) { _decimals = __decimals; } @@ -47,21 +47,26 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter { return _decimals; } + // ============ TokenRouter overrides ============ + + /** + * @inheritdoc TokenRouter + */ function token() public view virtual override returns (address) { return address(this); } /** - * @dev Burns `_amount` of token from `msg.sender` balance. * @inheritdoc TokenRouter + * @dev Overrides to burn `_amount` of token from `msg.sender` balance. */ function _transferFromSender(uint256 _amount) internal virtual override { _burn(msg.sender, _amount); } /** - * @dev Mints `_amount` of token to `_recipient` balance. * @inheritdoc TokenRouter + * @dev Overrides to mint `_amount` of token to `_recipient` balance. */ function _transferTo( address _recipient, diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index f03993a8b8..cd1916749c 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -16,17 +16,15 @@ pragma solidity >=0.8.0; // ============ Internal Imports ============ import {TokenMessage} from "./libs/TokenMessage.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; -import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol"; import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol"; +import {ERC20Collateral} from "./libs/TokenCollateral.sol"; // ============ External Imports ============ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Context} from "@openzeppelin/contracts/utils/Context.sol"; -import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; /** * @title Hyperlane ERC20 Token Collateral that wraps an existing ERC20 with remote transfer functionality. @@ -34,6 +32,7 @@ import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/Cont */ contract HypERC20Collateral is LpCollateralRouter { using SafeERC20 for IERC20; + using ERC20Collateral for IERC20; IERC20 public immutable wrappedToken; @@ -45,7 +44,7 @@ contract HypERC20Collateral is LpCollateralRouter { address erc20, uint256 _scale, address _mailbox - ) FungibleTokenRouter(_scale, _mailbox) { + ) TokenRouter(_scale, _mailbox) { require(Address.isContract(erc20), "HypERC20Collateral: invalid token"); wrappedToken = IERC20(erc20); } @@ -55,14 +54,6 @@ contract HypERC20Collateral is LpCollateralRouter { address _interchainSecurityModule, address _owner ) public virtual initializer { - _HypERC20_initialize(_hook, _interchainSecurityModule, _owner); - } - - function _HypERC20_initialize( - address _hook, - address _interchainSecurityModule, - address _owner - ) internal { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); _LpCollateralRouter_initialize(); } @@ -89,7 +80,7 @@ contract HypERC20Collateral is LpCollateralRouter { * @inheritdoc TokenRouter */ function _transferFromSender(uint256 _amount) internal virtual override { - wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); + wrappedToken._transferFromSender(_amount); } /** @@ -100,6 +91,6 @@ contract HypERC20Collateral is LpCollateralRouter { address _recipient, uint256 _amount ) internal virtual override { - wrappedToken.safeTransfer(_recipient, _amount); + wrappedToken._transferTo(_recipient, _amount); } } diff --git a/solidity/contracts/token/HypERC721.sol b/solidity/contracts/token/HypERC721.sol index d92aa8d5c8..2aef0b6d32 100644 --- a/solidity/contracts/token/HypERC721.sol +++ b/solidity/contracts/token/HypERC721.sol @@ -12,7 +12,7 @@ import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/t * @author Abacus Works */ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter { - constructor(address _mailbox) TokenRouter(_mailbox) {} + constructor(address _mailbox) TokenRouter(1, _mailbox) {} /** * @notice Initializes the Hyperlane router, ERC721 metadata, and mints initial supply to deployer. @@ -38,15 +38,26 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter { } } - function token() public view virtual override returns (address) { + /** + * @inheritdoc TokenRouter + */ + function token() public view override returns (address) { return address(this); } + /** + * @inheritdoc TokenRouter + * @dev NFTs cannot have a fee recipient + */ + function feeRecipient() public view override returns (address) { + return address(0); + } + /** * @dev Asserts `msg.sender` is owner and burns `_tokenId`. * @inheritdoc TokenRouter */ - function _transferFromSender(uint256 _tokenId) internal virtual override { + function _transferFromSender(uint256 _tokenId) internal override { require(ownerOf(_tokenId) == msg.sender, "!owner"); _burn(_tokenId); } @@ -58,7 +69,7 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter { function _transferTo( address _recipient, uint256 _tokenId - ) internal virtual override { + ) internal override { _safeMint(_recipient, _tokenId); } } diff --git a/solidity/contracts/token/HypERC721Collateral.sol b/solidity/contracts/token/HypERC721Collateral.sol index d7bafcd126..8c8c84b6e2 100644 --- a/solidity/contracts/token/HypERC721Collateral.sol +++ b/solidity/contracts/token/HypERC721Collateral.sol @@ -2,21 +2,26 @@ pragma solidity >=0.8.0; // ============ Internal Imports ============ -import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; +import {ERC721Collateral} from "./libs/TokenCollateral.sol"; + +// ============ External Imports ============ +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /** * @title Hyperlane ERC721 Token Collateral that wraps an existing ERC721 with remote transfer functionality. * @author Abacus Works */ contract HypERC721Collateral is TokenRouter { + using ERC721Collateral for IERC721; + IERC721 public immutable wrappedToken; /** * @notice Constructor * @param erc721 Address of the token to keep as collateral */ - constructor(address erc721, address _mailbox) TokenRouter(_mailbox) { + constructor(address erc721, address _mailbox) TokenRouter(1, _mailbox) { wrappedToken = IERC721(erc721); } @@ -34,17 +39,27 @@ contract HypERC721Collateral is TokenRouter { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } - function token() public view virtual override returns (address) { + /** + * @inheritdoc TokenRouter + */ + function token() public view override returns (address) { return address(wrappedToken); } + /** + * @inheritdoc TokenRouter + * @dev NFTs cannot have a fee recipient + */ + function feeRecipient() public view override returns (address) { + return address(0); + } + /** * @dev Transfers `_tokenId` of `wrappedToken` from `msg.sender` to this contract. * @inheritdoc TokenRouter */ - function _transferFromSender(uint256 _tokenId) internal virtual override { - // safeTransferFrom not used here because recipient is this contract - wrappedToken.transferFrom(msg.sender, address(this), _tokenId); + function _transferFromSender(uint256 _tokenId) internal override { + wrappedToken._transferFromSender(_tokenId); } /** @@ -55,6 +70,6 @@ contract HypERC721Collateral is TokenRouter { address _recipient, uint256 _tokenId ) internal override { - wrappedToken.safeTransferFrom(address(this), _recipient, _tokenId); + wrappedToken._transferTo(_recipient, _tokenId); } } diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 76023dc10f..36c0b219b7 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.0; -import {TokenRouter} from "./libs/TokenRouter.sol"; -import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol"; import {Quote, ITokenBridge} from "../interfaces/ITokenBridge.sol"; +import {NativeCollateral} from "./libs/TokenCollateral.sol"; +import {TokenRouter} from "./libs/TokenRouter.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; @@ -14,13 +14,12 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; * @dev Supply on each chain is not constant but the aggregate supply across all chains is. */ contract HypNative is LpCollateralRouter { - string internal constant INSUFFICIENT_NATIVE_AMOUNT = - "Native: amount exceeds msg.value"; + using NativeCollateral for address; constructor( uint256 _scale, address _mailbox - ) FungibleTokenRouter(_scale, _mailbox) {} + ) TokenRouter(_scale, _mailbox) {} /** * @notice Initializes the Hyperlane router @@ -37,21 +36,9 @@ contract HypNative is LpCollateralRouter { _LpCollateralRouter_initialize(); } - // override for single unified quote - function quoteTransferRemote( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) external view virtual override returns (Quote[] memory quotes) { - quotes = new Quote[](1); - quotes[0] = Quote({ - token: address(0), - amount: _quoteGasPayment(_destination, _recipient, _amount) + - _feeAmount(_destination, _recipient, _amount) + - _amount - }); - } - + /** + * @inheritdoc TokenRouter + */ function token() public view virtual override returns (address) { return address(0); } @@ -59,42 +46,18 @@ contract HypNative is LpCollateralRouter { /** * @inheritdoc TokenRouter */ - function _transferFromSender(uint256 _amount) internal virtual override { - require(msg.value >= _amount, "Native: amount exceeds msg.value"); - } - - function _nativeRebalanceValue( - uint256 collateralAmount - ) internal override returns (uint256 nativeValue) { - nativeValue = msg.value + collateralAmount; - require( - address(this).balance >= nativeValue, - "Native: rebalance amount exceeds balance" - ); + function _transferFromSender(uint256 _amount) internal override { + NativeCollateral._transferFromSender(_amount); } /** - * @dev Sends `_amount` of native token to `_recipient` balance. * @inheritdoc TokenRouter */ function _transferTo( address _recipient, uint256 _amount - ) internal virtual override { - Address.sendValue(payable(_recipient), _amount); - } - - function _chargeSender( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal virtual override returns (uint256 dispatchValue) { - uint256 fee = _feeAmount(_destination, _recipient, _amount); - _transferFromSender(_amount + fee); - dispatchValue = msg.value - (_amount + fee); - if (fee > 0) { - _transferTo(feeRecipient(), fee); - } + ) internal override { + NativeCollateral._transferTo(_recipient, _amount); } receive() external payable { diff --git a/solidity/contracts/token/README.md b/solidity/contracts/token/README.md index 1fbc80cf7d..945dd93e4e 100644 --- a/solidity/contracts/token/README.md +++ b/solidity/contracts/token/README.md @@ -53,7 +53,7 @@ The Token Router contract comes in several flavors and a warp route can be compo Warp routes are unique amongst token bridging solutions because they provide modular security. Because the `TokenRouter` implements the `IMessageRecipient` interface, it can be configured with a custom interchain security module. Please refer to the relevant guide to specifying interchain security modules on the [Messaging API receive docs](https://docs.hyperlane.xyz/docs/reference/messaging/messaging-interface). -## Remote Transfer Lifecycle Diagrams +## Remote Transfer Lifecycle To initiate a remote transfer, users call the `TokenRouter.transferRemote` function with the `destination` chain ID, `recipient` address, and transfer `amount`. @@ -220,12 +220,58 @@ graph TB **NOTE:** ERC721 collateral variants are assumed to [enumerable](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721Enumerable) and [metadata](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721Metadata) compliant. -## Versions +## Bridging Fees -| Git Ref | Release Date | Notes | -| ------------------------ | ------------ | ------------------------------ | -| [audit-v2-remediation]() | 2023-02-15 | Hyperlane V2 Audit remediation | -| [main]() | ~ | Bleeding edge | +Warp routes may charge additional fees for bridging to cover the costs of relaying, security, and liquidity management. + +To quote the fees charged by a warp route, users call the `TokenRouter.quoteTransferRemote` function with the same parameters to `transferRemote`. + +```solidity +struct Quote { + address token; // address(0) for the native token + uint256 amount; +} + +interface TokenRouter { + function quoteTransferRemote( + uint32 destination, + bytes32 recipient, + uint256 amount + ) public returns (Quote[] quotes); +} +``` + +We recommend performing this quote offchain and populating the value and token approvals accordingly. If you must quote onchain, there is a [`Quotes` utility library](./libs/Quotes.sol) for extracting the fees charged in specific denominations. + +### Funding Pseudocode + +```solidity +Quotes[] memory quotes = tokenRouter.quoteTransferRemote(destination, recipient, amount); + +uint256 nativeFee = quotes.extract(address(0)); + +address token = tokenRouter.token(); +uint256 tokenFee = quotes.extract(token); +IERC20(token).approve(tokenRouter, tokenFee); + +tokenRouter.transferRemote{value: nativeFee}(destination, recipient, amount); +``` + +### Fee Recipients + +Warp routes have configurable fees/fee recipients which are a function of the `transferRemote` parameters. + +```solidity +interface TokenRouter { + function feeRecipient() public view returns (address); +} +``` + +These fees will be surfaced in the `quoteTransferRemote` API response (if configured). These fees are charged at `transferRemote` time through the `TokenRouter` such that the above funding strategy applies. + +### External Fees + +Warp routes may wrap external bridges like [CCTP V2](./TokenBridgeCctpV2.sol) or [Everclear](./bridge/EverclearTokenBridge.sol) that have their own fee models. These are also exposed in the `quoteTransferRemote` API and charged at `transferRemote` time before being forwarded to the external bridge. ## Learn more diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 51c1418029..444b31c28f 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -14,8 +14,8 @@ import {IPostDispatchHook} from "../interfaces/hooks/IPostDispatchHook.sol"; import {StandardHookMetadata} from "../hooks/libs/StandardHookMetadata.sol"; import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; import {TypeCasts} from "../libs/TypeCasts.sol"; -import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol"; -import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol"; +import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol"; +import {TokenRouter} from "./libs/TokenRouter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -29,8 +29,14 @@ interface CctpService { returns (bytes memory cctpMessage, bytes memory attestation); } +// need intermediate contract to insert slots between TokenRouter and AbstractCcipReadIsm +abstract contract TokenBridgeCctpBaseStorage is TokenRouter { + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + MovableCollateralRouterStorage private __MOVABLE_COLLATERAL_GAP; +} + abstract contract TokenBridgeCctpBase is - MovableCollateralRouter, + TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, IPostDispatchHook { @@ -69,7 +75,7 @@ abstract contract TokenBridgeCctpBase is address _mailbox, IMessageTransmitter _messageTransmitter, ITokenMessenger _tokenMessenger - ) FungibleTokenRouter(_SCALE, _mailbox) { + ) TokenRouter(_SCALE, _mailbox) { require( _messageTransmitter.version() == _getCCTPVersion(), "Invalid messageTransmitter CCTP version" @@ -87,6 +93,9 @@ abstract contract TokenBridgeCctpBase is _disableInitializers(); } + /** + * @inheritdoc TokenRouter + */ function token() public view virtual override returns (address) { return address(wrappedToken); } @@ -104,6 +113,45 @@ abstract contract TokenBridgeCctpBase is wrappedToken.approve(address(tokenMessenger), type(uint256).max); } + /** + * @inheritdoc TokenRouter + * @dev Overrides to bridge the tokens via Circle. + */ + function transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) public payable virtual override returns (bytes32 messageId) { + // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary + ( + uint256 externalFee, + uint256 remainingNativeValue + ) = _calculateFeesAndCharge( + _destination, + _recipient, + _amount, + msg.value + ); + + // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides + uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination); + bytes memory _message = _bridgeViaCircle( + circleDomain, + _recipient, + _amount + externalFee + ); + + // 3. Emit the SentTransferRemote event and 4. dispatch the message + return + _emitAndDispatch( + _destination, + _recipient, + _amount, + remainingNativeValue, + _message + ); + } + function interchainSecurityModule() external view @@ -238,7 +286,7 @@ abstract contract TokenBridgeCctpBase is /// @inheritdoc IPostDispatchHook function supportsMetadata( - bytes calldata metadata + bytes calldata /*metadata*/ ) public pure override returns (bool) { return true; } @@ -266,15 +314,28 @@ abstract contract TokenBridgeCctpBase is _sendMessageIdToIsm(circleDestination, ism, id); } - // @dev Copied from HypERC20Collateral._transferFromSender + /** + * @inheritdoc TokenRouter + * @dev Overrides to transfer the tokens from the sender to this contract (like HypERC20Collateral). + */ function _transferFromSender(uint256 _amount) internal virtual override { wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); } + /** + * @inheritdoc TokenRouter + * @dev Overrides to not transfer the tokens to the recipient, as the CCTP transfer will do it. + */ function _transferTo( address _recipient, uint256 _amount ) internal override { // do not transfer to recipient as the CCTP transfer will do it } + + function _bridgeViaCircle( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) internal virtual returns (bytes memory message) {} } diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 3e906e8563..a137c91d8b 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -142,33 +142,26 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { ); } - function _beforeDispatch( - uint32 destination, - bytes32 recipient, - uint256 amount - ) - internal - virtual - override - returns (uint256 dispatchValue, bytes memory message) - { - dispatchValue = _chargeSender(destination, recipient, amount); - - uint32 circleDomain = hyperlaneDomainToCircleDomain(destination); - + function _bridgeViaCircle( + uint32 circleDomain, + bytes32 _recipient, + uint256 _amount + ) internal override returns (bytes memory _message) { uint64 nonce = ITokenMessengerV1(address(tokenMessenger)) .depositForBurn( - amount, + _amount, circleDomain, - recipient, + _recipient, address(wrappedToken) ); - message = TokenMessage.format( - recipient, - _outboundAmount(amount), + _message = TokenMessage.format( + _recipient, + _outboundAmount(_amount), abi.encodePacked(nonce) ); - _validateTokenMessageLength(message); + _validateTokenMessageLength(_message); + + return _message; } } diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index fb166aa48b..a6b3393790 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.0; import {TokenBridgeCctpBase} from "./TokenBridgeCctpBase.sol"; +import {TokenRouter} from "./libs/TokenRouter.sol"; import {TypedMemView} from "./../libs/TypedMemView.sol"; import {Message} from "./../libs/Message.sol"; import {TokenMessage} from "./libs/TokenMessage.sol"; @@ -46,6 +47,20 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { minFinalityThreshold = _minFinalityThreshold; } + // ============ TokenRouter overrides ============ + + /** + * @inheritdoc TokenRouter + * @dev Overrides to indicate v2 fees. + */ + function _externalFeeAmount( + uint32, + bytes32, + uint256 amount + ) internal view override returns (uint256 feeAmount) { + return (amount * maxFeeBps) / 10_000; + } + function _getCCTPVersion() internal pure override returns (uint32) { return 1; } @@ -153,43 +168,24 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { ); } - function _feeAmount( - uint32 destination, - bytes32 recipient, - uint256 amount - ) internal view override returns (uint256 feeAmount) { - return (amount * maxFeeBps) / 10_000; - } - - function _beforeDispatch( - uint32 destination, - bytes32 recipient, - uint256 amount - ) - internal - virtual - override - returns (uint256 dispatchValue, bytes memory message) - { - uint256 burnAmount = amount + - _feeAmount(destination, recipient, amount); - - _transferFromSender(burnAmount); - - uint32 circleDomain = hyperlaneDomainToCircleDomain(destination); - + function _bridgeViaCircle( + uint32 circleDomain, + bytes32 _recipient, + uint256 _amount + ) internal override returns (bytes memory message) { ITokenMessengerV2(address(tokenMessenger)).depositForBurn( - burnAmount, + _amount, circleDomain, - recipient, + _recipient, address(wrappedToken), bytes32(0), // allow anyone to relay maxFeeBps, minFinalityThreshold ); - dispatchValue = msg.value; - message = TokenMessage.format(recipient, burnAmount); + message = TokenMessage.format(_recipient, _amount); _validateTokenMessageLength(message); + + return message; } } diff --git a/solidity/contracts/token/bridge/EverclearEthBridge.sol b/solidity/contracts/token/bridge/EverclearEthBridge.sol deleted file mode 100644 index 72ed3f299e..0000000000 --- a/solidity/contracts/token/bridge/EverclearEthBridge.sol +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity ^0.8.22; - -import {EverclearTokenBridge, Quote} from "./EverclearTokenBridge.sol"; -import {IEverclearAdapter, IEverclear} from "../../interfaces/IEverclearAdapter.sol"; -import {IWETH} from "../interfaces/IWETH.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Address} from "@openzeppelin/contracts/utils/Address.sol"; -import {TypeCasts} from "../../libs/TypeCasts.sol"; -import {HypERC20Collateral} from "../HypERC20Collateral.sol"; -import {TokenMessage} from "../libs/TokenMessage.sol"; - -/** - * @title EverclearEthBridge - * @author Hyperlane Team - * @notice A specialized ETH bridge that integrates with Everclear's intent-based architecture - * @dev Extends EverclearTokenBridge to handle ETH by wrapping to WETH for transfers and unwrapping on destination - */ -contract EverclearEthBridge is EverclearTokenBridge { - using TokenMessage for bytes; - using SafeERC20 for IERC20; - using Address for address payable; - using TypeCasts for bytes32; - - /** - * @notice Constructor to initialize the Everclear ETH bridge - * @param _weth The WETH contract address for wrapping/unwrapping ETH - * @param _scale The scaling factor for token amounts (typically 1 for 18-decimal tokens) - * @param _mailbox The address of the Hyperlane mailbox contract - * @param _everclearAdapter The address of the Everclear adapter contract - */ - constructor( - IWETH _weth, - uint256 _scale, - address _mailbox, - IEverclearAdapter _everclearAdapter - ) - EverclearTokenBridge( - address(_weth), - _scale, - _mailbox, - _everclearAdapter - ) - {} - - /** - * @notice Gets the receiver address for an ETH transfer intent - * @dev Overrides parent to use the remote router instead of direct recipient - * @param _destination The destination domain ID - * @return receiver The remote router address that will handle the ETH transfer - */ - function _getReceiver( - uint32 _destination, - bytes32 /* _recipient */ - ) internal view override returns (bytes32 receiver) { - return _mustHaveRemoteRouter(_destination); - } - - /** - * @notice Encodes the intent calldata for ETH transfers - * @dev Overrides parent to encode recipient and amount for ETH-specific intent validation - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of ETH to transfer - * @return The encoded calldata containing recipient and amount - */ - function _getIntentCalldata( - bytes32 _recipient, - uint256 _amount - ) internal pure override returns (bytes memory) { - return abi.encode(_recipient, _amount); - } - - /** - * @notice Provides a quote for transferring ETH to a remote chain - * @dev Overrides parent to return a single quote for ETH (including transfer amount, fees, and gas) - * @param _destination The destination domain ID - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of ETH to transfer - * @return quotes Array containing a single quote with total ETH amount needed - */ - function quoteTransferRemote( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) public view override returns (Quote[] memory quotes) { - quotes = new Quote[](1); - quotes[0] = Quote({ - token: address(0), - amount: _amount + - feeParams[_destination].fee + - _quoteGasPayment(_destination, _recipient, _amount) - }); - } - - /** - * @notice Transfers ETH from sender, wrapping to WETH - * @dev Requires msg.value to be at least the specified amount, then wraps ETH to WETH - * @param _amount The amount of ETH to wrap to WETH (includes transfer amount and fees) - */ - function _transferFromSender(uint256 _amount) internal override { - // The `_amount` here will be amount + fee where amount is what the user wants to send, - // And `fee` is what is being payed to everclear. - // The user will also include the gas payment in the msg.value. - require(msg.value >= _amount, "EEB: ETH amount mismatch"); - IWETH(address(wrappedToken)).deposit{value: _amount}(); - } - - /** - * @notice Transfers ETH to a recipient by unwrapping WETH and sending native ETH - * @dev Unwraps WETH to ETH and uses Address.sendValue for safe ETH transfer - * @param _recipient The address to receive the ETH - * @param _amount The amount of ETH to transfer - */ - function _transferTo( - address _recipient, - uint256 _amount - ) internal override { - // Withdraw WETH to ETH - IWETH(address(wrappedToken)).withdraw(_amount); - - // Send ETH to recipient - payable(_recipient).sendValue(_amount); - } - - /** - * @notice Charges the sender for ETH transfer including all fees - * @dev Overrides parent to handle ETH-specific charging logic with fee calculation and distribution - * @param _destination The destination domain ID - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of ETH to transfer (excluding fees) - * @return dispatchValue The remaining ETH value to include with the Hyperlane message dispatch - */ - function _chargeSender( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal virtual override returns (uint256 dispatchValue) { - uint256 fee = _feeAmount(_destination, _recipient, _amount); - - uint256 totalAmount = _amount + fee + feeParams[_destination].fee; - _transferFromSender(totalAmount); - dispatchValue = msg.value - totalAmount; - if (fee > 0) { - _transferTo(feeRecipient(), fee); - } - return dispatchValue; - } - - /** - * @notice Validates the Everclear intent for ETH transfers - * @dev Overrides parent to add ETH-specific validation by checking intent data matches message - * @param _message The incoming message containing transfer details - * @return intentId The unique identifier for the validated intent - * @return intentBytes The encoded intent data - */ - function _validateIntent( - bytes calldata _message - ) internal view override returns (bytes32, bytes memory) { - (bytes32 intentId, bytes memory intentBytes) = super._validateIntent( - _message - ); - IEverclear.Intent memory intent = abi.decode( - intentBytes, - (IEverclear.Intent) - ); - (bytes32 _intentRecipient, uint256 _intentAmount) = abi.decode( - intent.data, - (bytes32, uint256) - ); - - require( - _intentRecipient == _message.recipient(), - "EEB: Intent recipient mismatch" - ); - require( - _intentAmount == _message.amount(), - "EEB: Intent amount mismatch" - ); - - return (intentId, intentBytes); - } - - /** - * @notice Allows the contract to receive ETH - * @dev Required for WETH unwrapping functionality - */ - receive() external payable { - require( - msg.sender == address(wrappedToken), - "EEB: Only WETH can send ETH" - ); - } -} diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index 2769df7cc6..c2550aba41 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -3,15 +3,20 @@ pragma solidity ^0.8.22; import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol"; import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../interfaces/IEverclearAdapter.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {PackageVersioned} from "../../PackageVersioned.sol"; import {IWETH} from "../interfaces/IWETH.sol"; import {TokenMessage} from "../libs/TokenMessage.sol"; import {TypeCasts} from "../../libs/TypeCasts.sol"; -import {FungibleTokenRouter} from "../libs/FungibleTokenRouter.sol"; +import {ERC20Collateral, WETHCollateral} from "../libs/TokenCollateral.sol"; + +import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; /** * @notice Information about an output asset for a destination domain @@ -24,15 +29,15 @@ struct OutputAssetInfo { } /** - * @title EverclearTokenBridge + * @title EverclearBridge * @author Hyperlane Team * @notice A token bridge that integrates with Everclear's intent-based architecture - * @dev Extends HypERC20Collateral to provide cross-chain token transfers via Everclear's intent system */ -contract EverclearTokenBridge is HypERC20Collateral { +abstract contract EverclearBridge is TokenRouter { using TokenMessage for bytes; using TypeCasts for bytes32; - using SafeERC20 for IERC20; + + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; /** * @notice Parameters for creating an Everclear intent @@ -70,10 +75,10 @@ contract EverclearTokenBridge is HypERC20Collateral { mapping(uint32 destination => IEverclearAdapter.FeeParams feeParams) public feeParams; - /** - * @notice The Everclear adapter contract interface - * @dev Immutable reference to the Everclear adapter used for creating and managing intents - */ + IERC20 public immutable wrappedToken; + + /// @notice The Everclear adapter contract interface + /// @dev Immutable reference to the Everclear adapter used for creating intents IEverclearAdapter public immutable everclearAdapter; /** @@ -104,11 +109,12 @@ contract EverclearTokenBridge is HypERC20Collateral { * @param _everclearAdapter The address of the Everclear adapter contract */ constructor( - address _erc20, + IEverclearAdapter _everclearAdapter, + IERC20 _erc20, uint256 _scale, - address _mailbox, - IEverclearAdapter _everclearAdapter - ) HypERC20Collateral(_erc20, _scale, _mailbox) { + address _mailbox + ) TokenRouter(_scale, _mailbox) { + wrappedToken = _erc20; everclearAdapter = _everclearAdapter; everclearSpoke = _everclearAdapter.spoke(); } @@ -120,10 +126,25 @@ contract EverclearTokenBridge is HypERC20Collateral { * @param _owner The address that will own this contract */ function initialize(address _hook, address _owner) public initializer { - _HypERC20_initialize(_hook, address(0), _owner); + _MailboxClient_initialize(_hook, address(0), _owner); wrappedToken.approve(address(everclearAdapter), type(uint256).max); } + function _settleIntent(bytes calldata _message) internal virtual { + /* CHECKS */ + // Check that intent is settled + bytes32 intentId = keccak256(_message.metadata()); + require( + everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED, + "ETB: Intent Status != SETTLED" + ); + // Check that we have not processed this intent before + require(!intentSettled[intentId], "ETB: Intent already processed"); + + /* EFFECTS */ + intentSettled[intentId] = true; + } + /** * @notice Sets the fee parameters for Everclear bridge operations * @dev Only callable by the contract owner @@ -187,30 +208,73 @@ contract EverclearTokenBridge is HypERC20Collateral { } /** - * @notice Provides a quote for transferring tokens to a remote chain - * @dev Returns the gas payment quote and the total token amount needed (including fees) - * @param _destination The destination domain ID - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of tokens to transfer - * @return quotes Array of quotes containing gas payment and token amount requirements + * @inheritdoc TokenRouter + */ + function _externalFeeAmount( + uint32 _destination, + bytes32, + uint256 + ) internal view override returns (uint256 feeAmount) { + return feeParams[_destination].fee; + } + + /** + * @inheritdoc TokenRouter + * @dev Overrides to create an Everclear intent for the transfer. */ - function quoteTransferRemote( + function transferRemote( uint32 _destination, bytes32 _recipient, uint256 _amount - ) public view virtual override returns (Quote[] memory quotes) { - _destination; // Keep this to avoid solc's documentation warning (3881) - _recipient; - - quotes = new Quote[](2); - quotes[0] = Quote({ - token: address(0), - amount: _quoteGasPayment(_destination, _recipient, _amount) - }); - quotes[1] = Quote({ - token: address(wrappedToken), - amount: _amount + feeParams[_destination].fee - }); + ) public payable virtual override returns (bytes32 messageId) { + // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary + (, uint256 remainingNativeValue) = _calculateFeesAndCharge( + _destination, + _recipient, + _amount, + msg.value + ); + + // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides + IEverclear.Intent memory intent = _createIntent( + _destination, + _recipient, + _amount + ); + + bytes memory _tokenMessage = TokenMessage.format( + _recipient, + _outboundAmount(_amount), + abi.encode(intent) + ); + + // 3. Emit the SentTransferRemote event and 4. dispatch the message + return + _emitAndDispatch( + _destination, + _recipient, + _amount, + remainingNativeValue, + _tokenMessage + ); + } + + /** + * @inheritdoc TokenRouter + * @dev Overrides to check for the Everclear intent status and transfer tokens to the recipient. + */ + function _handle( + uint32 _origin, + bytes32 /* sender */, + bytes calldata _message + ) internal virtual override { + _settleIntent(_message); + + bytes32 _recipient = _message.recipient(); + uint256 _amount = _message.amount(); + + emit ReceivedTransferRemote(_origin, _recipient, _amount); + _transferTo(_recipient.bytes32ToAddress(), _amount); } /** @@ -223,9 +287,7 @@ contract EverclearTokenBridge is HypERC20Collateral { function _getIntentCalldata( bytes32 _recipient, uint256 _amount - ) internal pure virtual returns (bytes memory) { - return ""; - } + ) internal pure virtual returns (bytes memory); /** * @notice Creates an Everclear intent for cross-chain token transfer @@ -288,116 +350,183 @@ contract EverclearTokenBridge is HypERC20Collateral { function _getReceiver( uint32 _destination, bytes32 _recipient - ) internal view virtual returns (bytes32) { + ) internal view virtual returns (bytes32 receiver); +} + +/** + * @title EverclearTokenBridge + * @author Hyperlane Team + * @notice A token bridge that integrates with Everclear's intent-based architecture + * @dev Extends HypERC20Collateral to provide cross-chain token transfers via Everclear's intent system + */ +contract EverclearTokenBridge is EverclearBridge { + using ERC20Collateral for IERC20; + + /** + * @notice Constructor to initialize the Everclear token bridge + * @param _everclearAdapter The address of the Everclear adapter contract + */ + constructor( + address _erc20, + uint256 _scale, + address _mailbox, + IEverclearAdapter _everclearAdapter + ) EverclearBridge(_everclearAdapter, IERC20(_erc20), _scale, _mailbox) {} + + /** + * @inheritdoc EverclearBridge + */ + function _getReceiver( + uint32 /* _destination */, + bytes32 _recipient + ) internal pure override returns (bytes32 receiver) { return _recipient; } /** - * @notice Charges the sender for the transfer including Everclear fees - * @dev We can't use _feeAmount here because Everclear wants to pull tokens from this contract - * and the amount from _feeAmount is sent to the fee recipient. - * @param _destination The destination domain ID - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of tokens to transfer (excluding fees) - * @return dispatchValue The ETH value to include with the Hyperlane message dispatch + * @inheritdoc TokenRouter */ - function _chargeSender( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal virtual override returns (uint256 dispatchValue) { - return - super._chargeSender( - _destination, - _recipient, - _amount + feeParams[_destination].fee - ); + function token() public view override returns (address) { + return address(wrappedToken); } /** - * @notice Handles pre-dispatch logic including charging sender and creating Everclear intent - * @dev Overrides parent function to integrate with Everclear's intent system - * @param _destination The destination domain ID - * @param _recipient The recipient address on the destination chain - * @param _amount The amount of tokens to transfer - * @return dispatchValue The ETH value to include with the message dispatch - * @return message The encoded message containing transfer details and intent + * @inheritdoc TokenRouter */ - function _beforeDispatch( - uint32 _destination, - bytes32 _recipient, + function _transferFromSender(uint256 _amount) internal override { + wrappedToken._transferFromSender(_amount); + } + + /** + * @inheritdoc TokenRouter + */ + function _transferTo( + address _recipient, uint256 _amount - ) internal virtual override returns (uint256, bytes memory) { - uint256 dispatchValue = _chargeSender( - _destination, - _recipient, - _amount - ); + ) internal override { + wrappedToken._transferTo(_recipient, _amount); + } - IEverclear.Intent memory intent = _createIntent( - _destination, - _recipient, - _amount - ); + /** + * @notice Encodes the intent calldata for ETH transfers + * @return The encoded calldata for the everclear intent. + */ + function _getIntentCalldata( + bytes32 /* _recipient */, + uint256 /* _amount */ + ) internal pure override returns (bytes memory) { + return ""; + } +} - bytes memory message = TokenMessage.format( - _recipient, - _outboundAmount(_amount), - abi.encode(intent) - ); +/** + * @title EverclearEthBridge + * @author Hyperlane Team + * @notice A specialized ETH bridge that integrates with Everclear's intent-based architecture + * @dev Extends EverclearTokenBridge to handle ETH by wrapping to WETH for transfers and unwrapping on destination + */ +contract EverclearEthBridge is EverclearBridge { + using WETHCollateral for IWETH; + using TokenMessage for bytes; + using SafeERC20 for IERC20; + using Address for address payable; + using TypeCasts for bytes32; - return (dispatchValue, message); + /** + * @notice Constructor to initialize the Everclear ETH bridge + * @param _everclearAdapter The address of the Everclear adapter contract + */ + constructor( + IWETH _weth, + uint256 _scale, + address _mailbox, + IEverclearAdapter _everclearAdapter + ) EverclearBridge(_everclearAdapter, IERC20(_weth), _scale, _mailbox) {} + + /** + * @inheritdoc EverclearBridge + */ + function _getReceiver( + uint32 _destination, + bytes32 /* _recipient */ + ) internal view override returns (bytes32 receiver) { + return _mustHaveRemoteRouter(_destination); } + // senders and recipients are ETH, so we return address(0) /** - * @notice Transfers tokens to the recipient (no-op in Everclear bridge) - * @dev No-op implementation since funds are transferred directly to recipient via Everclear's intent system - * @param _recipient The address to receive the tokens (unused) - * @param _amount The amount of tokens to transfer (unused) + * @inheritdoc TokenRouter + */ + function token() public pure override returns (address) { + return address(0); + } + + /** + * @inheritdoc TokenRouter + */ + function _transferFromSender(uint256 _amount) internal override { + IWETH(address(wrappedToken))._transferFromSender(_amount); + } + + /** + * @inheritdoc TokenRouter */ function _transferTo( address _recipient, uint256 _amount - ) internal virtual override { - // No-op, the funds are transferred directly to `_recipient` via Everclear + ) internal override { + IWETH(address(wrappedToken))._transferTo(_recipient, _amount); } /** - * @notice Validates the Everclear intent associated with an incoming message - * @dev Checks that the intent is settled on Everclear and hasn't been processed before - * @param _message The incoming message containing intent metadata - * @return intentId The unique identifier for the validated intent - * @return intentBytes The encoded intent data from the message metadata + * @notice Allows the contract to receive ETH + * @dev Required for WETH unwrapping functionality */ - function _validateIntent( - bytes calldata _message - ) internal view virtual returns (bytes32, bytes memory) { - bytes memory intentBytes = _message.metadata(); - bytes32 intentId = keccak256(intentBytes); - // Check Everclear intent status + receive() external payable { require( - everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED, - "ETB: Intent Status != SETTLED" + msg.sender == address(wrappedToken), + "EEB: Only WETH can send ETH" ); - // Check that we have not processed this intent before - require(!intentSettled[intentId], "ETB: Intent already processed"); - return (intentId, intentBytes); } /** - * @notice Handles incoming messages from remote chains - * @dev Validates the Everclear intent, marks it as settled, and delegates to parent handler - * @param _origin The origin domain ID where the message was sent from - * @param _sender The address of the sender on the origin chain - * @param _message The message payload containing transfer details and intent metadata + * @notice Encodes the intent calldata for ETH transfers + * @dev Overrides parent to encode recipient and amount for ETH-specific intent validation + * @param _recipient The recipient address on the destination chain + * @param _amount The amount of ETH to transfer + * @return The encoded calldata containing recipient and amount */ - function _handle( - uint32 _origin, - bytes32 _sender, - bytes calldata _message - ) internal override { - (bytes32 intentId, ) = _validateIntent(_message); - intentSettled[intentId] = true; - super._handle(_origin, _sender, _message); + function _getIntentCalldata( + bytes32 _recipient, + uint256 _amount + ) internal pure override returns (bytes memory) { + return abi.encode(_recipient, _amount); + } + + /** + * @notice Validates the Everclear intent for ETH transfers + * @dev Overrides parent to add ETH-specific validation by checking intent data matches message + * @param _message The incoming message containing transfer details + */ + function _settleIntent(bytes calldata _message) internal override { + super._settleIntent(_message); + + IEverclear.Intent memory intent = abi.decode( + _message.metadata(), + (IEverclear.Intent) + ); + (bytes32 _intentRecipient, uint256 _intentAmount) = abi.decode( + intent.data, + (bytes32, uint256) + ); + + require( + _intentRecipient == _message.recipient(), + "EEB: Intent recipient mismatch" + ); + require( + _intentAmount == _message.amount(), + "EEB: Intent amount mismatch" + ); } } diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol index 7e922ff92c..897b0489f1 100644 --- a/solidity/contracts/token/extensions/HypERC4626.sol +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -19,7 +19,6 @@ import {Message} from "../../libs/Message.sol"; import {TokenMessage} from "../libs/TokenMessage.sol"; import {TokenRouter} from "../libs/TokenRouter.sol"; import {Router} from "../../client/Router.sol"; -import {FungibleTokenRouter} from "../libs/FungibleTokenRouter.sol"; // ============ External Imports ============ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -97,13 +96,12 @@ contract HypERC4626 is HypERC20 { HypERC20._transferFromSender(assetsToShares(_amount)); } - // @inheritdoc FungibleTokenRouter + // @inheritdoc TokenRouter // @dev Amount specified by user is in assets, but the message accounting is in shares function _outboundAmount( uint256 _localAmount ) internal view virtual override returns (uint256) { - return - FungibleTokenRouter._outboundAmount(assetsToShares(_localAmount)); + return TokenRouter._outboundAmount(assetsToShares(_localAmount)); } // @inheritdoc ERC20Upgradeable @@ -116,7 +114,7 @@ contract HypERC4626 is HypERC20 { super._transfer(_from, _to, assetsToShares(_amount)); } - // `_inboundAmount` implementation reused from `FungibleTokenRouter` unchanged because message + // `_inboundAmount` implementation reused from `TokenRouter` unchanged because message // accounting is in shares // ========== TokenRouter extensions ============ diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index edde5baf15..49d580572c 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -18,26 +18,36 @@ import {TokenMessage} from "../libs/TokenMessage.sol"; import {HypERC20Collateral} from "../HypERC20Collateral.sol"; import {TypeCasts} from "../../libs/TypeCasts.sol"; import {TokenRouter} from "../libs/TokenRouter.sol"; +import {ERC20Collateral} from "../libs/TokenCollateral.sol"; +import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol"; // ============ External Imports ============ import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title Hyperlane ERC4626 Token Collateral with deposits collateral to a vault * @author Abacus Works */ -contract HypERC4626Collateral is HypERC20Collateral { +contract HypERC4626Collateral is TokenRouter { + using ERC20Collateral for IERC20; using TypeCasts for address; using TokenMessage for bytes; using Math for uint256; // Address of the ERC4626 compatible vault ERC4626 public immutable vault; + IERC20 public immutable wrappedToken; + // Precision for the exchange rate uint256 public constant PRECISION = 1e10; // Null recipient for rebase transfer bytes32 public constant NULL_RECIPIENT = 0x0000000000000000000000000000000000000000000000000000000000000001; + + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; + // Nonce for the rate update, to ensure sequential updates uint32 public rateUpdateNonce; @@ -45,44 +55,50 @@ contract HypERC4626Collateral is HypERC20Collateral { ERC4626 _vault, uint256 _scale, address _mailbox - ) HypERC20Collateral(_vault.asset(), _scale, _mailbox) { + ) TokenRouter(_scale, _mailbox) { vault = _vault; + wrappedToken = IERC20(_vault.asset()); } function initialize( address _hook, address _interchainSecurityModule, address _owner - ) public override initializer { + ) public initializer { wrappedToken.approve(address(vault), type(uint256).max); _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } - function _chargeSender( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal virtual override returns (uint256 dispatchValue) { - uint256 fee = _feeAmount(_destination, _recipient, _amount); - HypERC20Collateral._transferFromSender(_amount + fee); - if (fee > 0) { - HypERC20Collateral._transferTo(feeRecipient(), fee); - } - return msg.value; - } + // ============ TokenRouter overrides ============ - function _beforeDispatch( + /** + * @inheritdoc TokenRouter + * @dev Overrides to deposit tokens into the vault and add exchange rate metadata. + */ + function transferRemote( uint32 _destination, bytes32 _recipient, uint256 _amount - ) - internal - virtual - override - returns (uint256 dispatchValue, bytes memory message) - { - dispatchValue = _chargeSender(_destination, _recipient, _amount); + ) public payable override returns (bytes32 messageId) { + // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary + // Don't use HypERC4626Collateral's implementation of _transferTo since it does a redemption. + uint256 feeRecipientFee = _feeRecipientAmount( + _destination, + _recipient, + _amount + ); + uint256 externalFee = _externalFeeAmount( + _destination, + _recipient, + _amount + ); + _transferFromSender(_amount + feeRecipientFee); + if (feeRecipientFee > 0) { + wrappedToken._transferTo(feeRecipient(), feeRecipientFee); + } + // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides + // Deposit the amount into the vault and get the shares for the TokenMessage amount uint256 _shares = _depositIntoVault(_amount); uint256 _exchangeRate = vault.convertToAssets(PRECISION); @@ -94,26 +110,33 @@ contract HypERC4626Collateral is HypERC20Collateral { ); uint256 _outboundAmount = _outboundAmount(_shares); - message = TokenMessage.format( + bytes memory _tokenMessage = TokenMessage.format( _recipient, _outboundAmount, _tokenMetadata ); + + // 3. Emit the SentTransferRemote event and 4. dispatch the message + return + _emitAndDispatch( + _destination, + _recipient, + _amount, + msg.value, + _tokenMessage + ); } /** - * @dev Deposits into the vault and increment assetDeposited - * @param _amount amount to deposit into vault + * @inheritdoc TokenRouter */ - function _depositIntoVault( - uint256 _amount - ) internal virtual returns (uint256) { - return vault.deposit(_amount, address(this)); + function token() public view override returns (address) { + return address(wrappedToken); } /** + * @inheritdoc TokenRouter * @dev Withdraws `_shares` of `wrappedToken` from this contract to `_recipient` - * @inheritdoc HypERC20Collateral */ function _transferTo( address _recipient, @@ -122,22 +145,31 @@ contract HypERC4626Collateral is HypERC20Collateral { vault.redeem(_shares, _recipient, address(this)); } + /** + * @inheritdoc TokenRouter + */ + function _transferFromSender(uint256 _amount) internal override { + wrappedToken._transferFromSender(_amount); + } + + /** + * @param _amount amount to deposit into vault + * @dev Deposits into the vault and increment assetDeposited. + * Known overrides: + * - HypERC4626OwnerCollateral: Tracks the total asset deposited and allows sweeping excess + */ + function _depositIntoVault( + uint256 _amount + ) internal virtual returns (uint256) { + return vault.deposit(_amount, address(this)); + } + /** * @dev Update the exchange rate on the synthetic token by accounting for additional yield accrued to the underlying vault * @param _destinationDomain domain of the vault */ - function rebase( - uint32 _destinationDomain, - bytes calldata _hookMetadata, - address _hook - ) public payable { + function rebase(uint32 _destinationDomain) public payable { // force a rebase with an empty transfer to 0x1 - _transferRemote( - _destinationDomain, - NULL_RECIPIENT, - 0, - _hookMetadata, - _hook - ); + transferRemote(_destinationDomain, NULL_RECIPIENT, 0); } } diff --git a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol index fb25411f5d..fb307d369d 100644 --- a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol @@ -36,6 +36,12 @@ contract HypERC4626OwnerCollateral is HypERC4626Collateral { address _mailbox ) HypERC4626Collateral(_vault, _scale, _mailbox) {} + // =========== TokenRouter Overrides ============ + + /** + * @inheritdoc HypERC4626Collateral + * @dev Overrides to track the total asset deposited. + */ function _depositIntoVault( uint256 _amount ) internal virtual override returns (uint256) { @@ -45,8 +51,8 @@ contract HypERC4626OwnerCollateral is HypERC4626Collateral { } /** - * @dev Transfers `_amount` of `wrappedToken` from this contract to `_recipient`, and withdraws from vault - * @inheritdoc HypERC20Collateral + * @inheritdoc HypERC4626Collateral + * @dev Overrides to withdraw from the vault and track the asset deposited. */ function _transferTo( address _recipient, diff --git a/solidity/contracts/token/extensions/HypERC721URICollateral.sol b/solidity/contracts/token/extensions/HypERC721URICollateral.sol index 384d1e9775..7a059bd216 100644 --- a/solidity/contracts/token/extensions/HypERC721URICollateral.sol +++ b/solidity/contracts/token/extensions/HypERC721URICollateral.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.0; import {HypERC721Collateral} from "../HypERC721Collateral.sol"; import {TokenMessage} from "../libs/TokenMessage.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; import {IERC721MetadataUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721MetadataUpgradeable.sol"; @@ -17,17 +18,40 @@ contract HypERC721URICollateral is HypERC721Collateral { address _mailbox ) HypERC721Collateral(erc721, _mailbox) {} - function _beforeDispatch( + /** + * @inheritdoc TokenRouter + * @dev Overrides to fetch the URI and pass it to the token message. + */ + function transferRemote( uint32 _destination, bytes32 _recipient, uint256 _tokenId - ) internal override returns (uint256 dispatchValue, bytes memory message) { - HypERC721Collateral._transferFromSender(_tokenId); - dispatchValue = msg.value; + ) public payable override returns (bytes32 messageId) { + (, uint256 remainingNativeValue) = _calculateFeesAndCharge( + _destination, + _recipient, + _tokenId, + msg.value + ); string memory _tokenURI = IERC721MetadataUpgradeable( address(wrappedToken) ).tokenURI(_tokenId); - message = TokenMessage.format(_recipient, _tokenId, bytes(_tokenURI)); + + bytes memory _tokenMessage = TokenMessage.format( + _recipient, + _tokenId, + bytes(_tokenURI) + ); + + // 3. Emit the SentTransferRemote event and 4. dispatch the message + return + _emitAndDispatch( + _destination, + _recipient, + _tokenId, + remainingNativeValue, + _tokenMessage + ); } } diff --git a/solidity/contracts/token/extensions/HypERC721URIStorage.sol b/solidity/contracts/token/extensions/HypERC721URIStorage.sol index 2987d25961..1b49f64cb8 100644 --- a/solidity/contracts/token/extensions/HypERC721URIStorage.sol +++ b/solidity/contracts/token/extensions/HypERC721URIStorage.sol @@ -20,36 +20,13 @@ contract HypERC721URIStorage is HypERC721, ERC721URIStorageUpgradeable { constructor(address _mailbox) HypERC721(_mailbox) {} - function _beforeDispatch( - uint32 _destination, - bytes32 _recipient, - uint256 _tokenId - ) internal override returns (uint256 dispatchValue, bytes memory message) { - string memory _tokenURI = tokenURI(_tokenId); // requires minted - - HypERC721._transferFromSender(_tokenId); - - dispatchValue = msg.value; - - message = TokenMessage.format( - _recipient, - _tokenId, - abi.encodePacked(_tokenURI) - ); - } - function _handle( uint32 _origin, - bytes32, + bytes32 _sender, bytes calldata _message - ) internal virtual override { - bytes32 recipient = _message.recipient(); - uint256 tokenId = _message.tokenId(); - - emit ReceivedTransferRemote(_origin, recipient, tokenId); - - HypERC721._transferTo(recipient.bytes32ToAddress(), tokenId); - _setTokenURI(tokenId, string(_message.metadata())); + ) internal override { + super._handle(_origin, _sender, _message); + _setTokenURI(_message.tokenId(), string(_message.metadata())); } function tokenURI( diff --git a/solidity/contracts/token/extensions/HypFiatToken.sol b/solidity/contracts/token/extensions/HypFiatToken.sol index 1f693cef82..f4d0aab379 100644 --- a/solidity/contracts/token/extensions/HypFiatToken.sol +++ b/solidity/contracts/token/extensions/HypFiatToken.sol @@ -3,28 +3,62 @@ pragma solidity >=0.8.0; import {IFiatToken} from "../interfaces/IFiatToken.sol"; import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; +import {ERC20Collateral} from "../libs/TokenCollateral.sol"; +import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol"; // see https://github.com/circlefin/stablecoin-evm/blob/master/doc/tokendesign.md#issuing-and-destroying-tokens -contract HypFiatToken is HypERC20Collateral { +contract HypFiatToken is TokenRouter { + using ERC20Collateral for IFiatToken; + + IFiatToken public immutable wrappedToken; + + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; + constructor( address _fiatToken, uint256 _scale, address _mailbox - ) HypERC20Collateral(_fiatToken, _scale, _mailbox) {} + ) TokenRouter(_scale, _mailbox) { + wrappedToken = IFiatToken(_fiatToken); + _disableInitializers(); + } + + function initialize( + address _hook, + address _interchainSecurityModule, + address _owner + ) public virtual initializer { + _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); + } + + // ============ TokenRouter overrides ============ + function token() public view override returns (address) { + return address(wrappedToken); + } + /** + * @inheritdoc TokenRouter + * @dev Overrides to burn tokens on outbound transfer. + */ function _transferFromSender(uint256 _amount) internal override { // transfer amount to address(this) - HypERC20Collateral._transferFromSender(_amount); + wrappedToken._transferFromSender(_amount); // burn amount of address(this) balance - IFiatToken(address(wrappedToken)).burn(_amount); + wrappedToken.burn(_amount); } + /** + * @inheritdoc TokenRouter + * @dev Overrides to mint tokens on inbound transfer. + */ function _transferTo( address _recipient, uint256 _amount ) internal override { require( - IFiatToken(address(wrappedToken)).mint(_recipient, _amount), + wrappedToken.mint(_recipient, _amount), "FiatToken mint failed" ); } diff --git a/solidity/contracts/token/extensions/HypXERC20.sol b/solidity/contracts/token/extensions/HypXERC20.sol index 03f5377115..a6a28e2355 100644 --- a/solidity/contracts/token/extensions/HypXERC20.sol +++ b/solidity/contracts/token/extensions/HypXERC20.sol @@ -3,24 +3,53 @@ pragma solidity >=0.8.0; import {IXERC20} from "../interfaces/IXERC20.sol"; import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; +import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol"; + +contract HypXERC20 is TokenRouter { + IXERC20 public immutable wrappedToken; + + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; -contract HypXERC20 is HypERC20Collateral { constructor( address _xerc20, uint256 _scale, address _mailbox - ) HypERC20Collateral(_xerc20, _scale, _mailbox) { + ) TokenRouter(_scale, _mailbox) { + wrappedToken = IXERC20(_xerc20); _disableInitializers(); } + function initialize( + address _hook, + address _interchainSecurityModule, + address _owner + ) public virtual initializer { + _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); + } + + // ============ TokenRouter overrides ============ + function token() public view override returns (address) { + return address(wrappedToken); + } + + /** + * @inheritdoc TokenRouter + * @dev Overrides to burn tokens on outbound transfer. + */ function _transferFromSender(uint256 _amountOrId) internal override { - IXERC20(address(wrappedToken)).burn(msg.sender, _amountOrId); + wrappedToken.burn(msg.sender, _amountOrId); } + /** + * @inheritdoc TokenRouter + * @dev Overrides to mint tokens on inbound transfer. + */ function _transferTo( address _recipient, uint256 _amountOrId ) internal override { - IXERC20(address(wrappedToken)).mint(_recipient, _amountOrId); + wrappedToken.mint(_recipient, _amountOrId); } } diff --git a/solidity/contracts/token/extensions/HypXERC20Lockbox.sol b/solidity/contracts/token/extensions/HypXERC20Lockbox.sol index 6f0a18a77d..8de1e712d1 100644 --- a/solidity/contracts/token/extensions/HypXERC20Lockbox.sol +++ b/solidity/contracts/token/extensions/HypXERC20Lockbox.sol @@ -3,30 +3,32 @@ pragma solidity >=0.8.0; import {IXERC20Lockbox} from "../interfaces/IXERC20Lockbox.sol"; import {IXERC20, IERC20} from "../interfaces/IXERC20.sol"; -import {HypERC20Collateral} from "../HypERC20Collateral.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; +import {ERC20Collateral} from "../libs/TokenCollateral.sol"; +import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol"; + +contract HypXERC20Lockbox is TokenRouter { + using SafeERC20 for IERC20; + using ERC20Collateral for IERC20; -contract HypXERC20Lockbox is HypERC20Collateral { uint256 constant MAX_INT = 2 ** 256 - 1; IXERC20Lockbox public immutable lockbox; IXERC20 public immutable xERC20; + IERC20 public immutable wrappedToken; - using SafeERC20 for IERC20; + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; constructor( address _lockbox, uint256 _scale, address _mailbox - ) - HypERC20Collateral( - address(IXERC20Lockbox(_lockbox).ERC20()), - _scale, - _mailbox - ) - { + ) TokenRouter(_scale, _mailbox) { lockbox = IXERC20Lockbox(_lockbox); - xERC20 = lockbox.XERC20(); + xERC20 = IXERC20(lockbox.XERC20()); + wrappedToken = IERC20(lockbox.ERC20()); approveLockbox(); _disableInitializers(); } @@ -36,7 +38,7 @@ contract HypXERC20Lockbox is HypERC20Collateral { * @dev This function is idempotent and need not be access controlled */ function approveLockbox() public { - IERC20(wrappedToken).safeApprove(address(lockbox), MAX_INT); + wrappedToken.safeApprove(address(lockbox), MAX_INT); IERC20(xERC20).safeApprove(address(lockbox), MAX_INT); } @@ -50,20 +52,33 @@ contract HypXERC20Lockbox is HypERC20Collateral { address _hook, address _ism, address _owner - ) public override initializer { + ) public initializer { approveLockbox(); _MailboxClient_initialize(_hook, _ism, _owner); } + // ============ TokenRouter overrides ============ + function token() public view override returns (address) { + return address(wrappedToken); + } + + /** + * @inheritdoc TokenRouter + * @dev Overrides to burn tokens on outbound transfer. + */ function _transferFromSender(uint256 _amount) internal override { // transfer erc20 from sender - super._transferFromSender(_amount); + wrappedToken._transferFromSender(_amount); // convert erc20 to xERC20 lockbox.deposit(_amount); // burn xERC20 xERC20.burn(address(this), _amount); } + /** + * @inheritdoc TokenRouter + * @dev Overrides to mint tokens on inbound transfer. + */ function _transferTo( address _recipient, uint256 _amount diff --git a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol index f1756bfc55..255540df0d 100644 --- a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol +++ b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.0; import {HypNative} from "../../token/HypNative.sol"; import {TypeCasts} from "../../libs/TypeCasts.sol"; import {TokenRouter} from "../../token/libs/TokenRouter.sol"; +import {Router} from "../../client/Router.sol"; import {IStandardBridge} from "../../interfaces/optimism/IStandardBridge.sol"; import {Quote, ITokenBridge} from "../../interfaces/ITokenBridge.sol"; import {StandardHookMetadata} from "../../hooks/libs/StandardHookMetadata.sol"; @@ -13,10 +14,12 @@ import {TokenMessage} from "../../token/libs/TokenMessage.sol"; import {Message} from "../../libs/Message.sol"; import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {NativeCollateral} from "../../token/libs/TokenCollateral.sol"; +import {LpCollateralRouterStorage} from "../../token/libs/LpCollateralRouter.sol"; uint256 constant SCALE = 1; -contract OpL2NativeTokenBridge is HypNative { +contract OpL2NativeTokenBridge is TokenRouter { using TypeCasts for bytes32; using StandardHookMetadata for bytes; using Address for address payable; @@ -29,10 +32,13 @@ contract OpL2NativeTokenBridge is HypNative { // L2 bridge used to initiate the withdrawal IStandardBridge public immutable l2Bridge; + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; + constructor( address _mailbox, address _l2Bridge - ) HypNative(SCALE, _mailbox) { + ) TokenRouter(SCALE, _mailbox) { require(_l2Bridge.isContract(), "L2 bridge must be a contract"); l2Bridge = IStandardBridge(payable(_l2Bridge)); } @@ -45,32 +51,91 @@ contract OpL2NativeTokenBridge is HypNative { _MailboxClient_initialize(_hook, address(0), _owner); } - function quoteTransferRemote( + /** + * @inheritdoc TokenRouter + * @dev Overrides to use the L2 bridge for transferring native tokens and trigger two messages: + * - Prove message with amount 0 to prove the withdrawal + * - Finalize message with the actual amount to finalize the withdrawal + * transferRemote typically has the dispatch of the message as the 4th and final step. However, in this case we want the Hyperlane messageId to be passed via the rollup bridge. + */ + function transferRemote( uint32 _destination, bytes32 _recipient, uint256 _amount - ) external view virtual override returns (Quote[] memory quotes) { - bytes memory message = TokenMessage.format(_recipient, _amount); - uint256 proveQuote = _Router_quoteDispatch( + ) public payable override returns (bytes32) { + // 1. No external fee calculation necessary + require( + _amount > 0, + "OP L2 token bridge: amount must be greater than 0" + ); + + // 2. Prepare the "dispatch" of messages by actually dispatching the Hyperlane messages + + // Dispatch proof message (no token amount) + bytes32 proveMessageId = _Router_dispatch( _destination, - message, + msg.value - _amount, + TokenMessage.format(_recipient, 0), _proveHookMetadata(), address(hook) ); - uint256 finalizeQuote = _Router_quoteDispatch( + + // Dispatch withdrawal message (token + fee) + bytes32 withdrawMessageId = _Router_dispatch( _destination, - message, + address(this).balance - _amount, + TokenMessage.format(_recipient, _amount), _finalizeHookMetadata(), address(hook) ); - quotes = new Quote[](1); - quotes[0] = Quote({ - token: address(0), - amount: proveQuote + finalizeQuote + _amount - }); + + // include for legible error message + require( + address(this).balance >= _amount, + "OP L2 token bridge: insufficient balance" + ); + + // 3. Emit event manually + emit SentTransferRemote(_destination, _recipient, _amount); + + // used for mapping withdrawal to hyperlane prove and finalize messages + bytes memory extraData = OPL2ToL1Withdrawal.encodeData( + proveMessageId, + withdrawMessageId + ); + + // 4. "Dispatch" the message by calling the L2 bridge to transfer native tokens + l2Bridge.bridgeETHTo{value: _amount}( + _recipient.bytes32ToAddress(), + OP_MIN_GAS_LIMIT_ON_L1, + extraData + ); + + if (address(this).balance > 0) { + payable(msg.sender).sendValue(address(this).balance); + } + + return withdrawMessageId; + } + + // needed for hook refunds + receive() external payable {} + + /** + * @inheritdoc Router + */ + function handle(uint32, bytes32, bytes calldata) external payable override { + revert("OP L2 token bridge should not receive messages"); } - function _proveHookMetadata() internal view virtual returns (bytes memory) { + /** + * @inheritdoc TokenRouter + */ + function token() public view override returns (address) { + return address(0); + } + + function _proveHookMetadata() internal view returns (bytes memory) { return StandardHookMetadata.format({ _msgValue: 0, @@ -79,12 +144,7 @@ contract OpL2NativeTokenBridge is HypNative { }); } - function _finalizeHookMetadata() - internal - view - virtual - returns (bytes memory) - { + function _finalizeHookMetadata() internal view returns (bytes memory) { return StandardHookMetadata.format({ _msgValue: 0, @@ -93,67 +153,60 @@ contract OpL2NativeTokenBridge is HypNative { }); } - function _transferRemote( + /** + * @inheritdoc TokenRouter + * @dev Overrides to quote for two messages: prove and finalize. + */ + function _quoteGasPayment( uint32 _destination, bytes32 _recipient, - uint256 _amount, - bytes memory _hookMetadata, - address _hook - ) internal virtual override returns (bytes32) { - require( - _amount > 0, - "OP L2 token bridge: amount must be greater than 0" - ); - - // refund first message fees to address(this) to cover second message - bytes32 proveMessageId = super._transferRemote( + uint256 _amount + ) internal view override returns (uint256) { + bytes memory message = TokenMessage.format(_recipient, _amount); + uint256 proveQuote = _Router_quoteDispatch( _destination, - _recipient, - 0, + message, _proveHookMetadata(), - _hook + address(hook) ); - - bytes32 withdrawMessageId = super._transferRemote( + uint256 finalizeQuote = _Router_quoteDispatch( _destination, - _recipient, - _amount, + message, _finalizeHookMetadata(), - _hook - ); - - // include for legible error message - _transferFromSender(_amount); - - // used for mapping withdrawal to hyperlane prove and finalize messages - bytes memory extraData = OPL2ToL1Withdrawal.encodeData( - proveMessageId, - withdrawMessageId - ); - l2Bridge.bridgeETHTo{value: _amount}( - _recipient.bytes32ToAddress(), - OP_MIN_GAS_LIMIT_ON_L1, - extraData + address(hook) ); + return proveQuote + finalizeQuote; + } - if (address(this).balance > 0) { - address refundAddress = _hookMetadata.getRefundAddress(msg.sender); - require( - refundAddress != address(0), - "OP L2 token bridge: refund address is 0" - ); - payable(refundAddress).sendValue(address(this).balance); - } - - return withdrawMessageId; + /** + * @inheritdoc TokenRouter + */ + function _transferFromSender(uint256 _amount) internal override { + NativeCollateral._transferFromSender(_amount); } - function handle(uint32, bytes32, bytes calldata) external payable override { - revert("OP L2 token bridge should not receive messages"); + /** + * @inheritdoc TokenRouter + */ + function _transferTo( + address _recipient, + uint256 _amount + ) internal override { + // should never be called + assert(false); } } -abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm { +// need intermediate contract to insert slots between TokenRouter and OPL2ToL1CcipReadIsm +abstract contract OpTokenBridgeStorage is TokenRouter { + /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to. + LpCollateralRouterStorage private __LP_COLLATERAL_GAP; +} + +abstract contract OpL1NativeTokenBridge is + OpTokenBridgeStorage, + OPL2ToL1CcipReadIsm +{ using Message for bytes; using TokenMessage for bytes; @@ -168,13 +221,11 @@ abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm { _MailboxClient_initialize(address(0), address(0), _owner); } - function _transferRemote( + function transferRemote( uint32, bytes32, - uint256, - bytes memory, - address - ) internal override returns (bytes32) { + uint256 + ) public payable override returns (bytes32) { revert("OP L1 token bridge should not send messages"); } @@ -185,6 +236,14 @@ abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm { return _message.body().amount() == 0; } + function token() public view override returns (address) { + return address(0); + } + + function _transferFromSender(uint256 _amount) internal override { + assert(false); + } + function _transferTo( address _recipient, uint256 _amount @@ -209,7 +268,7 @@ contract OpL1V1NativeTokenBridge is constructor( address _mailbox, address _opPortal - ) HypNative(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {} + ) TokenRouter(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {} } contract OpL1V2NativeTokenBridge is @@ -219,5 +278,5 @@ contract OpL1V2NativeTokenBridge is constructor( address _mailbox, address _opPortal - ) HypNative(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {} + ) TokenRouter(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {} } diff --git a/solidity/contracts/token/libs/FungibleTokenRouter.sol b/solidity/contracts/token/libs/FungibleTokenRouter.sol deleted file mode 100644 index 12fdee8cd0..0000000000 --- a/solidity/contracts/token/libs/FungibleTokenRouter.sol +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity >=0.8.0; - -import {TokenRouter} from "./TokenRouter.sol"; -import {Quote, ITokenFee} from "../../interfaces/ITokenBridge.sol"; -import {TokenMessage} from "./TokenMessage.sol"; -import {TypeCasts} from "../../libs/TypeCasts.sol"; -import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; - -/** - * @title Hyperlane Fungible Token Router that extends TokenRouter with scaling logic for fungible tokens with different decimals. - * @author Abacus Works - */ -abstract contract FungibleTokenRouter is TokenRouter { - using TokenMessage for bytes; - using TypeCasts for bytes32; - using StorageSlot for bytes32; - - uint256 public immutable scale; - - bytes32 private constant FEE_RECIPIENT_SLOT = - keccak256("FungibleTokenRouter.feeRecipient"); - - event FeeRecipientSet(address feeRecipient); - - constructor(uint256 _scale, address _mailbox) TokenRouter(_mailbox) { - scale = _scale; - } - - /** - * @notice Sets the fee recipient for the router. - * @dev Allows for address(0) to be set, which disables fees. - * @param _feeRecipient The address of the fee recipient. - */ - function setFeeRecipient(address _feeRecipient) public onlyOwner { - FEE_RECIPIENT_SLOT.getAddressSlot().value = _feeRecipient; - emit FeeRecipientSet(_feeRecipient); - } - - function feeRecipient() public view virtual returns (address) { - return FEE_RECIPIENT_SLOT.getAddressSlot().value; - } - - /** - * @inheritdoc ITokenFee - * @dev Returns fungible fee and bridge amounts separately for client to easily distinguish. - */ - function quoteTransferRemote( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) external view virtual override returns (Quote[] memory quotes) { - quotes = new Quote[](2); - quotes[0] = Quote({ - token: address(0), - amount: _quoteGasPayment(_destination, _recipient, _amount) - }); - quotes[1] = Quote({ - token: token(), - amount: _feeAmount(_destination, _recipient, _amount) + _amount - }); - return quotes; - } - - function _feeAmount( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal view virtual returns (uint256 feeAmount) { - if (feeRecipient() == address(0)) { - return 0; - } - - Quote[] memory quotes = ITokenFee(feeRecipient()).quoteTransferRemote( - _destination, - _recipient, - _amount - ); - if (quotes.length == 0) { - return 0; - } - - require( - quotes.length == 1 && quotes[0].token == token(), - "FungibleTokenRouter: fee must match token" - ); - return quotes[0].amount; - } - - /** - * @dev Scales local amount to message amount (up by scale factor). - */ - function _outboundAmount( - uint256 _localAmount - ) internal view virtual returns (uint256 _messageAmount) { - _messageAmount = _localAmount * scale; - } - - /** - * @dev Scales message amount to local amount (down by scale factor). - */ - function _inboundAmount( - uint256 _messageAmount - ) internal view virtual returns (uint256 _localAmount) { - _localAmount = _messageAmount / scale; - } - - function _chargeSender( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) internal virtual returns (uint256 dispatchValue) { - uint256 fee = _feeAmount(_destination, _recipient, _amount); - _transferFromSender(_amount + fee); - if (fee > 0) { - _transferTo(feeRecipient(), fee); - } - return msg.value; - } - - function _beforeDispatch( - uint32 _destination, - bytes32 _recipient, - uint256 _amount - ) - internal - virtual - override - returns (uint256 dispatchValue, bytes memory message) - { - dispatchValue = _chargeSender(_destination, _recipient, _amount); - message = TokenMessage.format(_recipient, _outboundAmount(_amount)); - } - - function _handle( - uint32 _origin, - bytes32, - bytes calldata _message - ) internal virtual override { - bytes32 recipient = _message.recipient(); - uint256 amount = _message.amount(); - - // effects - emit ReceivedTransferRemote(_origin, recipient, amount); - - // interactions - _transferTo(recipient.bytes32ToAddress(), _inboundAmount(amount)); - } -} diff --git a/solidity/contracts/token/libs/LpCollateralRouter.sol b/solidity/contracts/token/libs/LpCollateralRouter.sol index 8c0467057c..44378ed1d5 100644 --- a/solidity/contracts/token/libs/LpCollateralRouter.sol +++ b/solidity/contracts/token/libs/LpCollateralRouter.sol @@ -1,9 +1,33 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0; +// ============ Internal Imports ============ +import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./MovableCollateralRouter.sol"; + +// ============ External Imports ============ import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; -import {MovableCollateralRouter} from "./MovableCollateralRouter.sol"; + +struct LpCollateralRouterStorage { + // MovableCollateralRouter layout + MovableCollateralRouterStorage __MOVABLE_COLLATERAL_GAP; + // ERC4626 layout + // - (ERC20 layout) + mapping(address => uint256) _balances; + mapping(address => mapping(address => uint256)) _allowances; + uint256 _totalSupply; + string _name; + string _symbol; + // @openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol:376 + uint256[45] __ERC20_GAP; + // - (ERC4626 layout) + address _asset; + uint8 _underlyingDecimals; + // @openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol:267 + uint256[49] __ERC4626_GAP; + // user defined fields + uint256 lpAssets; +} abstract contract LpCollateralRouter is MovableCollateralRouter, diff --git a/solidity/contracts/token/libs/MovableCollateralRouter.sol b/solidity/contracts/token/libs/MovableCollateralRouter.sol index 374e1b1534..effa04eb9b 100644 --- a/solidity/contracts/token/libs/MovableCollateralRouter.sol +++ b/solidity/contracts/token/libs/MovableCollateralRouter.sol @@ -1,29 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.0; -import {Router} from "../../client/Router.sol"; -import {FungibleTokenRouter} from "./FungibleTokenRouter.sol"; import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; - +import {TokenRouter} from "./TokenRouter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Router} from "../../client/Router.sol"; +import {Quotes} from "./Quotes.sol"; -abstract contract MovableCollateralRouter is FungibleTokenRouter { +struct MovableCollateralRouterStorage { + mapping(uint32 routerDomain => bytes32 recipient) recipient; + mapping(uint32 routerDomain => EnumerableSet.AddressSet bridges) bridges; + EnumerableSet.AddressSet rebalancers; +} + +abstract contract MovableCollateralRouter is TokenRouter { using SafeERC20 for IERC20; using EnumerableSet for EnumerableSet.AddressSet; + using Quotes for Quote[]; - /// @notice Mapping of domain to allowed rebalance recipient. - /// @dev Keys constrained to a subset of Router.domains() - mapping(uint32 routerDomain => bytes32 recipient) public allowedRecipient; - - /// @notice Mapping of domain to allowed rebalance bridges. - /// @dev Keys constrained to a subset of Router.domains() - mapping(uint32 routerDomain => EnumerableSet.AddressSet bridges) - internal _allowedBridges; - - /// @notice Set of addresses that are allowed to rebalance. - EnumerableSet.AddressSet internal _allowedRebalancers; + MovableCollateralRouterStorage private allowed; event CollateralMoved( uint32 indexed domain, @@ -34,36 +31,45 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { modifier onlyRebalancer() { require( - _allowedRebalancers.contains(_msgSender()), + allowed.rebalancers.contains(_msgSender()), "MCR: Only Rebalancer" ); _; } modifier onlyAllowedBridge(uint32 domain, ITokenBridge bridge) { - EnumerableSet.AddressSet storage bridges = _allowedBridges[domain]; + EnumerableSet.AddressSet storage bridges = allowed.bridges[domain]; require(bridges.contains(address(bridge)), "MCR: Not allowed bridge"); _; } + /// @notice Set of addresses that are allowed to rebalance. function allowedRebalancers() external view returns (address[] memory) { - return _allowedRebalancers.values(); + return allowed.rebalancers.values(); } + /// @notice Mapping of domain to allowed rebalance recipient. + /// @dev Keys constrained to a subset of Router.domains() + function allowedRecipient(uint32 domain) external view returns (bytes32) { + return allowed.recipient[domain]; + } + + /// @notice Mapping of domain to allowed rebalance bridges. + /// @dev Keys constrained to a subset of Router.domains() function allowedBridges( uint32 domain ) external view returns (address[] memory) { - return _allowedBridges[domain].values(); + return allowed.bridges[domain].values(); } function setRecipient(uint32 domain, bytes32 recipient) external onlyOwner { // constrain to a subset of Router.domains() _mustHaveRemoteRouter(domain); - allowedRecipient[domain] = recipient; + allowed.recipient[domain] = recipient; } function removeRecipient(uint32 domain) external onlyOwner { - delete allowedRecipient[domain]; + delete allowed.recipient[domain]; } function addBridge(uint32 domain, ITokenBridge bridge) external onlyOwner { @@ -73,7 +79,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { } function _addBridge(uint32 domain, ITokenBridge bridge) internal virtual { - _allowedBridges[domain].add(address(bridge)); + allowed.bridges[domain].add(address(bridge)); } function removeBridge( @@ -87,7 +93,7 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { uint32 domain, ITokenBridge bridge ) internal virtual { - _allowedBridges[domain].remove(address(bridge)); + allowed.bridges[domain].remove(address(bridge)); } /** @@ -104,24 +110,24 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { } function addRebalancer(address rebalancer) external onlyOwner { - _allowedRebalancers.add(rebalancer); + allowed.rebalancers.add(rebalancer); } function removeRebalancer(address rebalancer) external onlyOwner { - _allowedRebalancers.remove(rebalancer); + allowed.rebalancers.remove(rebalancer); } /** * @notice Rebalances the collateral between router domains. * @param domain The domain to rebalance to. - * @param amount The amount of collateral to rebalance. + * @param collateralAmount The amount of collateral to rebalance. * @param bridge The bridge to use for the rebalance. * @dev The caller must be an allowed rebalancer and the bridge must be an allowed bridge for the domain. * @dev The recipient is the enrolled router if no recipient is set for the domain. */ function rebalance( uint32 domain, - uint256 amount, + uint256 collateralAmount, ITokenBridge bridge ) external payable onlyRebalancer onlyAllowedBridge(domain, bridge) { bytes32 recipient = _recipient(domain); @@ -129,38 +135,36 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { Quote[] memory quotes = bridge.quoteTransferRemote( domain, recipient, - amount + collateralAmount ); - if (quotes.length > 0) { - require( - quotes[quotes.length - 1].token == token(), - "MCR: collateral token mismatch" - ); - uint256 collateralFee = quotes[quotes.length - 1].amount; - - // charge the rebalancer any bridging fees denominated in the collateral - // token to avoid undercollateralization - if (collateralFee > amount) { - _transferFromSender(collateralFee - amount); - } + // charge the rebalancer any bridging fees denominated in the collateral + // token to avoid undercollateralization + uint256 collateralFees = quotes.extract(token()); + if (collateralFees > collateralAmount) { + _transferFromSender(collateralFees - collateralAmount); } - uint256 nativeValue = _nativeRebalanceValue(amount); - bridge.transferRemote{value: nativeValue}(domain, recipient, amount); - emit CollateralMoved(domain, recipient, amount, msg.sender); - } + // need to handle native quote separately from collateral quote because + // token() may be address(0), in which case we need to use address(this).balance + // to move native collateral tokens across chains + uint256 nativeFees = quotes.extract(address(0)); + if (nativeFees > address(this).balance) { + revert("Rebalance native fee exceeds balance"); + } - function _nativeRebalanceValue( - uint256 /*amount*/ - ) internal virtual returns (uint256 nativeValue) { - return msg.value; + bridge.transferRemote{value: nativeFees}( + domain, + recipient, + collateralAmount + ); + emit CollateralMoved(domain, recipient, collateralAmount, msg.sender); } function _recipient( uint32 domain ) internal view returns (bytes32 recipient) { - recipient = allowedRecipient[domain]; + recipient = allowed.recipient[domain]; if (recipient == bytes32(0)) { recipient = _mustHaveRemoteRouter(domain); } @@ -184,8 +188,8 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter { /// @dev Constrains keys of rebalance mappings to Router.domains() function _unenrollRemoteRouter(uint32 domain) internal override { - delete allowedRecipient[domain]; - _clear(_allowedBridges[domain]._inner); + delete allowed.recipient[domain]; + _clear(allowed.bridges[domain]._inner); Router._unenrollRemoteRouter(domain); } } diff --git a/solidity/contracts/token/libs/Quotes.sol b/solidity/contracts/token/libs/Quotes.sol new file mode 100644 index 0000000000..6f4d61525b --- /dev/null +++ b/solidity/contracts/token/libs/Quotes.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; + +import {Quote} from "../../interfaces/ITokenBridge.sol"; + +library Quotes { + function extract( + Quote[] memory quotes, + address token + ) internal pure returns (uint256 sum) { + sum = 0; + for (uint256 i = 0; i < quotes.length; i++) { + if (quotes[i].token == token) { + sum += quotes[i].amount; + } + } + } +} diff --git a/solidity/contracts/token/libs/TokenCollateral.sol b/solidity/contracts/token/libs/TokenCollateral.sol new file mode 100644 index 0000000000..2435c467a3 --- /dev/null +++ b/solidity/contracts/token/libs/TokenCollateral.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; + +import {IWETH} from "../interfaces/IWETH.sol"; + +// ============ External Imports ============ +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/** + * @title Handles deposits and withdrawals of native token collateral. + */ +library NativeCollateral { + function _transferFromSender(uint256 _amount) internal { + require(msg.value >= _amount, "Native: amount exceeds msg.value"); + } + + function _transferTo(address _recipient, uint256 _amount) internal { + Address.sendValue(payable(_recipient), _amount); + } +} + +/** + * @title Handles deposits and withdrawals of WETH collateral. + */ +library WETHCollateral { + function _transferFromSender(IWETH token, uint256 _amount) internal { + NativeCollateral._transferFromSender(_amount); + token.deposit{value: _amount}(); + } + + function _transferTo( + IWETH token, + address _recipient, + uint256 _amount + ) internal { + token.withdraw(_amount); + NativeCollateral._transferTo(_recipient, _amount); + } +} + +/** + * @title Handles deposits and withdrawals of ERC20 collateral. + */ +library ERC20Collateral { + using SafeERC20 for IERC20; + + function _transferFromSender(IERC20 token, uint256 _amount) internal { + token.safeTransferFrom(msg.sender, address(this), _amount); + } + + function _transferTo( + IERC20 token, + address _recipient, + uint256 _amount + ) internal { + token.safeTransfer(_recipient, _amount); + } +} + +/** + * @title Handles deposits and withdrawals of ERC721 collateral. + */ +library ERC721Collateral { + function _transferFromSender(IERC721 token, uint256 _tokenId) internal { + // safeTransferFrom not used here because recipient is this contract + token.transferFrom(msg.sender, address(this), _tokenId); + } + + function _transferTo( + IERC721 token, + address _recipient, + uint256 _tokenId + ) internal { + token.safeTransferFrom(address(this), _recipient, _tokenId); + } +} diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index 455c8fbb28..d584434f32 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -5,16 +5,25 @@ pragma solidity >=0.8.0; import {TypeCasts} from "../../libs/TypeCasts.sol"; import {GasRouter} from "../../client/GasRouter.sol"; import {TokenMessage} from "./TokenMessage.sol"; -import {Quote, ITokenBridge} from "../../interfaces/ITokenBridge.sol"; +import {Quote, ITokenBridge, ITokenFee} from "../../interfaces/ITokenBridge.sol"; +import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; /** * @title Hyperlane Token Router that extends Router with abstract token (ERC20/ERC721) remote transfer functionality. + * @dev Overridable functions: + * - token(): specify the managed token address + * - _transferFromSender(uint256): pull tokens/ETH from msg.sender + * - _transferTo(address,uint256): send tokens/ETH to the recipient + * - _externalFeeAmount(uint32,bytes32,uint256): compute external fees (default returns 0) + * @dev Override transferRemote only to implement custom logic that can't be accomplished with the above functions. + * * @author Abacus Works */ abstract contract TokenRouter is GasRouter, ITokenBridge { using TypeCasts for bytes32; using TypeCasts for address; using TokenMessage for bytes; + using StorageSlot for bytes32; /** * @dev Emitted on `transferRemote` when a transfer message is dispatched. @@ -40,174 +49,344 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { uint256 amountOrId ); - constructor(address _mailbox) GasRouter(_mailbox) {} + uint256 public immutable scale; + + // cannot use compiler assigned slot without + // breaking backwards compatibility of storage layout + bytes32 private constant FEE_RECIPIENT_SLOT = + keccak256("FungibleTokenRouter.feeRecipient"); + + event FeeRecipientSet(address feeRecipient); + + constructor(uint256 _scale, address _mailbox) GasRouter(_mailbox) { + scale = _scale; + } + + // =========================== + // ========== Main API ========== + // =========================== /** - * @notice Returns the address of the token managed by this router. + * @notice Returns the address of the token managed by this router. It can be one of three options: + * - ERC20 token address for fungible tokens that are being collateralized (HypERC20Collateral, HypERC4626, etc.) + * - 0x0 address for native tokens (ETH, MATIC, etc.) (HypNative, etc.) + * - address(this) for synthetic ERC20 tokens (HypERC20, etc.) + * It is being used for quotes and fees from the fee recipient and pulling/push the tokens from the sender/receipient. * @dev This function must be implemented by derived contracts to specify the token address. * @return The address of the token contract. */ function token() public view virtual returns (address); /** - * @notice Transfers `_amountOrId` token to `_recipient` on `_destination` domain. - * @dev Delegates transfer logic to `_transferFromSender` implementation. - * @dev Emits `SentTransferRemote` event on the origin chain. + * @inheritdoc ITokenFee + * @notice Implements the standardized fee quoting interface for token transfers based on + * overridable internal functions of _quoteGasPayment, _feeRecipientAmount, and _externalFeeAmount. * @param _destination The identifier of the destination chain. * @param _recipient The address of the recipient on the destination chain. - * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient. - * @return messageId The identifier of the dispatched message. + * @param _amount The amount or identifier of tokens to be sent to the remote recipient + * @return quotes An array of Quote structs representing the fees in different tokens. + * @dev This function may return multiple quotes with the same denomination. Convention is to return: + * [index 0] native fees charged by the mailbox dispatch + * [index 1] then any internal warp route fees (amount bridged plus fee recipient) + * [index 2] then any external bridging fees (if any, else 0) + * These are surfaced as separate elements to enable clients to interpret/render fees independently. + * There is a Quotes library with an extract function for onchain quoting in a specific denomination, + * but we discourage onchain quoting in favor of offchain quoting and overpaying with refunds. */ - function transferRemote( + function quoteTransferRemote( uint32 _destination, bytes32 _recipient, - uint256 _amountOrId - ) external payable virtual returns (bytes32 messageId) { - return - _transferRemote( - _destination, - _recipient, - _amountOrId, - _GasRouter_hookMetadata(_destination), - address(hook) - ); + uint256 _amount + ) external view override returns (Quote[] memory quotes) { + quotes = new Quote[](3); + quotes[0] = Quote({ + token: address(0), + amount: _quoteGasPayment(_destination, _recipient, _amount) + }); + quotes[1] = Quote({ + token: token(), + amount: _amount + + _feeRecipientAmount(_destination, _recipient, _amount) + }); + quotes[2] = Quote({ + token: token(), + amount: _externalFeeAmount(_destination, _recipient, _amount) + }); } /** - * @notice Transfers `_amountOrId` token to `_recipient` on `_destination` domain with a specified hook + * @notice Transfers `_amount` token to `_recipient` on the `_destination` domain. * @dev Delegates transfer logic to `_transferFromSender` implementation. - * @dev The metadata is the token metadata, and is DIFFERENT than the hook metadata. - * @dev Emits `SentTransferRemote` event on the origin chain. + * Emits `SentTransferRemote` event on the origin chain. + * Override with custom behavior for storing or forwarding tokens. + * Known overrides: + * - OPL2ToL1TokenBridgeNative: adds hook metadata for message dispatch. + * - EverclearTokenBridge: creates Everclear intent for cross-chain token transfer. + * - TokenBridgeCctpBase: adds CCTP-specific metadata for message dispatch. + * - HypERC4626Collateral: deposits into vault and handles shares. + * When overriding, mirror the general flow of this function for consistency: + * 1. Calculate fees and charge the sender. + * 2. Prepare the token message with recipient, amount, and any additional metadata. + * 3. Emit `SentTransferRemote` event. + * 4. Dispatch the message. * @param _destination The identifier of the destination chain. * @param _recipient The address of the recipient on the destination chain. - * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient. - * @param _hookMetadata The metadata passed into the hook - * @param _hook The post dispatch hook to be called by the Mailbox + * @param _amount The amount or identifier of tokens to be sent to the remote recipient. * @return messageId The identifier of the dispatched message. */ function transferRemote( uint32 _destination, bytes32 _recipient, - uint256 _amountOrId, - bytes calldata _hookMetadata, - address _hook - ) external payable virtual returns (bytes32 messageId) { + uint256 _amount + ) public payable virtual returns (bytes32 messageId) { + // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary + (, uint256 remainingNativeValue) = _calculateFeesAndCharge( + _destination, + _recipient, + _amount, + msg.value + ); + + // 2. Prepare the token message with the recipient and amount + bytes memory _tokenMessage = TokenMessage.format( + _recipient, + _outboundAmount(_amount) + ); + + // 3. Emit the SentTransferRemote event and 4. dispatch the message return - _transferRemote( + _emitAndDispatch( _destination, _recipient, - _amountOrId, - _hookMetadata, - _hook + _amount, + remainingNativeValue, + _tokenMessage ); } - function _transferRemote( + // =========================== + // ========== Internal convenience functions for readability ========== + // ========================== + function _calculateFeesAndCharge( uint32 _destination, bytes32 _recipient, - uint256 _amountOrId, - bytes memory _hookMetadata, - address _hook - ) internal virtual returns (bytes32 messageId) { - // checks - (uint256 _dispatchValue, bytes memory _tokenMessage) = _beforeDispatch( + uint256 _amount, + uint256 _msgValue + ) internal returns (uint256 externalFee, uint256 remainingNativeValue) { + uint256 feeRecipientFee = _feeRecipientAmount( _destination, _recipient, - _amountOrId + _amount ); + externalFee = _externalFeeAmount(_destination, _recipient, _amount); + uint256 charge = _amount + feeRecipientFee + externalFee; + _transferFromSender(charge); + if (feeRecipientFee > 0) { + // transfer atomically so we don't need to keep track of collateral + // and fee balances separately + _transferTo(feeRecipient(), feeRecipientFee); + } + remainingNativeValue = token() != address(0) + ? _msgValue + : _msgValue - charge; + } + // Emits the SentTransferRemote event and dispatches the message. + function _emitAndDispatch( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + uint256 _messageDispatchValue, + bytes memory _tokenMessage + ) internal returns (bytes32 messageId) { // effects - emit SentTransferRemote(_destination, _recipient, _amountOrId); + emit SentTransferRemote(_destination, _recipient, _amount); // interactions messageId = _Router_dispatch( _destination, - _dispatchValue, + _messageDispatchValue, _tokenMessage, - _hookMetadata, - _hook + _GasRouter_hookMetadata(_destination), + address(hook) ); } + // =========================== + // ========== Fees & Quoting ========== + // =========================== + /** - * @dev Called by `transferRemote` before message dispatch. - * @dev Can be overriden to add metadata to the message. - * @dev Can be overriden to change the value forwarded to the mailbox. - * @param _destination The identifier of the destination chain. - * @param _recipient The address of the recipient on the destination chain. - * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient. - * @return dispatchValue The value to be forwarded to the mailbox. - * @return message The message to the router on the destination chain. + * @notice Sets the fee recipient for the router. + * @dev Allows for address(0) to be set, which disables fees. + * @param _feeRecipient The address of the fee recipient. */ - function _beforeDispatch( - uint32 _destination, - bytes32 _recipient, - uint256 _amountOrId - ) internal virtual returns (uint256 dispatchValue, bytes memory message) { - _transferFromSender(_amountOrId); + function setFeeRecipient(address _feeRecipient) public onlyOwner { + FEE_RECIPIENT_SLOT.getAddressSlot().value = _feeRecipient; + emit FeeRecipientSet(_feeRecipient); + } - dispatchValue = msg.value; - message = TokenMessage.format(_recipient, _amountOrId); + /** + * @notice Returns the address of the fee recipient. + * @dev Returns address(0) if no fee recipient is set. + * @dev Can be overriden with address(0) to disable fees entirely. + * @return address of the fee recipient. + */ + function feeRecipient() public view virtual returns (address) { + return FEE_RECIPIENT_SLOT.getAddressSlot().value; } + // To be overridden by derived contracts if they have additional fees /** - * @dev Should transfer `_amountOrId` of tokens from `msg.sender` to this token router. - * @dev Called by `transferRemote` before message dispatch. + * @notice Returns the external fee amount for the given parameters. + * param _destination The identifier of the destination chain. + * param _recipient The address of the recipient on the destination chain. + * param _amount The amount or identifier of tokens to be sent to the remote recipient + * @return feeAmount The external fee amount. + * @dev The default implementation returns 0, meaning no external fees are charged. + * This function is intended to be overridden by derived contracts that have additional fees. + * Known overrides: + * - TokenBridgeCctpBase: for CCTP-specific fees + * - EverclearTokenBridge: for Everclear-specific fees */ - function _transferFromSender(uint256 _amountOrId) internal virtual; + function _externalFeeAmount( + uint32, // _destination, + bytes32, // _recipient, + uint256 // _amount + ) internal view virtual returns (uint256 feeAmount) { + return 0; + } /** - * @notice Returns the gas payment required to dispatch a message to the given domain's router. - * @param _destination The domain of the router. + * @notice Returns the fee recipient amount for the given parameters. + * @param _destination The identifier of the destination chain. * @param _recipient The address of the recipient on the destination chain. - * @param _amount The amount of tokens to be sent to the remote recipient. - * @dev This should be overridden for warp routes that require additional fees/approvals. - * @return quotes Indicate how much of each token to approve and/or send. + * @param _amount The amount or identifier of tokens to be sent to the remote recipient + * @return feeAmount The fee recipient amount. + * @dev This function is is not intended to be overridden as storage and logic is contained in TokenRouter. */ - function quoteTransferRemote( + function _feeRecipientAmount( uint32 _destination, bytes32 _recipient, uint256 _amount - ) external view virtual override returns (Quote[] memory quotes) { - quotes = new Quote[](1); - quotes[0] = Quote({ - token: address(0), - amount: _quoteGasPayment(_destination, _recipient, _amount) - }); + ) internal view returns (uint256 feeAmount) { + if (feeRecipient() == address(0)) { + return 0; + } + + Quote[] memory quotes = ITokenFee(feeRecipient()).quoteTransferRemote( + _destination, + _recipient, + _amount + ); + if (quotes.length == 0) { + return 0; + } + + require( + quotes.length == 1 && quotes[0].token == token(), + "FungibleTokenRouter: fee must match token" + ); + return quotes[0].amount; } /** - * DEPRECATED: Use `quoteTransferRemote` instead. * @notice Returns the gas payment required to dispatch a message to the given domain's router. - * @param _destinationDomain The domain of the router. - * @dev Assumes bytes32(0) recipient and max amount of tokens for quoting. + * @param _destination The identifier of the destination chain. + * @param _recipient The address of the recipient on the destination chain. + * @param _amount The amount or identifier of tokens to be sent to the remote recipient * @return payment How much native value to send in transferRemote call. + * @dev This function is intended to be overridden by derived contracts that trigger multiple messages. + * Known overrides: + * - OPL2ToL1TokenBridgeNative: Quote for two messages (prove and finalize). */ - function quoteGasPayment( - uint32 _destinationDomain - ) public view virtual override returns (uint256) { - return - _quoteGasPayment(_destinationDomain, bytes32(0), type(uint256).max); - } - function _quoteGasPayment( - uint32 _destinationDomain, + uint32 _destination, bytes32 _recipient, uint256 _amount - ) internal view returns (uint256) { + ) internal view virtual returns (uint256) { return - _GasRouter_quoteDispatch( - _destinationDomain, + _Router_quoteDispatch( + _destination, TokenMessage.format(_recipient, _amount), + _GasRouter_hookMetadata(_destination), address(hook) ); } + // =========================== + // ========== Internal virtual functions for token handling ========== + // =========================== + + /** + * @dev Should transfer `_amount` of tokens from `msg.sender` to this token router. + * Called by `transferRemote` before message dispatch. + * Known overrides: + * - HypERC20: Burns the tokens from the sender. + * - HypERC20Collateral: Pulls the tokens from the sender. + * - HypNative: Asserts msg.value >= _amount + * - TokenBridgeCctpBase: (like HypERC20Collateral) Pulls the tokens from the sender. + * - EverclearEthTokenBridge: Wraps the native token (ETH) to WETH + * - HypERC4626: Converts the amounts to shares and burns from the User (via HypERC20 implementation) + * - HypFiatToken: Pulls the tokens from the sender and burns them on the FiatToken contract. + * - HypXERC20: Burns the tokens from the sender. + * - HypXERC20Lockbox: Pulls the tokens from the sender, locks them in the XERC20Lockbox contract and burns the resulting xERC20 tokens. + */ + function _transferFromSender(uint256 _amountOrId) internal virtual; + + /** + * @dev Should transfer `_amountOrId` of tokens from this token router to `_recipient`. + * @dev Called by `handle` after message decoding. + * Known overrides: + * - HypERC20: Mints the tokens to the recipient. + * - HypERC20Collateral: Releases the tokens to the recipient. + * - HypNative: Releases native tokens to the recipient. + * - TokenBridgeCctpBase: Do nothing (CCTP transfers tokens to the recipient directly). + * - EverclearEthTokenBridge: Unwraps WETH to ETH and sends to the recipient. + * - HypERC4626: Converts the amount to shares and mints to the User (via HypERC20 implementation) + * - HypFiatToken: Mints the tokens to the recipient on the FiatToken contract. + * - HypXERC20: Mints the tokens to the recipient. + * - HypXERC20Lockbox: Withdraws the underlying tokens from the Lockbox and sends to the recipient. + * - OpL1NativeTokenBridge: Do nothing (the L2 bridge transfers the native tokens to the recipient directly). + */ + function _transferTo( + address _recipient, + uint256 _amountOrId + ) internal virtual; + + /** + * @dev Scales local amount to message amount (up by scale factor). + * Known overrides: + * - HypERC4626: Scales by exchange rate + */ + function _outboundAmount( + uint256 _localAmount + ) internal view virtual returns (uint256 _messageAmount) { + _messageAmount = _localAmount * scale; + } + + /** + * @dev Scales message amount to local amount (down by scale factor). + * Known overrides: + * - HypERC4626: Scales by exchange rate + */ + function _inboundAmount( + uint256 _messageAmount + ) internal view virtual returns (uint256 _localAmount) { + _localAmount = _messageAmount / scale; + } + /** - * @dev Mints tokens to recipient when router receives transfer message. - * @dev Emits `ReceivedTransferRemote` event on the destination chain. + * @notice Handles the incoming transfer message. + * It decodes the message, emits the ReceivedTransferRemote event, and transfers tokens to the recipient. * @param _origin The identifier of the origin chain. - * @param _message The encoded remote transfer message containing the recipient address and amount. + * @dev param _sender The address of the sender router on the origin chain. + * @param _message The message data containing recipient and amount. + * @dev Override this function if custom logic is required for sending out the tokens. + * Known overrides: + * - EverclearTokenBridge: Receives the tokens and sends them to the recipient. + * - EverclearEthBridge: Receives WETH, unwraps it and sends native ETH to the recipient. + * - HypERC4626: Updates the exchange rate from the metadata */ function _handle( uint32 _origin, @@ -221,15 +400,6 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { emit ReceivedTransferRemote(_origin, recipient, amount); // interactions - _transferTo(recipient.bytes32ToAddress(), amount); + _transferTo(recipient.bytes32ToAddress(), _inboundAmount(amount)); } - - /** - * @dev Should transfer `_amountOrId` of tokens from this token router to `_recipient`. - * @dev Called by `handle` after message decoding. - */ - function _transferTo( - address _recipient, - uint256 _amountOrId - ) internal virtual; } diff --git a/solidity/script/xerc20/ezETH.s.sol b/solidity/script/xerc20/ezETH.s.sol index e0bd531836..5cdbbdb6db 100644 --- a/solidity/script/xerc20/ezETH.s.sol +++ b/solidity/script/xerc20/ezETH.s.sol @@ -84,7 +84,8 @@ contract ezETH is Script { vm.prank(0x7BE481D464CAD7ad99500CE8A637599eB8d0FCDB); // ezETH whale IXERC20(blastXERC20).transfer(address(this), amount); IXERC20(blastXERC20).approve(address(hypXERC20), amount); - uint256 value = hypXERC20.quoteGasPayment(ethereumDomainId); + uint256 value = hypXERC20 + .quoteTransferRemote(ethereumDomainId, recipient, amount)[0].amount; hypXERC20.transferRemote{value: value}( ethereumDomainId, recipient, diff --git a/solidity/test/GasRouter.t.sol b/solidity/test/GasRouter.t.sol index becd43cf38..525c372371 100644 --- a/solidity/test/GasRouter.t.sol +++ b/solidity/test/GasRouter.t.sol @@ -77,14 +77,20 @@ contract GasRouterTest is Test { assertEq(originRouter.destinationGas(remoteDomain), gas); } - function testQuoteGasPayment(uint256 gas) public { + function testQuoteGasPayment(uint256 gas, bytes memory body) public { vm.assume(gas > 0 && type(uint256).max / gas > gasPrice); setDestinationGas(originRouter, remoteDomain, gas); - assertEq(originRouter.quoteGasPayment(remoteDomain), gas * gasPrice); + assertEq( + originRouter.quoteDispatch(remoteDomain, body), + gas * gasPrice + ); setDestinationGas(remoteRouter, originDomain, gas); - assertEq(remoteRouter.quoteGasPayment(originDomain), gas * gasPrice); + assertEq( + remoteRouter.quoteDispatch(originDomain, body), + gas * gasPrice + ); } uint256 refund = 0; diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 8bb3073494..2f0829f86e 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -23,8 +23,7 @@ import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.so import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; import {MockHyperlaneEnvironment} from "../../contracts/mock/MockHyperlaneEnvironment.sol"; import {Message} from "../../contracts/libs/Message.sol"; -import {EverclearTokenBridge, OutputAssetInfo} from "../../contracts/token/bridge/EverclearTokenBridge.sol"; -import {EverclearEthBridge} from "../../contracts/token/bridge/EverclearEthBridge.sol"; +import {EverclearBridge, EverclearEthBridge, EverclearTokenBridge, OutputAssetInfo} from "../../contracts/token/bridge/EverclearTokenBridge.sol"; import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../contracts/interfaces/IEverclearAdapter.sol"; import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; @@ -189,7 +188,7 @@ contract EverclearTokenBridgeTest is Test { TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(implementation), PROXY_ADMIN, - abi.encodeCall(EverclearTokenBridge.initialize, (address(0), OWNER)) + abi.encodeCall(EverclearBridge.initialize, (address(0), OWNER)) ); bridge = EverclearTokenBridge(address(proxy)); @@ -350,11 +349,13 @@ contract EverclearTokenBridgeTest is Test { TRANSFER_AMT ); - assertEq(quotes.length, 2); + assertEq(quotes.length, 3); assertEq(quotes[0].token, address(0)); assertEq(quotes[0].amount, 0); // Gas payment is 0 for test dispatch hooks assertEq(quotes[1].token, address(token)); - assertEq(quotes[1].amount, TRANSFER_AMT + FEE_AMOUNT); + assertEq(quotes[1].amount, TRANSFER_AMT); + assertEq(quotes[2].token, address(token)); + assertEq(quotes[2].amount, FEE_AMOUNT); } // ============ transferRemote Tests ============ @@ -498,7 +499,8 @@ contract EverclearTokenBridgeTest is Test { RECIPIENT, transferAmount ); - uint256 tokenCost = quotes[1].amount; // Token cost including fee + uint256 tokenCost = quotes[1].amount; + uint256 fee = quotes[2].amount; // 2. Execute transfer vm.prank(ALICE); @@ -509,7 +511,7 @@ contract EverclearTokenBridgeTest is Test { ); // 3. Verify state changes - assertEq(token.balanceOf(ALICE), initialAliceBalance - tokenCost); + assertEq(token.balanceOf(ALICE), initialAliceBalance - tokenCost - fee); // 4. Verify Everclear intent was created correctly assertEq(everclearAdapter.newIntentCallCount(), 1); @@ -644,7 +646,7 @@ contract BaseEverclearTokenBridgeForkTest is Test { TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(implementation), PROXY_ADMIN, - abi.encodeCall(EverclearTokenBridge.initialize, (address(0), OWNER)) + abi.encodeCall(EverclearBridge.initialize, (address(0), OWNER)) ); return address(proxy); @@ -828,7 +830,7 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { address(implementation), PROXY_ADMIN, abi.encodeCall( - EverclearTokenBridge.initialize, + EverclearBridge.initialize, (address(new TestPostDispatchHook()), OWNER) ) ); @@ -881,7 +883,7 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { vm.deal(ALICE, totalAmount - 1); vm.prank(ALICE); - vm.expectRevert("EEB: ETH amount mismatch"); + vm.expectRevert("Native: amount exceeds msg.value"); ethBridge.transferRemote{value: totalAmount - 1}( OPTIMISM_DOMAIN, RECIPIENT, @@ -898,9 +900,13 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { amount ); - assertEq(quotes.length, 1); + assertEq(quotes.length, 3); assertEq(quotes[0].token, address(0)); - assertEq(quotes[0].amount, amount + FEE_AMOUNT); + assertEq(quotes[0].amount, 0); + assertEq(quotes[1].token, address(0)); + assertEq(quotes[1].amount, amount); + assertEq(quotes[2].token, address(0)); + assertEq(quotes[2].amount, FEE_AMOUNT); } function testEthBridgeConstructor() public { @@ -916,7 +922,7 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { address(newBridge.everclearAdapter()), address(everclearAdapter) ); - assertEq(address(newBridge.token()), address(weth)); + assertEq(address(newBridge.token()), address(0)); } function testFork_receiveMessage(uint256 amount) public { diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol index ec7312eb33..ca7d0d0902 100644 --- a/solidity/test/token/HypERC20.t.sol +++ b/solidity/test/token/HypERC20.t.sol @@ -25,7 +25,7 @@ import {TestInterchainGasPaymaster} from "../../contracts/test/TestInterchainGas import {GasRouter} from "../../contracts/client/GasRouter.sol"; import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol"; import {LinearFee} from "../../contracts/token/fees/LinearFee.sol"; -import {FungibleTokenRouter} from "../../contracts/token/libs/FungibleTokenRouter.sol"; +import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol"; import {Router} from "../../contracts/client/Router.sol"; import {HypERC20} from "../../contracts/token/HypERC20.sol"; @@ -63,7 +63,7 @@ abstract contract HypTokenTest is Test { address internal constant PROXY_ADMIN = address(0x37); ERC20Test internal primaryToken; - FungibleTokenRouter internal localToken; + TokenRouter internal localToken; HypERC20 internal remoteToken; MockMailbox internal localMailbox; MockMailbox internal remoteMailbox; @@ -246,42 +246,6 @@ abstract contract HypTokenTest is Test { _performRemoteTransferAndGas(_msgValue, _amount, _gasOverhead); } - function _performRemoteTransferWithHook( - uint256 _msgValue, - uint256 _amount, - address _hook, - bytes memory _hookMetadata - ) internal returns (bytes32 messageId) { - vm.prank(ALICE); - messageId = localToken.transferRemote{value: _msgValue}( - DESTINATION, - BOB.addressToBytes32(), - _amount, - _hookMetadata, - address(_hook) - ); - _processTransfers(); - assertEq(remoteToken.balanceOf(BOB), _amount); - } - - function testTransfer_withHookSpecified( - uint256 fee, - bytes calldata metadata - ) public virtual { - TestPostDispatchHook hook = new TestPostDispatchHook(); - hook.setFee(fee); - - vm.prank(ALICE); - primaryToken.approve(address(localToken), TRANSFER_AMT); - bytes32 messageId = _performRemoteTransferWithHook( - REQUIRED_VALUE, - TRANSFER_AMT, - address(hook), - metadata - ); - assertTrue(hook.messageDispatched(messageId)); - } - function testBenchmark_overheadGasUsage() public virtual { vm.prank(address(localMailbox)); @@ -775,24 +739,6 @@ contract HypNativeTest is HypTokenTest { return _account.balance; } - function testTransfer_withHookSpecified( - uint256 fee, - bytes calldata metadata - ) public override { - TestPostDispatchHook hook = new TestPostDispatchHook(); - hook.setFee(fee); - - uint256 value = REQUIRED_VALUE + TRANSFER_AMT; - - bytes32 messageId = _performRemoteTransferWithHook( - value, - TRANSFER_AMT, - address(hook), - metadata - ); - assertTrue(hook.messageDispatched(messageId)); - } - function testRemoteTransfer() public { _performRemoteTransferWithEmit( REQUIRED_VALUE, @@ -842,9 +788,7 @@ contract HypNativeTest is HypTokenTest { nativeToken.transferRemote{value: nativeValue}( DESTINATION, bRecipient, - nativeValue + 1, - bytes(""), - address(0) + nativeValue + 1 ); } } @@ -926,24 +870,6 @@ contract HypERC20ScaledTest is HypTokenTest { _handleLocalTransfer(TRANSFER_AMT); } - function testTransfer_withHookSpecified( - uint256 fee, - bytes calldata metadata - ) public override { - TestPostDispatchHook hook = new TestPostDispatchHook(); - hook.setFee(fee); - - vm.prank(ALICE); - bytes32 messageId = localToken.transferRemote{value: REQUIRED_VALUE}( - DESTINATION, - BOB.addressToBytes32(), - TRANSFER_AMT, - metadata, - address(hook) - ); - assertTrue(hook.messageDispatched(messageId)); - } - function _getBalances( address sender, address recipient diff --git a/solidity/test/token/HypERC20MovableCollateral.t.sol b/solidity/test/token/HypERC20MovableCollateral.t.sol index 00623d2f2b..5e4658f6b5 100644 --- a/solidity/test/token/HypERC20MovableCollateral.t.sol +++ b/solidity/test/token/HypERC20MovableCollateral.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.13; import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol"; import {MockITokenBridge} from "./MovableCollateralRouter.t.sol"; import {HypERC20Collateral} from "contracts/token/HypERC20Collateral.sol"; -// import {HypERC20MovableCollateral} from "contracts/token/HypERC20MovableCollateral.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20Test} from "../../contracts/test/ERC20Test.sol"; import {MockMailbox} from "contracts/mock/MockMailbox.sol"; @@ -48,12 +48,14 @@ contract HypERC20MovableCollateralRouterTest is Test { router.addBridge(destinationDomain, vtb); } - function testMovingCollateral() public { + function test_rebalance() public { // Configuration _configure(bytes32(uint256(uint160(alice)))); + uint256 amount = 1e18; + // Setup - approvals happen automatically - token.mintTo(address(router), 1e18); + token.mintTo(address(router), amount); // Execute router.rebalance(destinationDomain, 1e18, vtb); @@ -62,10 +64,7 @@ contract HypERC20MovableCollateralRouterTest is Test { assertEq(token.balanceOf(address(vtb)), 1e18); } - function testFuzz_MovingCollateral( - uint256 amount, - bytes32 recipient - ) public { + function testFuzz_rebalance(uint256 amount, bytes32 recipient) public { vm.assume(recipient != bytes32(0)); // Configuration diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol index 17af08e7b0..44167bd07b 100644 --- a/solidity/test/token/HypERC4626Test.t.sol +++ b/solidity/test/token/HypERC4626Test.t.sol @@ -140,7 +140,7 @@ contract HypERC4626CollateralTest is HypTokenTest { _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( remoteToken.balanceOf(BOB), @@ -150,28 +150,6 @@ contract HypERC4626CollateralTest is HypTokenTest { ); } - function testRemoteTransfer_rebaseWithCustomHook() public { - _performRemoteTransferWithoutExpectation(0, transferAmount); - assertEq(remoteToken.balanceOf(BOB), transferAmount); - - _accrueYield(); - - uint256 FEE = 1e18; - ProtocolFee customHook = new ProtocolFee( - FEE, - FEE, - address(this), - address(this) - ); - - localRebasingToken.rebase{value: FEE}( - DESTINATION, - StandardHookMetadata.overrideMsgValue(FEE), - address(customHook) - ); - assertEq(address(customHook).balance, FEE); - } - function testRebaseWithTransfer() public { _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -321,7 +299,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); uint256 sharesAfterYield = remoteRebasingToken.totalShares(); @@ -343,7 +321,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); uint256 bobShareBalanceAfterYield = remoteRebasingToken.shareBalanceOf( @@ -415,7 +393,7 @@ contract HypERC4626CollateralTest is HypTokenTest { _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); // Use balance here since it might be off by <1bp @@ -454,7 +432,7 @@ contract HypERC4626CollateralTest is HypTokenTest { _accrueYield(); _accrueYield(); // earning 2x yield to be split - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); vm.prank(CAROL); remoteToken.transferRemote( @@ -492,7 +470,7 @@ contract HypERC4626CollateralTest is HypTokenTest { // decrease collateral in vault by 10% uint256 drawdown = 5e18; primaryToken.burnFrom(address(vault), drawdown); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); // Use balance here since it might be off by <1bp @@ -518,7 +496,7 @@ contract HypERC4626CollateralTest is HypTokenTest { _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); vm.prank(BOB); @@ -537,7 +515,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); // 5 * 0.9 = 4.5% yield assertEq(peerRebasingToken.exchangeRate(), 1e10); // assertingthat transfers by the synthetic variant don't impact the exchang rate - localRebasingToken.rebase(PEER_DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(PEER_DESTINATION); peerMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( @@ -553,7 +531,7 @@ contract HypERC4626CollateralTest is HypTokenTest { assertEq(remoteToken.balanceOf(BOB), transferAmount); _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); // yield is added + localRebasingToken.rebase(DESTINATION); // yield is added remoteMailbox.processNextInboundMessage(); uint256 balance = remoteToken.balanceOf(BOB); @@ -582,7 +560,7 @@ contract HypERC4626CollateralTest is HypTokenTest { _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); // yield is added + localRebasingToken.rebase(DESTINATION); // yield is added remoteMailbox.processNextInboundMessage(); // BOB: remote -> peer(BOB) (yield is leftover) @@ -594,7 +572,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); peerMailbox.processNextInboundMessage(); - localRebasingToken.rebase(PEER_DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(PEER_DESTINATION); peerMailbox.processNextInboundMessage(); // BOB: peer -> local(CAROL) @@ -634,7 +612,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); uint256 supplyAfterYield = remoteToken.totalSupply(); @@ -653,7 +631,7 @@ contract HypERC4626CollateralTest is HypTokenTest { _accrueYield(); - localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); + localRebasingToken.rebase(DESTINATION); remoteMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( remoteToken.balanceOf(BOB), diff --git a/solidity/test/token/HypnativeMovable.t.sol b/solidity/test/token/HypnativeMovable.t.sol index dc1b3699fb..8425e0bf7a 100644 --- a/solidity/test/token/HypnativeMovable.t.sol +++ b/solidity/test/token/HypnativeMovable.t.sol @@ -56,7 +56,7 @@ contract HypNativeMovableTest is Test { vtb.enrollRemoteRouter(destinationDomain, bytes32(uint256(uint160(0)))); } - function testMovingCollateral() public { + function test_rebalance() public { // Configuration router.addRebalancer(address(this)); @@ -86,35 +86,31 @@ contract HypNativeMovableTest is Test { bytes32(uint256(uint160(alice))) ); router.addBridge(destinationDomain, vtb); - vm.expectRevert("Native: rebalance amount exceeds balance"); + vm.expectRevert("Rebalance native fee exceeds balance"); router.rebalance(destinationDomain, 1 ether, vtb); } function test_rebalance_cannotUndercollateralize( uint96 fee, - uint96 collateralAmount + uint96 amount, + uint96 balance ) public { - vm.assume(fee > 0); - vm.assume(collateralAmount > 1); + vm.assume(balance > 2); + amount = uint96(bound(uint256(amount), 2, uint256(balance))); + fee = uint96(bound(uint256(fee), 1, uint256(amount))); vtb.setFeeRecipient( - address( - new LinearFee( - address(0), - fee, - collateralAmount / 2, - address(this) - ) - ) + address(new LinearFee(address(0), fee, amount / 2, address(this))) ); router.addRebalancer(address(this)); router.addBridge(destinationDomain, vtb); - deal(address(router), collateralAmount); + deal(address(router), balance); deal(address(this), fee); - router.rebalance{value: fee}(destinationDomain, collateralAmount, vtb); - assertEq(address(vtb).balance, collateralAmount); + router.rebalance{value: fee}(destinationDomain, amount, vtb); + assertEq(address(router).balance, balance - amount); + assertEq(address(vtb).balance, amount); } } diff --git a/solidity/test/token/MovableCollateralRouter.t.sol b/solidity/test/token/MovableCollateralRouter.t.sol index 77f449f06a..0a8fa7b38e 100644 --- a/solidity/test/token/MovableCollateralRouter.t.sol +++ b/solidity/test/token/MovableCollateralRouter.t.sol @@ -6,20 +6,28 @@ import {MovableCollateralRouter} from "contracts/token/libs/MovableCollateralRou import {ITokenBridge, Quote} from "contracts/interfaces/ITokenBridge.sol"; import {MockMailbox} from "contracts/mock/MockMailbox.sol"; import {Router} from "contracts/client/Router.sol"; -import {FungibleTokenRouter} from "contracts/token/libs/FungibleTokenRouter.sol"; +import {TokenRouter} from "contracts/token/libs/TokenRouter.sol"; import {TypeCasts} from "contracts/libs/TypeCasts.sol"; +import {Quotes} from "contracts/token/libs/Quotes.sol"; import "forge-std/Test.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; contract MockMovableCollateralRouter is MovableCollateralRouter { - constructor(address _mailbox) FungibleTokenRouter(1, _mailbox) {} + uint256 public chargedToRebalancer; + address _token; + + constructor(address _mailbox, address __token) TokenRouter(1, _mailbox) { + _token = __token; + } function token() public view override returns (address) { - return address(0); + return _token; } - function _transferFromSender(uint256 _amount) internal override {} + function _transferFromSender(uint256 _amount) internal override { + chargedToRebalancer = _amount; + } function _transferTo(address _to, uint256 _amount) internal override {} @@ -31,8 +39,11 @@ contract MockMovableCollateralRouter is MovableCollateralRouter { } contract MockITokenBridge is ITokenBridge { + using TypeCasts for bytes32; + ERC20Test token; - bytes32 public myRecipient; + uint256 collateralFee; + uint256 nativeFee; constructor(ERC20Test _token) { token = _token; @@ -43,24 +54,40 @@ contract MockITokenBridge is ITokenBridge { bytes32 recipient, uint256 amountOut ) external payable override returns (bytes32 transferId) { - token.transferFrom(msg.sender, address(this), amountOut); - myRecipient = recipient; + require(msg.value >= nativeFee); + token.transferFrom( + msg.sender, + address(this), + amountOut + collateralFee + ); return recipient; } + function setCollateralFee(uint256 _fee) public { + collateralFee = _fee; + } + + function setNativeFee(uint256 _fee) public { + nativeFee = _fee; + } + function quoteTransferRemote( uint32 destinationDomain, bytes32 recipient, uint256 amountOut ) public view override returns (Quote[] memory) { - return new Quote[](0); + Quote[] memory quotes = new Quote[](2); + quotes[0] = Quote(address(0), nativeFee); + quotes[1] = Quote(address(token), amountOut + collateralFee); + return quotes; } } contract MovableCollateralRouterTest is Test { using TypeCasts for address; + using Quotes for Quote[]; - MovableCollateralRouter internal router; + MockMovableCollateralRouter internal router; MockITokenBridge internal vtb; ERC20Test internal token; uint32 internal constant destinationDomain = 2; @@ -70,38 +97,55 @@ contract MovableCollateralRouterTest is Test { function setUp() public { mailbox = new MockMailbox(1); - router = new MockMovableCollateralRouter(address(mailbox)); - token = new ERC20Test("Foo Token", "FT", 1_000_000e18, 18); + token = new ERC20Test("Foo Token", "FT", 0, 18); + router = new MockMovableCollateralRouter( + address(mailbox), + address(token) + ); vtb = new MockITokenBridge(token); remote = vm.addr(10); - router.enrollRemoteRouter(destinationDomain, remote.addressToBytes32()); } - function testMovingCollateral() public { - router.addRebalancer(address(this)); - - // Configuration - // Add the destination domain - router.setRecipient( - destinationDomain, - bytes32(uint256(uint160(alice))) - ); + function test_rebalance( + uint256 collateralBalance, + uint256 collateralAmount, + uint256 collateralFee, + uint256 nativeFee + ) public { + vm.assume(collateralBalance < type(uint256).max / 3); + collateralAmount = bound(collateralAmount, 0, collateralBalance); + collateralFee = bound(collateralFee, 0, collateralAmount); - // Add the given bridge - router.addBridge(destinationDomain, vtb); + router.addRebalancer(address(this)); // Setup - token.mintTo(address(router), 1e18); + token.mintTo(address(router), collateralBalance + collateralFee); + router.addBridge(destinationDomain, vtb); vm.prank(address(router)); - token.approve(address(vtb), 1e18); + token.approve(address(vtb), type(uint256).max); + + vtb.setCollateralFee(collateralFee); + vtb.setNativeFee(nativeFee); + vm.deal(address(this), nativeFee); // Execute - router.rebalance(destinationDomain, 1e18, vtb); - // Assert - assertEq(token.balanceOf(address(router)), 0); - assertEq(token.balanceOf(address(vtb)), 1e18); + vm.expectCall( + address(vtb), + nativeFee, + abi.encodeCall( + ITokenBridge.transferRemote, + (destinationDomain, remote.addressToBytes32(), collateralAmount) + ) + ); + router.rebalance{value: nativeFee}( + destinationDomain, + collateralAmount, + vtb + ); + + assertEq(router.chargedToRebalancer(), collateralFee); } function testBadRebalancer() public { diff --git a/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol b/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol index 0f41cc9c76..ce06295acb 100644 --- a/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol +++ b/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol @@ -183,7 +183,9 @@ contract OPL2ToL1TokenBridgeNativeTest is Test { function test_transferRemote_fundsReceived() public { Quote[] memory quotes = _getQuote(); - vtbOrigin.transferRemote{value: quotes[0].amount}( + uint256 value = quotes[0].amount + quotes[1].amount + quotes[2].amount; + + vtbOrigin.transferRemote{value: value}( destination, userB32, transferAmount @@ -215,9 +217,11 @@ contract OPL2ToL1TokenBridgeNativeTest is Test { function test_transferRemote_refunds() public { Quote[] memory quotes = _getQuote(); + uint256 value = quotes[0].amount + quotes[1].amount + quotes[2].amount; + uint256 balanceBefore = address(this).balance; - vtbOrigin.transferRemote{value: 2 * quotes[0].amount}( + vtbOrigin.transferRemote{value: 2 * value}( destination, userB32, transferAmount @@ -225,7 +229,7 @@ contract OPL2ToL1TokenBridgeNativeTest is Test { uint256 balanceAfter = address(this).balance; - assertEq(balanceBefore - balanceAfter, quotes[0].amount); + assertEq(balanceBefore - balanceAfter, value); } function test_interchainSecurityModule_returnsConfiguredIsm() public { @@ -249,35 +253,4 @@ contract OPL2ToL1TokenBridgeNativeTest is Test { // Call handle with dummy values as it should revert before using them. vtbOrigin.handle(destination, userB32, bytes("")); } - - function test_OpL2_transferRemote_revertsIfRefundAddressIsZero() public { - // 1. Craft metadata that specifies address(0) for refunds. - // The msgValue and gasLimit here are for the StandardHookMetadata format, - // but their specific values don't affect the refund address retrieval part we're testing. - bytes memory zeroRefundMetadata = StandardHookMetadata.format( - 0, // msgValue for hook - 0, // gasLimit for hook - address(0) // explicit zero refund address - ); - - // 2. Determine the value needed for the transfer operation itself. - // This quote includes the amount to bridge and the gas for two internal messages. - uint256 internalOpsValue = vtbOrigin - .quoteTransferRemote(destination, userB32, transferAmount)[0].amount; - - // 3. Send a bit more than required, so there's something left to refund. - uint256 valueToSend = internalOpsValue + 1 wei; // 1 wei to be refunded - - // 4. Expect a revert from the explicit check in OpL2NativeTokenBridge. - vm.expectRevert(bytes("OP L2 token bridge: refund address is 0")); - - // 5. Call the transferRemote function that accepts custom metadata. - vtbOrigin.transferRemote{value: valueToSend}( - destination, - userB32, - transferAmount, - zeroRefundMetadata, // Our crafted metadata with address(0) as refund target - address(igp) // Use the standard IGP for gas payments - ); - } } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index ec2ca1ba65..45b6850c7f 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -31,6 +31,7 @@ import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTrans import {IMailbox} from "../../contracts/interfaces/IMailbox.sol"; import {ISpecifiesInterchainSecurityModule} from "../../contracts/interfaces/IInterchainSecurityModule.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {LinearFee} from "../../contracts/token/fees/LinearFee.sol"; contract TokenBridgeCctpV1Test is Test { using TypeCasts for address; @@ -231,7 +232,11 @@ contract TokenBridgeCctpV1Test is Test { ); vm.startPrank(user); - tokenOrigin.approve(address(tbOrigin), quote[1].amount); + // approve internal and external fees + tokenOrigin.approve( + address(tbOrigin), + quote[1].amount + quote[2].amount + ); cctpNonce = tokenMessengerOrigin.nextNonce(); tbOrigin.transferRemote{value: quote[0].amount}( @@ -262,7 +267,7 @@ contract TokenBridgeCctpV1Test is Test { amount ); - assertEq(quotes.length, 2); + assertEq(quotes.length, 3); assertEq(quotes[0].token, address(0)); assertEq( quotes[0].amount, @@ -270,17 +275,22 @@ contract TokenBridgeCctpV1Test is Test { ); assertEq(quotes[1].token, address(tokenOrigin)); assertEq(quotes[1].amount, amount); + // external fee + assertEq(quotes[2].token, address(tokenOrigin)); + assertEq(quotes[2].amount, 0); } function test_transferRemoteCctp() public virtual { - Quote[] memory quote = tbOrigin.quoteTransferRemote( + Quote[] memory quotes = tbOrigin.quoteTransferRemote( destination, user.addressToBytes32(), amount ); + uint256 charge = quotes[1].amount + quotes[2].amount; + vm.startPrank(user); - tokenOrigin.approve(address(tbOrigin), quote[1].amount); + tokenOrigin.approve(address(tbOrigin), charge); uint64 cctpNonce = tokenMessengerOrigin.nextNonce(); @@ -289,14 +299,58 @@ contract TokenBridgeCctpV1Test is Test { abi.encodeCall( ITokenMessengerV1.depositForBurn, ( - amount, + charge, cctpDestination, user.addressToBytes32(), address(tokenOrigin) ) ) ); - tbOrigin.transferRemote{value: quote[0].amount}( + tbOrigin.transferRemote{value: quotes[0].amount}( + destination, + user.addressToBytes32(), + amount + ); + } + + function test_transferRemoteCctp_withFeeRecipient() public virtual { + LinearFee feeContract = new LinearFee( + address(tokenOrigin), + 1e6, + amount / 2, + address(this) + ); + tbOrigin.setFeeRecipient(address(feeContract)); + uint256 feeRecipientFee = feeContract + .quoteTransferRemote(destination, user.addressToBytes32(), amount)[0] + .amount; + + Quote[] memory quotes = tbOrigin.quoteTransferRemote( + destination, + user.addressToBytes32(), + amount + ); + + uint256 charge = quotes[1].amount + quotes[2].amount; + + vm.startPrank(user); + tokenOrigin.approve(address(tbOrigin), charge); + + uint64 cctpNonce = tokenMessengerOrigin.nextNonce(); + + vm.expectCall( + address(tokenMessengerOrigin), + abi.encodeCall( + ITokenMessengerV1.depositForBurn, + ( + charge - feeRecipientFee, + cctpDestination, + user.addressToBytes32(), + address(tokenOrigin) + ) + ) + ); + tbOrigin.transferRemote{value: quotes[0].amount}( destination, user.addressToBytes32(), amount @@ -670,6 +724,7 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 hook = TokenBridgeCctpV1( 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975 ); + bytes32 router = hook.routers(1); uint32 origin = hook.localDomain(); @@ -1025,6 +1080,9 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { } function testFork_transferRemote(bytes32 recipient, uint32 amount) public { + // recipient cannot be bytes32(0) in CCTP + vm.assume(recipient != bytes32(0)); + // depositForBurn will revert if amount is less than maxFee vm.assume(amount > maxFee); vm.createSelectFork(vm.rpcUrl("base"), 32_739_842); @@ -1047,13 +1105,16 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { assertEq(quotes[1].token, usdc); uint256 usdcQuote = quotes[1].amount; - deal(usdc, address(this), usdcQuote); - IERC20(usdc).approve(address(router), usdcQuote); + assertEq(quotes[2].token, usdc); + uint256 fastFee = quotes[2].amount; + + deal(usdc, address(this), usdcQuote + fastFee); + IERC20(usdc).approve(address(router), usdcQuote + fastFee); vm.expectEmit(true, true, true, true, address(router.tokenMessenger())); emit ITokenMessengerV2.DepositForBurn( usdc, - usdcQuote, + usdcQuote + fastFee, address(router), recipient, circleDestination, @@ -1177,15 +1238,16 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); uint256 tokenQuote = quote[1].amount; + uint256 fastFee = quote[2].amount; vm.startPrank(user); - tokenOrigin.approve(address(tbOrigin), tokenQuote); + tokenOrigin.approve(address(tbOrigin), tokenQuote + fastFee); vm.expectCall( address(tokenMessengerOrigin), abi.encodeCall( ITokenMessengerV2.depositForBurn, ( - tokenQuote, + tokenQuote + fastFee, cctpDestination, user.addressToBytes32(), address(tokenOrigin), @@ -1202,6 +1264,54 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); } + function test_transferRemoteCctp_withFeeRecipient() public override { + LinearFee feeContract = new LinearFee( + address(tokenOrigin), + 1e6, + amount / 2, + address(this) + ); + tbOrigin.setFeeRecipient(address(feeContract)); + uint256 feeRecipientFee = feeContract + .quoteTransferRemote(destination, user.addressToBytes32(), amount)[0] + .amount; + + Quote[] memory quotes = tbOrigin.quoteTransferRemote( + destination, + user.addressToBytes32(), + amount + ); + + uint256 tokenQuote = quotes[1].amount; + uint256 fastFee = quotes[2].amount; + + vm.startPrank(user); + tokenOrigin.approve(address(tbOrigin), tokenQuote + fastFee); + + uint64 cctpNonce = tokenMessengerOrigin.nextNonce(); + + vm.expectCall( + address(tokenMessengerOrigin), + abi.encodeCall( + ITokenMessengerV2.depositForBurn, + ( + tokenQuote + fastFee - feeRecipientFee, + cctpDestination, + user.addressToBytes32(), + address(tokenOrigin), + bytes32(0), + maxFee, + minFinalityThreshold + ) + ) + ); + tbOrigin.transferRemote{value: quotes[0].amount}( + destination, + user.addressToBytes32(), + amount + ); + } + function test_postDispatch( bytes32 recipient, bytes calldata body @@ -1283,14 +1393,16 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { amount ); - assertEq(quotes.length, 2); + assertEq(quotes.length, 3); assertEq(quotes[0].token, address(0)); assertEq( quotes[0].amount, igpOrigin.quoteGasPayment(destination, gasLimit) ); assertEq(quotes[1].token, address(tokenOrigin)); + assertEq(quotes[1].amount, amount); uint256 fastFee = (amount * maxFee) / 10_000; - assertEq(quotes[1].amount, amount + fastFee); + assertEq(quotes[2].token, address(tokenOrigin)); + assertEq(quotes[2].amount, fastFee); } } diff --git a/typescript/cli/src/rebalancer/core/Rebalancer.ts b/typescript/cli/src/rebalancer/core/Rebalancer.ts index fb36878ed9..7e424f9a2b 100644 --- a/typescript/cli/src/rebalancer/core/Rebalancer.ts +++ b/typescript/cli/src/rebalancer/core/Rebalancer.ts @@ -3,7 +3,7 @@ import { PopulatedTransaction } from 'ethers'; import { type ChainMap, type ChainMetadata, - EvmHypCollateralAdapter, + EvmMovableCollateralAdapter, InterchainGasQuote, type MultiProvider, type Token, @@ -144,7 +144,7 @@ export class Rebalancer implements IRebalancer { originTokenAmount.getDecimalFormattedAmount(); const originHypAdapter = originToken.getHypAdapter( this.warpCore.multiProvider, - ) as EvmHypCollateralAdapter; + ) as EvmMovableCollateralAdapter; const { bridge, bridgeIsWarp } = getBridgeConfig( this.bridges, origin, @@ -238,7 +238,7 @@ export class Rebalancer implements IRebalancer { const originHypAdapter = originToken.getHypAdapter( this.warpCore.multiProvider, ); - if (!(originHypAdapter instanceof EvmHypCollateralAdapter)) { + if (!(originHypAdapter instanceof EvmMovableCollateralAdapter)) { rebalancerLogger.error( { origin, diff --git a/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts b/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts index e922daa9b0..54df2be328 100644 --- a/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts +++ b/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts @@ -1,10 +1,10 @@ -import { EvmHypCollateralAdapter, TokenAmount } from '@hyperlane-xyz/sdk'; +import { EvmMovableCollateralAdapter, TokenAmount } from '@hyperlane-xyz/sdk'; import { RebalancingRoute } from './IStrategy.js'; export type PreparedTransaction = { populatedTx: Awaited< - ReturnType + ReturnType >; route: RebalancingRoute; originTokenAmount: TokenAmount; diff --git a/typescript/helloworld/contracts/HelloWorld.sol b/typescript/helloworld/contracts/HelloWorld.sol index fc81207c50..17f2f40f89 100644 --- a/typescript/helloworld/contracts/HelloWorld.sol +++ b/typescript/helloworld/contracts/HelloWorld.sol @@ -63,7 +63,7 @@ contract HelloWorld is Router { ) external payable { sent += 1; sentTo[_destinationDomain] += 1; - _dispatch(_destinationDomain, bytes(_message)); + _Router_dispatch(_destinationDomain, msg.value, bytes(_message)); emit SentHelloWorld( mailbox.localDomain(), _destinationDomain, @@ -79,7 +79,7 @@ contract HelloWorld is Router { uint32 _destinationDomain, bytes calldata _message ) external view returns (uint256) { - return _quoteDispatch(_destinationDomain, _message); + return _Router_quoteDispatch(_destinationDomain, _message); } // ============ Internal functions ============ diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index d3a77e7bd5..2a1e95d68b 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -539,6 +539,7 @@ export { } from './token/adapters/CosmWasmTokenAdapter.js'; export { EvmHypCollateralAdapter, + EvmMovableCollateralAdapter, EvmHypNativeAdapter, EvmHypSyntheticAdapter, EvmHypVSXERC20Adapter, diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts index 030655f9a7..7de3f26d17 100644 --- a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts @@ -182,18 +182,6 @@ describe('EvmERC20WarpHyperlaneModule', async () => { token: token.address, allowedRebalancers, }, - [TokenType.collateralVault]: { - ...baseConfig, - type: TokenType.collateralVault, - token: vault.address, - allowedRebalancers, - }, - [TokenType.collateralVaultRebase]: { - ...baseConfig, - type: TokenType.collateralVaultRebase, - token: vault.address, - allowedRebalancers, - }, [TokenType.native]: { ...baseConfig, type: TokenType.native, diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts index 43edec5e82..3c2c244833 100644 --- a/typescript/sdk/src/token/Token.ts +++ b/typescript/sdk/src/token/Token.ts @@ -43,7 +43,6 @@ import { CosmNativeTokenAdapter, } from './adapters/CosmosTokenAdapter.js'; import { - EvmHypCollateralAdapter, EvmHypCollateralFiatAdapter, EvmHypNativeAdapter, EvmHypRebaseCollateralAdapter, @@ -51,6 +50,7 @@ import { EvmHypSyntheticRebaseAdapter, EvmHypXERC20Adapter, EvmHypXERC20LockboxAdapter, + EvmMovableCollateralAdapter, EvmNativeTokenAdapter, EvmTokenAdapter, } from './adapters/EvmTokenAdapter.js'; @@ -203,7 +203,7 @@ export class Token implements IToken { standard === TokenStandard.EvmHypCollateral || standard === TokenStandard.EvmHypOwnerCollateral ) { - return new EvmHypCollateralAdapter(chainName, multiProvider, { + return new EvmMovableCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, }); } else if (standard === TokenStandard.EvmHypRebaseCollateral) { diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts index cf597100bb..9a1bced1ec 100644 --- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts @@ -10,8 +10,6 @@ import { ERC4626__factory, GasRouter__factory, HypERC20, - HypERC20Collateral, - HypERC20Collateral__factory, HypERC20__factory, HypERC4626, HypERC4626Collateral, @@ -26,6 +24,10 @@ import { IXERC20VS, IXERC20VS__factory, IXERC20__factory, + MovableCollateralRouter, + MovableCollateralRouter__factory, + TokenRouter, + TokenRouter__factory, } from '@hyperlane-xyz/core'; import { Address, @@ -281,11 +283,9 @@ export class EvmHypSyntheticAdapter // Interacts with HypCollateral contracts export class EvmHypCollateralAdapter extends EvmHypSyntheticAdapter - implements - IHypTokenAdapter, - IMovableCollateralRouterAdapter + implements IHypTokenAdapter { - public readonly collateralContract: HypERC20Collateral; + public readonly collateralContract: TokenRouter; protected wrappedTokenAddress?: Address; constructor( @@ -294,7 +294,7 @@ export class EvmHypCollateralAdapter public readonly addresses: { token: Address }, ) { super(chainName, multiProvider, addresses); - this.collateralContract = HypERC20Collateral__factory.connect( + this.collateralContract = TokenRouter__factory.connect( addresses.token, this.getProvider(), ); @@ -302,7 +302,7 @@ export class EvmHypCollateralAdapter protected async getWrappedTokenAddress(): Promise
{ if (!this.wrappedTokenAddress) { - this.wrappedTokenAddress = await this.collateralContract.wrappedToken(); + this.wrappedTokenAddress = await this.collateralContract.token(); } return this.wrappedTokenAddress!; } @@ -355,22 +355,34 @@ export class EvmHypCollateralAdapter t.populateTransferTx(params), ); } +} + +export class EvmMovableCollateralAdapter + extends EvmHypCollateralAdapter + implements IMovableCollateralRouterAdapter +{ + movableCollateral(): MovableCollateralRouter { + return MovableCollateralRouter__factory.connect( + this.addresses.token, + this.getProvider(), + ); + } async isRebalancer(account: Address): Promise { - const rebalancers = await this.collateralContract.allowedRebalancers(); + const rebalancers = await this.movableCollateral().allowedRebalancers(); return rebalancers.includes(account); } async getAllowedDestination(domain: Domain): Promise
{ const allowedDestinationBytes32 = - await this.collateralContract.allowedRecipient(domain); + await this.movableCollateral().allowedRecipient(domain); // If allowedRecipient is not set (returns bytes32(0)), // fall back to the enrolled remote router for that domain, // matching the contract's fallback logic in MovableCollateralRouter.sol if (allowedDestinationBytes32 === ZERO_ADDRESS_HEX_32) { - const routerBytes32 = await this.collateralContract.routers(domain); + const routerBytes32 = await this.movableCollateral().routers(domain); return bytes32ToAddress(routerBytes32); } @@ -378,7 +390,8 @@ export class EvmHypCollateralAdapter } async isBridgeAllowed(domain: Domain, bridge: Address): Promise { - const allowedBridges = await this.collateralContract.allowedBridges(domain); + const allowedBridges = + await this.movableCollateral().allowedBridges(domain); return allowedBridges.includes(bridge); } @@ -437,7 +450,7 @@ export class EvmHypCollateralAdapter 0n, ); - return this.collateralContract.populateTransaction.rebalance( + return this.movableCollateral().populateTransaction.rebalance( domain, amount, bridge, @@ -753,7 +766,7 @@ export class EvmHypVSXERC20Adapter // Interacts HypNative contracts export class EvmHypNativeAdapter - extends EvmHypCollateralAdapter + extends EvmMovableCollateralAdapter implements IHypTokenAdapter { override async isApproveRequired(): Promise { @@ -808,7 +821,7 @@ export class EvmHypNativeAdapter BigInt(amount), ); - return this.collateralContract.populateTransaction.rebalance( + return this.movableCollateral().populateTransaction.rebalance( domain, amount, bridge, diff --git a/typescript/sdk/src/token/config.ts b/typescript/sdk/src/token/config.ts index cd4f5d8a41..6edf3fa39f 100644 --- a/typescript/sdk/src/token/config.ts +++ b/typescript/sdk/src/token/config.ts @@ -26,8 +26,8 @@ const isMovableCollateralTokenTypeMap = { [TokenType.collateralCctp]: false, [TokenType.collateralFiat]: false, [TokenType.collateralUri]: false, - [TokenType.collateralVault]: true, - [TokenType.collateralVaultRebase]: true, + [TokenType.collateralVault]: false, + [TokenType.collateralVaultRebase]: false, [TokenType.native]: true, [TokenType.nativeOpL1]: false, [TokenType.nativeOpL2]: false, From 1d46a826d5e29c15b1b4b571565694d5f81a73eb Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Thu, 2 Oct 2025 16:28:09 -0400 Subject: [PATCH 21/36] test: lint for functions that are virtual and override (#7086) Co-authored-by: nambrot --- .changeset/many-stingrays-invite.md | 5 ++ package.json | 1 + solhint-plugin/index.js | 48 +++++++++++++++++++ solhint-plugin/package.json | 9 ++++ solidity/.solhint.json | 6 ++- .../contracts/avs/HyperlaneServiceManager.sol | 4 +- solidity/contracts/client/Router.sol | 1 + solidity/contracts/hooks/DefaultHook.sol | 4 +- .../StaticAggregationHookFactory.sol | 7 +-- .../hooks/igp/InterchainGasPaymaster.sol | 1 + .../hooks/layer-zero/LayerZeroV1Hook.sol | 4 +- .../hooks/layer-zero/LayerZeroV2Hook.sol | 2 +- .../hooks/libs/AbstractMessageIdAuthHook.sol | 1 + .../hooks/libs/AbstractPostDispatchHook.sol | 2 +- .../hooks/routing/DomainRoutingHook.sol | 7 +-- .../isms/aggregation/StaticAggregationIsm.sol | 2 +- .../StaticAggregationIsmFactory.sol | 7 +-- .../AbstractMerkleRootMultisigIsm.sol | 4 +- .../multisig/AbstractMessageIdMultisigIsm.sol | 2 +- solidity/contracts/token/HypERC20.sol | 11 +++-- .../contracts/token/HypERC20Collateral.sol | 8 ++-- .../contracts/token/HypERC721Collateral.sol | 2 +- solidity/contracts/token/HypNative.sol | 4 +- .../contracts/token/TokenBridgeCctpBase.sol | 10 ++-- .../token/bridge/EverclearTokenBridge.sol | 15 ++---- .../contracts/token/extensions/HypERC4626.sol | 14 +++--- .../token/extensions/HypERC4626Collateral.sol | 8 ++-- .../extensions/HypERC4626OwnerCollateral.sol | 4 +- .../token/extensions/HypFiatToken.sol | 2 +- .../contracts/token/extensions/HypXERC20.sol | 2 +- .../extensions/OPL2ToL1TokenBridgeNative.sol | 7 +-- solidity/contracts/token/fees/BaseFee.sol | 2 +- .../token/libs/LpCollateralRouter.sol | 4 +- solidity/contracts/token/libs/TokenRouter.sol | 1 + solidity/package.json | 1 + .../HypERC20CollateralVaultDeposit.t.sol | 10 ++-- .../test/token/MovableCollateralRouter.t.sol | 6 --- yarn.lock | 7 +++ 38 files changed, 143 insertions(+), 92 deletions(-) create mode 100644 .changeset/many-stingrays-invite.md create mode 100644 solhint-plugin/index.js create mode 100644 solhint-plugin/package.json diff --git a/.changeset/many-stingrays-invite.md b/.changeset/many-stingrays-invite.md new file mode 100644 index 0000000000..c64881cbfa --- /dev/null +++ b/.changeset/many-stingrays-invite.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Remove majority of virtual override functions diff --git a/package.json b/package.json index 2845d6f0ef..b354ab18ea 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "postinstall": "husky install" }, "workspaces": [ + "solhint-plugin", "solidity", "typescript/*", "starknet" diff --git a/solhint-plugin/index.js b/solhint-plugin/index.js new file mode 100644 index 0000000000..e7075f7fb9 --- /dev/null +++ b/solhint-plugin/index.js @@ -0,0 +1,48 @@ +// https://protofire.github.io/solhint/docs/writing-plugins.html +class NoVirtualOverrideAllowed { + constructor(reporter, config) { + this.ruleId = 'no-virtual-override'; + + this.reporter = reporter; + this.config = config; + } + + FunctionDefinition(ctx) { + const isVirtual = ctx.isVirtual; + const hasOverride = ctx.override !== null; + + if (isVirtual && hasOverride) { + this.reporter.error( + ctx, + this.ruleId, + 'Functions cannot be "virtual" and "override" at the same time', + ); + } + } +} + +class NoVirtualInitializerAllowed { + constructor(reporter, config) { + this.ruleId = 'no-virtual-initializer'; + + this.reporter = reporter; + this.config = config; + } + + FunctionDefinition(ctx) { + const isVirtual = ctx.isVirtual; + const hasInitializer = ctx.modifiers.some( + (modifier) => modifier.name === 'initializer', + ); + + if (isVirtual && hasInitializer) { + this.reporter.error( + ctx, + this.ruleId, + 'Functions cannot be "virtual" and "initializer" at the same time', + ); + } + } +} + +module.exports = [NoVirtualOverrideAllowed, NoVirtualInitializerAllowed]; diff --git a/solhint-plugin/package.json b/solhint-plugin/package.json new file mode 100644 index 0000000000..704c406e74 --- /dev/null +++ b/solhint-plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "solhint-plugin-hyperlane", + "private": true, + "version": "0.0.0", + "description": "", + "license": "Apache-2.0", + "type": "commonjs", + "main": "index.js" +} diff --git a/solidity/.solhint.json b/solidity/.solhint.json index 64e8d1b1d1..e6a2941ef3 100644 --- a/solidity/.solhint.json +++ b/solidity/.solhint.json @@ -12,7 +12,9 @@ "reason-string": ["warn", { "maxLength": 64 }], "prettier/prettier": "error", "gas-custom-errors": "off", - "named-parameters-mapping": "error" + "named-parameters-mapping": "error", + "hyperlane/no-virtual-override": "error", + "hyperlane/no-virtual-initializer": "error" }, - "plugins": ["prettier"] + "plugins": ["prettier", "hyperlane"] } diff --git a/solidity/contracts/avs/HyperlaneServiceManager.sol b/solidity/contracts/avs/HyperlaneServiceManager.sol index 9a408723b2..1343950aba 100644 --- a/solidity/contracts/avs/HyperlaneServiceManager.sol +++ b/solidity/contracts/avs/HyperlaneServiceManager.sol @@ -286,9 +286,7 @@ contract HyperlaneServiceManager is ECDSAServiceManagerBase, PackageVersioned { } /// @inheritdoc ECDSAServiceManagerBase - function _deregisterOperatorFromAVS( - address operator - ) internal virtual override { + function _deregisterOperatorFromAVS(address operator) internal override { address[] memory challengers = getOperatorChallengers(operator); _completeUnenrollment(operator, challengers); diff --git a/solidity/contracts/client/Router.sol b/solidity/contracts/client/Router.sol index 40f53b755c..7d17af755b 100644 --- a/solidity/contracts/client/Router.sol +++ b/solidity/contracts/client/Router.sol @@ -93,6 +93,7 @@ abstract contract Router is MailboxClient, IMessageRecipient { * @param _sender The sender address * @param _message The message */ + // solhint-disable-next-line hyperlane/no-virtual-override function handle( uint32 _origin, bytes32 _sender, diff --git a/solidity/contracts/hooks/DefaultHook.sol b/solidity/contracts/hooks/DefaultHook.sol index 6bd2204c04..60dba0fb68 100644 --- a/solidity/contracts/hooks/DefaultHook.sol +++ b/solidity/contracts/hooks/DefaultHook.sol @@ -24,14 +24,14 @@ contract DefaultHook is AbstractPostDispatchHook, MailboxClient { function _quoteDispatch( bytes calldata metadata, bytes calldata message - ) internal view virtual override returns (uint256) { + ) internal view override returns (uint256) { return _hook().quoteDispatch(metadata, message); } function _postDispatch( bytes calldata metadata, bytes calldata message - ) internal virtual override { + ) internal override { _hook().postDispatch{value: msg.value}(metadata, message); } } diff --git a/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol b/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol index c7c2d5a2c1..821a018f6a 100644 --- a/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol +++ b/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol @@ -18,12 +18,7 @@ import {StaticAggregationHook} from "./StaticAggregationHook.sol"; import {StaticAddressSetFactory} from "../../libs/StaticAddressSetFactory.sol"; contract StaticAggregationHookFactory is StaticAddressSetFactory { - function _deployImplementation() - internal - virtual - override - returns (address) - { + function _deployImplementation() internal override returns (address) { return address(new StaticAggregationHook()); } } diff --git a/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol b/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol index a2f90d1f9b..110219d470 100644 --- a/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol +++ b/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol @@ -192,6 +192,7 @@ contract InterchainGasPaymaster is * @param _gasLimit The amount of destination gas to pay for. * @return The amount of native tokens required to pay for interchain gas. */ + // solhint-disable-next-line hyperlane/no-virtual-override function quoteGasPayment( uint32 _destinationDomain, uint256 _gasLimit diff --git a/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol b/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol index 79761b41ef..13b75f830d 100644 --- a/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol +++ b/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol @@ -57,7 +57,7 @@ contract LayerZeroV1Hook is AbstractPostDispatchHook, MailboxClient { function _postDispatch( bytes calldata metadata, bytes calldata message - ) internal virtual override { + ) internal override { // ensure hook only dispatches messages that are dispatched by the mailbox bytes32 id = message.id(); require(_isLatestDispatched(id), "message not dispatched by mailbox"); @@ -80,7 +80,7 @@ contract LayerZeroV1Hook is AbstractPostDispatchHook, MailboxClient { function _quoteDispatch( bytes calldata metadata, bytes calldata - ) internal view virtual override returns (uint256 nativeFee) { + ) internal view override returns (uint256 nativeFee) { bytes calldata lZMetadata = metadata.getCustomMetadata(); LayerZeroMetadata memory layerZeroMetadata = parseLzMetadata( lZMetadata diff --git a/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol b/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol index 366cca3188..44058df471 100644 --- a/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol +++ b/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol @@ -85,7 +85,7 @@ contract LayerZeroV2Hook is AbstractMessageIdAuthHook { function _quoteDispatch( bytes calldata metadata, bytes calldata message - ) internal view virtual override returns (uint256) { + ) internal view override returns (uint256) { bytes calldata lZMetadata = metadata.getCustomMetadata(); (uint32 eid, , bytes memory options) = parseLzMetadata(lZMetadata); diff --git a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol index a07b0fa76e..4d7ef2ea54 100644 --- a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol +++ b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol @@ -67,6 +67,7 @@ abstract contract AbstractMessageIdAuthHook is // ============ Internal functions ============ /// @inheritdoc AbstractPostDispatchHook + // solhint-disable-next-line hyperlane/no-virtual-override function _postDispatch( bytes calldata metadata, bytes calldata message diff --git a/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol b/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol index 821fdaa47a..4dcc5c1b95 100644 --- a/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol +++ b/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol @@ -38,7 +38,7 @@ abstract contract AbstractPostDispatchHook is /// @inheritdoc IPostDispatchHook function supportsMetadata( bytes calldata metadata - ) public pure virtual override returns (bool) { + ) public pure virtual returns (bool) { return metadata.length == 0 || metadata.variant() == StandardHookMetadata.VARIANT; diff --git a/solidity/contracts/hooks/routing/DomainRoutingHook.sol b/solidity/contracts/hooks/routing/DomainRoutingHook.sol index 7806606065..498f64bbac 100644 --- a/solidity/contracts/hooks/routing/DomainRoutingHook.sol +++ b/solidity/contracts/hooks/routing/DomainRoutingHook.sol @@ -44,7 +44,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient { // ============ External Functions ============ /// @inheritdoc IPostDispatchHook - function hookType() external pure virtual override returns (uint8) { + function hookType() external pure virtual returns (uint8) { return uint8(IPostDispatchHook.HookTypes.ROUTING); } @@ -60,7 +60,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient { function supportsMetadata( bytes calldata - ) public pure virtual override returns (bool) { + ) public pure override returns (bool) { // routing hook does not care about metadata shape return true; } @@ -68,6 +68,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient { // ============ Internal Functions ============ /// @inheritdoc AbstractPostDispatchHook + // solhint-disable-next-line hyperlane/no-virtual-override function _postDispatch( bytes calldata metadata, bytes calldata message @@ -82,7 +83,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient { function _quoteDispatch( bytes calldata metadata, bytes calldata message - ) internal view virtual override returns (uint256) { + ) internal view override returns (uint256) { return _getConfiguredHook(message).quoteDispatch(metadata, message); } diff --git a/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol b/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol index 97390b632b..75e7caa159 100644 --- a/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol +++ b/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol @@ -22,7 +22,7 @@ contract StaticAggregationIsm is AbstractAggregationIsm { */ function modulesAndThreshold( bytes calldata - ) public view virtual override returns (address[] memory, uint8) { + ) public view override returns (address[] memory, uint8) { return abi.decode(MetaProxy.metadata(), (address[], uint8)); } } diff --git a/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol b/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol index 8fa18fa653..b316558c38 100644 --- a/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol +++ b/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol @@ -6,12 +6,7 @@ import {StaticAggregationIsm} from "./StaticAggregationIsm.sol"; import {StaticThresholdAddressSetFactory} from "../../libs/StaticAddressSetFactory.sol"; contract StaticAggregationIsmFactory is StaticThresholdAddressSetFactory { - function _deployImplementation() - internal - virtual - override - returns (address) - { + function _deployImplementation() internal override returns (address) { return address(new StaticAggregationIsm()); } } diff --git a/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol b/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol index caeeb355fe..5047f15ac1 100644 --- a/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol +++ b/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol @@ -33,7 +33,7 @@ abstract contract AbstractMerkleRootMultisigIsm is AbstractMultisig { function digest( bytes calldata _metadata, bytes calldata _message - ) internal pure virtual override returns (bytes32) { + ) internal pure override returns (bytes32) { require( _metadata.messageIndex() <= _metadata.signedIndex(), "Invalid merkle index metadata" @@ -61,7 +61,7 @@ abstract contract AbstractMerkleRootMultisigIsm is AbstractMultisig { function signatureAt( bytes calldata _metadata, uint256 _index - ) internal pure virtual override returns (bytes calldata) { + ) internal pure override returns (bytes calldata) { return _metadata.signatureAt(_index); } diff --git a/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol b/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol index d05a5abdee..55bf7116fd 100644 --- a/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol +++ b/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol @@ -46,7 +46,7 @@ abstract contract AbstractMessageIdMultisigIsm is AbstractMultisig { function signatureAt( bytes calldata _metadata, uint256 _index - ) internal pure virtual override returns (bytes calldata) { + ) internal pure override returns (bytes calldata) { return _metadata.signatureAt(_index); } diff --git a/solidity/contracts/token/HypERC20.sol b/solidity/contracts/token/HypERC20.sol index 5c32015f72..08c39a5600 100644 --- a/solidity/contracts/token/HypERC20.sol +++ b/solidity/contracts/token/HypERC20.sol @@ -36,14 +36,14 @@ contract HypERC20 is ERC20Upgradeable, TokenRouter { address _hook, address _interchainSecurityModule, address _owner - ) public virtual initializer { + ) public initializer { // Initialize ERC20 metadata __ERC20_init(_name, _symbol); _mint(msg.sender, _totalSupply); _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } - function decimals() public view virtual override returns (uint8) { + function decimals() public view override returns (uint8) { return _decimals; } @@ -52,14 +52,17 @@ contract HypERC20 is ERC20Upgradeable, TokenRouter { /** * @inheritdoc TokenRouter */ - function token() public view virtual override returns (address) { + function token() public view override returns (address) { return address(this); } /** * @inheritdoc TokenRouter * @dev Overrides to burn `_amount` of token from `msg.sender` balance. + * @dev Known overrides: + * - HypERC4626: Converts the amount to shares and burns from the User (via HypERC20 implementation) */ + // solhint-disable-next-line hyperlane/no-virtual-override function _transferFromSender(uint256 _amount) internal virtual override { _burn(msg.sender, _amount); } @@ -71,7 +74,7 @@ contract HypERC20 is ERC20Upgradeable, TokenRouter { function _transferTo( address _recipient, uint256 _amount - ) internal virtual override { + ) internal override { _mint(_recipient, _amount); } } diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index cd1916749c..bb9a48835c 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -53,12 +53,12 @@ contract HypERC20Collateral is LpCollateralRouter { address _hook, address _interchainSecurityModule, address _owner - ) public virtual initializer { + ) public initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); _LpCollateralRouter_initialize(); } - function token() public view virtual override returns (address) { + function token() public view override returns (address) { return address(wrappedToken); } @@ -79,7 +79,7 @@ contract HypERC20Collateral is LpCollateralRouter { * @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract. * @inheritdoc TokenRouter */ - function _transferFromSender(uint256 _amount) internal virtual override { + function _transferFromSender(uint256 _amount) internal override { wrappedToken._transferFromSender(_amount); } @@ -90,7 +90,7 @@ contract HypERC20Collateral is LpCollateralRouter { function _transferTo( address _recipient, uint256 _amount - ) internal virtual override { + ) internal override { wrappedToken._transferTo(_recipient, _amount); } } diff --git a/solidity/contracts/token/HypERC721Collateral.sol b/solidity/contracts/token/HypERC721Collateral.sol index 8c8c84b6e2..3193ab32e7 100644 --- a/solidity/contracts/token/HypERC721Collateral.sol +++ b/solidity/contracts/token/HypERC721Collateral.sol @@ -35,7 +35,7 @@ contract HypERC721Collateral is TokenRouter { address _hook, address _interchainSecurityModule, address _owner - ) public virtual initializer { + ) public initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 36c0b219b7..8d45d14125 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -31,7 +31,7 @@ contract HypNative is LpCollateralRouter { address _hook, address _interchainSecurityModule, address _owner - ) public virtual initializer { + ) public initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); _LpCollateralRouter_initialize(); } @@ -39,7 +39,7 @@ contract HypNative is LpCollateralRouter { /** * @inheritdoc TokenRouter */ - function token() public view virtual override returns (address) { + function token() public view override returns (address) { return address(0); } diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 444b31c28f..c4e3e2a2a5 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -96,7 +96,7 @@ abstract contract TokenBridgeCctpBase is /** * @inheritdoc TokenRouter */ - function token() public view virtual override returns (address) { + function token() public view override returns (address) { return address(wrappedToken); } @@ -104,7 +104,7 @@ abstract contract TokenBridgeCctpBase is address _hook, address _owner, string[] memory __urls - ) external virtual initializer { + ) external initializer { // ISM should not be set _MailboxClient_initialize(_hook, address(0), _owner); @@ -121,7 +121,7 @@ abstract contract TokenBridgeCctpBase is uint32 _destination, bytes32 _recipient, uint256 _amount - ) public payable virtual override returns (bytes32 messageId) { + ) public payable override returns (bytes32 messageId) { // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary ( uint256 externalFee, @@ -318,7 +318,7 @@ abstract contract TokenBridgeCctpBase is * @inheritdoc TokenRouter * @dev Overrides to transfer the tokens from the sender to this contract (like HypERC20Collateral). */ - function _transferFromSender(uint256 _amount) internal virtual override { + function _transferFromSender(uint256 _amount) internal override { wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); } @@ -337,5 +337,5 @@ abstract contract TokenBridgeCctpBase is uint32 _destination, bytes32 _recipient, uint256 _amount - ) internal virtual returns (bytes memory message) {} + ) internal virtual returns (bytes memory message); } diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index c2550aba41..34a70e4754 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -226,7 +226,7 @@ abstract contract EverclearBridge is TokenRouter { uint32 _destination, bytes32 _recipient, uint256 _amount - ) public payable virtual override returns (bytes32 messageId) { + ) public payable override returns (bytes32 messageId) { // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary (, uint256 remainingNativeValue) = _calculateFeesAndCharge( _destination, @@ -265,16 +265,11 @@ abstract contract EverclearBridge is TokenRouter { */ function _handle( uint32 _origin, - bytes32 /* sender */, + bytes32 _sender, bytes calldata _message - ) internal virtual override { + ) internal override { _settleIntent(_message); - - bytes32 _recipient = _message.recipient(); - uint256 _amount = _message.amount(); - - emit ReceivedTransferRemote(_origin, _recipient, _amount); - _transferTo(_recipient.bytes32ToAddress(), _amount); + super._handle(_origin, _sender, _message); } /** @@ -404,7 +399,7 @@ contract EverclearTokenBridge is EverclearBridge { address _recipient, uint256 _amount ) internal override { - wrappedToken._transferTo(_recipient, _amount); + // Do nothing (tokens transferred to recipient directly) } /** diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol index 897b0489f1..e0eb6175db 100644 --- a/solidity/contracts/token/extensions/HypERC4626.sol +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -60,15 +60,13 @@ contract HypERC4626 is HypERC20 { /// Override totalSupply to return the total assets instead of shares. This reflects the actual circulating supply in terms of assets, accounting for rebasing /// @inheritdoc ERC20Upgradeable - function totalSupply() public view virtual override returns (uint256) { + function totalSupply() public view override returns (uint256) { return sharesToAssets(totalShares()); } /// This returns the balance of the account in terms of assets, accounting for rebasing /// @inheritdoc ERC20Upgradeable - function balanceOf( - address account - ) public view virtual override returns (uint256) { + function balanceOf(address account) public view override returns (uint256) { return sharesToAssets(shareBalanceOf(account)); } @@ -92,7 +90,7 @@ contract HypERC4626 is HypERC20 { // @inheritdoc HypERC20 // @dev Amount specified by the user is in assets, but the internal accounting is in shares - function _transferFromSender(uint256 _amount) internal virtual override { + function _transferFromSender(uint256 _amount) internal override { HypERC20._transferFromSender(assetsToShares(_amount)); } @@ -100,7 +98,7 @@ contract HypERC4626 is HypERC20 { // @dev Amount specified by user is in assets, but the message accounting is in shares function _outboundAmount( uint256 _localAmount - ) internal view virtual override returns (uint256) { + ) internal view override returns (uint256) { return TokenRouter._outboundAmount(assetsToShares(_localAmount)); } @@ -110,7 +108,7 @@ contract HypERC4626 is HypERC20 { address _from, address _to, uint256 _amount - ) internal virtual override { + ) internal override { super._transfer(_from, _to, assetsToShares(_amount)); } @@ -123,7 +121,7 @@ contract HypERC4626 is HypERC20 { uint32 _origin, bytes32 _sender, bytes calldata _message - ) internal virtual override { + ) internal override { if (_origin == collateralDomain) { (uint256 newExchangeRate, uint32 rateUpdateNonce) = abi.decode( _message.metadata(), diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index 49d580572c..dca572de6f 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -87,11 +87,6 @@ contract HypERC4626Collateral is TokenRouter { _recipient, _amount ); - uint256 externalFee = _externalFeeAmount( - _destination, - _recipient, - _amount - ); _transferFromSender(_amount + feeRecipientFee); if (feeRecipientFee > 0) { wrappedToken._transferTo(feeRecipient(), feeRecipientFee); @@ -137,7 +132,10 @@ contract HypERC4626Collateral is TokenRouter { /** * @inheritdoc TokenRouter * @dev Withdraws `_shares` of `wrappedToken` from this contract to `_recipient` + * @dev Known overrides: + * - HypERC4626OwnerCollateral: Withdraws assets instead of redeeming shares */ + // solhint-disable-next-line hyperlane/no-virtual-override function _transferTo( address _recipient, uint256 _shares diff --git a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol index fb307d369d..40255dc04e 100644 --- a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol @@ -44,7 +44,7 @@ contract HypERC4626OwnerCollateral is HypERC4626Collateral { */ function _depositIntoVault( uint256 _amount - ) internal virtual override returns (uint256) { + ) internal override returns (uint256) { assetDeposited += _amount; vault.deposit(_amount, address(this)); return _amount; @@ -57,7 +57,7 @@ contract HypERC4626OwnerCollateral is HypERC4626Collateral { function _transferTo( address _recipient, uint256 _amount - ) internal virtual override { + ) internal override { assetDeposited -= _amount; vault.withdraw(_amount, _recipient, address(this)); } diff --git a/solidity/contracts/token/extensions/HypFiatToken.sol b/solidity/contracts/token/extensions/HypFiatToken.sol index f4d0aab379..5f843b61a5 100644 --- a/solidity/contracts/token/extensions/HypFiatToken.sol +++ b/solidity/contracts/token/extensions/HypFiatToken.sol @@ -29,7 +29,7 @@ contract HypFiatToken is TokenRouter { address _hook, address _interchainSecurityModule, address _owner - ) public virtual initializer { + ) public initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } diff --git a/solidity/contracts/token/extensions/HypXERC20.sol b/solidity/contracts/token/extensions/HypXERC20.sol index a6a28e2355..59958af89f 100644 --- a/solidity/contracts/token/extensions/HypXERC20.sol +++ b/solidity/contracts/token/extensions/HypXERC20.sol @@ -25,7 +25,7 @@ contract HypXERC20 is TokenRouter { address _hook, address _interchainSecurityModule, address _owner - ) public virtual initializer { + ) public initializer { _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } diff --git a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol index 255540df0d..75eb9612eb 100644 --- a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol +++ b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol @@ -43,10 +43,7 @@ contract OpL2NativeTokenBridge is TokenRouter { l2Bridge = IStandardBridge(payable(_l2Bridge)); } - function initialize( - address _hook, - address _owner - ) public virtual initializer { + function initialize(address _hook, address _owner) public initializer { // ISM should not be set (contract does not receive messages currently) _MailboxClient_initialize(_hook, address(0), _owner); } @@ -213,7 +210,7 @@ abstract contract OpL1NativeTokenBridge is function initialize( address _owner, string[] memory _urls - ) public virtual initializer { + ) public initializer { __Ownable_init(); setUrls(_urls); // ISM should not be set (this contract uses itself as ISM) diff --git a/solidity/contracts/token/fees/BaseFee.sol b/solidity/contracts/token/fees/BaseFee.sol index 33e853f029..5ce2b39a28 100644 --- a/solidity/contracts/token/fees/BaseFee.sol +++ b/solidity/contracts/token/fees/BaseFee.sol @@ -66,7 +66,7 @@ abstract contract BaseFee is Ownable, ITokenFee, PackageVersioned { uint32 /*_destination*/, bytes32 /*_recipient*/, uint256 _amount - ) external view virtual override returns (Quote[] memory quotes) { + ) external view virtual returns (Quote[] memory quotes) { quotes = new Quote[](1); quotes[0] = Quote(address(token), _quoteTransfer(_amount)); } diff --git a/solidity/contracts/token/libs/LpCollateralRouter.sol b/solidity/contracts/token/libs/LpCollateralRouter.sol index 44378ed1d5..44ebeb8b3d 100644 --- a/solidity/contracts/token/libs/LpCollateralRouter.sol +++ b/solidity/contracts/token/libs/LpCollateralRouter.sol @@ -55,7 +55,7 @@ abstract contract LpCollateralRouter is address receiver, uint256 assets, uint256 shares - ) internal virtual override { + ) internal override { // checks _transferFromSender(assets); @@ -75,7 +75,7 @@ abstract contract LpCollateralRouter is address owner, uint256 assets, uint256 shares - ) internal virtual override { + ) internal override { // checks if (caller != owner) { _spendAllowance(owner, caller, shares); diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index d584434f32..5f43f4a78e 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -388,6 +388,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { * - EverclearEthBridge: Receives WETH, unwraps it and sends native ETH to the recipient. * - HypERC4626: Updates the exchange rate from the metadata */ + // solhint-disable-next-line hyperlane/no-virtual-override function _handle( uint32 _origin, bytes32, diff --git a/solidity/package.json b/solidity/package.json index a18863774b..8f90640901 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -33,6 +33,7 @@ "prettier": "^3.5.3", "prettier-plugin-solidity": "^1.4.2", "solhint": "^5.0.5", + "solhint-plugin-hyperlane": "workspace:^", "solhint-plugin-prettier": "^0.1.0", "solidity-bytes-utils": "^0.8.0", "solidity-coverage": "^0.8.3", diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index e20092fcc2..c8f24ac165 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -17,6 +17,7 @@ import "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {HypERC4626} from "../../contracts/token/extensions/HypERC4626.sol"; +import {HypERC20} from "../../contracts/token/HypERC20.sol"; import {ERC4626Test} from "../../contracts/test/ERC4626/ERC4626Test.sol"; import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; @@ -240,11 +241,10 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { function testERC4626VaultDeposit_TransferFromSender_CorrectMetadata() public { - remoteToken = new HypERC4626( - DECIMALS, - SCALE, - address(remoteMailbox), - ORIGIN + remoteToken = HypERC20( + address( + new HypERC4626(DECIMALS, SCALE, address(remoteMailbox), ORIGIN) + ) ); _enrollRemoteTokenRouter(); vm.prank(ALICE); diff --git a/solidity/test/token/MovableCollateralRouter.t.sol b/solidity/test/token/MovableCollateralRouter.t.sol index 0a8fa7b38e..954e47d0fc 100644 --- a/solidity/test/token/MovableCollateralRouter.t.sol +++ b/solidity/test/token/MovableCollateralRouter.t.sol @@ -30,12 +30,6 @@ contract MockMovableCollateralRouter is MovableCollateralRouter { } function _transferTo(address _to, uint256 _amount) internal override {} - - function _handle( - uint32 _origin, - bytes32 _sender, - bytes calldata _message - ) internal override {} } contract MockITokenBridge is ITokenBridge { diff --git a/yarn.lock b/yarn.lock index 675e350e78..9dad4e0e4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8234,6 +8234,7 @@ __metadata: prettier: "npm:^3.5.3" prettier-plugin-solidity: "npm:^1.4.2" solhint: "npm:^5.0.5" + solhint-plugin-hyperlane: "workspace:^" solhint-plugin-prettier: "npm:^0.1.0" solidity-bytes-utils: "npm:^0.8.0" solidity-coverage: "npm:^0.8.3" @@ -36101,6 +36102,12 @@ __metadata: languageName: node linkType: hard +"solhint-plugin-hyperlane@workspace:^, solhint-plugin-hyperlane@workspace:solhint-plugin": + version: 0.0.0-use.local + resolution: "solhint-plugin-hyperlane@workspace:solhint-plugin" + languageName: unknown + linkType: soft + "solhint-plugin-prettier@npm:^0.1.0": version: 0.1.0 resolution: "solhint-plugin-prettier@npm:0.1.0" From 435b5dbc73c7e70842dcf1366dedbb8b82d3dd2a Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Fri, 10 Oct 2025 13:59:04 -0400 Subject: [PATCH 22/36] fix: enable native LP deposits with payable overload (#7147) --- solidity/contracts/token/HypNative.sol | 15 +- .../token/libs/LpCollateralRouter.sol | 2 +- solidity/test/token/HypNativeLp.t.sol | 223 ++++++++++++++++++ 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 solidity/test/token/HypNativeLp.t.sol diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 8d45d14125..0636945f0b 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -1,11 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.0; +// ============ Internal Imports ============ import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol"; import {Quote, ITokenBridge} from "../interfaces/ITokenBridge.sol"; import {NativeCollateral} from "./libs/TokenCollateral.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; +// ============ External Imports ============ +import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; /** @@ -36,10 +39,20 @@ contract HypNative is LpCollateralRouter { _LpCollateralRouter_initialize(); } + /** + * Replacement for ERC4626Upgradeable.deposit that allows for native token deposits. + * @dev msg.value will be used as the amount to deposit. + * @param receiver The address to deposit the native token to. + * @return shares The number of shares minted. + */ + function deposit(address receiver) public payable returns (uint256 shares) { + return ERC4626Upgradeable.deposit(msg.value, receiver); + } + /** * @inheritdoc TokenRouter */ - function token() public view override returns (address) { + function token() public pure override returns (address) { return address(0); } diff --git a/solidity/contracts/token/libs/LpCollateralRouter.sol b/solidity/contracts/token/libs/LpCollateralRouter.sol index 44ebeb8b3d..4c0024303e 100644 --- a/solidity/contracts/token/libs/LpCollateralRouter.sol +++ b/solidity/contracts/token/libs/LpCollateralRouter.sol @@ -92,7 +92,7 @@ abstract contract LpCollateralRouter is } // can be used to distribute rewards to LPs pro rata - function donate(uint256 amount) public { + function donate(uint256 amount) public payable { // checks _transferFromSender(amount); diff --git a/solidity/test/token/HypNativeLp.t.sol b/solidity/test/token/HypNativeLp.t.sol new file mode 100644 index 0000000000..018e5f6a1a --- /dev/null +++ b/solidity/test/token/HypNativeLp.t.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import {HypNative} from "../../contracts/token/HypNative.sol"; +import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; + +contract HypNativeLpTest is Test { + event Donation(address sender, uint256 amount); + event Deposit( + address indexed sender, + address indexed owner, + uint256 assets, + uint256 shares + ); + event Withdraw( + address indexed sender, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); + + HypNative internal router; + address internal alice = address(0x1); + address internal bob = address(0x2); + uint256 internal constant DEPOSIT_AMOUNT = 100e18; + uint256 internal constant DONATE_AMOUNT = 50e18; + + function setUp() public { + MockMailbox mailbox = new MockMailbox(1); + router = new HypNative(1, address(mailbox)); + router.initialize(address(0), address(0), address(this)); + + vm.label(alice, "Alice"); + vm.label(bob, "Bob"); + vm.deal(alice, 1000e18); + vm.deal(bob, 1000e18); + } + + function testDepositIncreasesBalances() public { + uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT); + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + assertEq(router.balanceOf(alice), shares); + assertEq(router.totalAssets(), DEPOSIT_AMOUNT); + } + + function testDepositEmitsEvent() public { + uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT); + vm.expectEmit(true, true, true, true); + emit Deposit(alice, alice, DEPOSIT_AMOUNT, shares); + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + } + + function testDepositWithZeroValue() public { + vm.prank(alice); + uint256 shares = router.deposit(alice); + assertEq(shares, 0); + assertEq(router.balanceOf(alice), 0); + } + + function testDepositToReceiverCreditsCorrectAccount() public { + uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT); + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(bob); + assertEq(router.balanceOf(bob), shares); + assertEq(router.balanceOf(alice), 0); + } + + function testWithdrawDecreasesBalances() public { + uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT); + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + vm.prank(alice); + router.withdraw(DEPOSIT_AMOUNT, bob, alice); + assertEq(router.balanceOf(alice), 0); + assertEq(router.totalAssets(), 0); + assertEq(bob.balance, 1000e18 + DEPOSIT_AMOUNT); + } + + function testWithdrawEmitsEvent() public { + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + uint256 shares = router.balanceOf(alice); + vm.expectEmit(true, true, true, true); + emit Withdraw(alice, bob, alice, DEPOSIT_AMOUNT, shares); + vm.prank(alice); + router.withdraw(DEPOSIT_AMOUNT, bob, alice); + } + + function testTotalSupplyTracksShares() public { + assertEq(router.totalSupply(), 0); + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + assertEq(router.totalSupply(), router.balanceOf(alice)); + } + + function testTotalAssetsTracksDepositsAndWithdrawals() public { + assertEq(router.totalAssets(), 0); + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + assertEq(router.totalAssets(), DEPOSIT_AMOUNT); + vm.prank(alice); + router.withdraw(DEPOSIT_AMOUNT, bob, alice); + assertEq(router.totalAssets(), 0); + } + + function testDonateIncreasesTotalAssets() public { + assertEq(router.totalAssets(), 0); + vm.prank(alice); + router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT); + assertEq(router.totalAssets(), DONATE_AMOUNT); + } + + function testDonateEmitsEvent() public { + vm.expectEmit(true, true, true, true); + emit Donation(alice, DONATE_AMOUNT); + vm.prank(alice); + router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT); + } + + function testDonateIsNotWithdrawable() public { + vm.prank(alice); + router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT); + vm.prank(alice); + vm.expectRevert(); + router.withdraw(DONATE_AMOUNT, bob, alice); + } + + function testWithdrawMoreThanBalanceReverts() public { + vm.prank(alice); + router.deposit{value: DEPOSIT_AMOUNT}(alice); + vm.prank(alice); + vm.expectRevert(); + router.withdraw(DEPOSIT_AMOUNT + 1, bob, alice); + } + + function testDonateDistributesToAllHolders() public { + uint256 aliceDeposit = 100e18; + uint256 bobDeposit = 200e18; + uint256 donation = DONATE_AMOUNT; + + // Alice deposits + vm.prank(alice); + router.deposit{value: aliceDeposit}(alice); + + // Bob deposits + vm.prank(bob); + router.deposit{value: bobDeposit}(bob); + + // Record balances before donation + uint256 aliceWithdrawBefore = router.maxWithdraw(alice); + uint256 bobWithdrawBefore = router.maxWithdraw(bob); + + // Donate to the vault + vm.deal(address(this), donation); + router.donate{value: donation}(donation); + + // After donation, both should be able to withdraw more + assertGt(router.maxWithdraw(alice), aliceWithdrawBefore); + assertGt(router.maxWithdraw(bob), bobWithdrawBefore); + + // Alice should get 1/3 of donation, Bob should get 2/3 + assertEq(router.maxWithdraw(alice), aliceDeposit + donation / 3); + assertEq(router.maxWithdraw(bob), bobDeposit + (donation * 2) / 3); + } + + function testReceiveCallsDonate() public { + assertEq(router.totalAssets(), 0); + vm.expectEmit(true, true, true, true); + emit Donation(alice, DONATE_AMOUNT); + vm.prank(alice); + (bool success, ) = address(router).call{value: DONATE_AMOUNT}(""); + assertTrue(success); + assertEq(router.totalAssets(), DONATE_AMOUNT); + } + + function testMultipleDepositsAndWithdrawals() public { + // Alice deposits + vm.prank(alice); + uint256 aliceShares = router.deposit{value: DEPOSIT_AMOUNT}(alice); + + // Bob deposits + vm.prank(bob); + uint256 bobShares = router.deposit{value: DEPOSIT_AMOUNT * 2}(bob); + + assertEq(router.totalAssets(), DEPOSIT_AMOUNT * 3); + assertEq(router.totalSupply(), aliceShares + bobShares); + + // Alice withdraws half + vm.prank(alice); + router.withdraw(DEPOSIT_AMOUNT / 2, alice, alice); + + assertEq(router.totalAssets(), DEPOSIT_AMOUNT * 3 - DEPOSIT_AMOUNT / 2); + assertEq(alice.balance, 1000e18 - DEPOSIT_AMOUNT + DEPOSIT_AMOUNT / 2); + + // Bob withdraws all + uint256 bobMaxWithdraw = router.maxWithdraw(bob); + vm.prank(bob); + router.withdraw(bobMaxWithdraw, bob, bob); + + assertEq(bob.balance, 1000e18); + } + + function testDepositAfterDonationGetsCorrectShares() public { + // Alice deposits initially + vm.prank(alice); + uint256 aliceShares = router.deposit{value: DEPOSIT_AMOUNT}(alice); + + // Someone donates + vm.deal(address(this), DONATE_AMOUNT); + router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT); + + // Bob deposits same amount + vm.prank(bob); + uint256 bobShares = router.deposit{value: DEPOSIT_AMOUNT}(bob); + + // Bob should get fewer shares since totalAssets increased from donation + assertLt(bobShares, aliceShares); + } +} From a58c17020db49b0ed21331cf80f00ec66d1a0758 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Fri, 10 Oct 2025 15:34:39 -0400 Subject: [PATCH 23/36] Fix fork tests --- solidity/test/token/EverclearTokenBridge.t.sol | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 2f0829f86e..3d23ddcede 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -755,6 +755,17 @@ contract EverclearTokenBridgeForkTest is BaseEverclearTokenBridgeForkTest { uint256 initialBalance = weth.balanceOf(ALICE); uint256 initialBridgeBalance = weth.balanceOf(address(bridge)); + // Get the gas payment quote + Quote[] memory quotes = bridge.quoteTransferRemote( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); + uint256 gasPayment = quotes[0].amount; + + // Give Alice ETH for gas payment + vm.deal(ALICE, gasPayment); + // Test the transfer - it may succeed or fail depending on adapter state vm.prank(ALICE); // We don't want to check _intentId, as it's not used @@ -769,7 +780,11 @@ contract EverclearTokenBridgeForkTest is BaseEverclearTokenBridgeForkTest { _tokenFee: FEE_AMOUNT, _nativeFee: 0 }); - bridge.transferRemote(OPTIMISM_DOMAIN, RECIPIENT, amount); + bridge.transferRemote{value: gasPayment}( + OPTIMISM_DOMAIN, + RECIPIENT, + amount + ); // Verify the balance changes // Alice should have lost the transfer amount and the fee From 99dff0fd536792f490abf750be4fcb5a8142199a Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Fri, 10 Oct 2025 15:45:37 -0400 Subject: [PATCH 24/36] chore: improve progressive fee comments and test coverage (#7153) --- .../contracts/token/fees/ProgressiveFee.sol | 28 +++-- .../contracts/token/fees/RegressiveFee.sol | 19 ++-- solidity/test/token/Fees.t.sol | 105 ++++++++++++++++++ 3 files changed, 131 insertions(+), 21 deletions(-) diff --git a/solidity/contracts/token/fees/ProgressiveFee.sol b/solidity/contracts/token/fees/ProgressiveFee.sol index 9f7302e6fa..a3f1712488 100644 --- a/solidity/contracts/token/fees/ProgressiveFee.sol +++ b/solidity/contracts/token/fees/ProgressiveFee.sol @@ -5,24 +5,26 @@ import {BaseFee, FeeType} from "./BaseFee.sol"; /** * @title Progressive Fee Structure - * @dev Implements a progressive fee model where the fee percentage increases as the transfer amount increases. + * @dev Implements a progressive fee model where the fee percentage increases with transfer amount + * until reaching a peak at halfAmount, then decreases as the absolute fee approaches maxFee. * * The fee calculation uses a rational function: fee = (maxFee * amount^2) / (halfAmount^2 + amount^2) * * Key characteristics: - * - Higher fee percentage for larger transfers - * - Lower fee percentage for smaller transfers - * - Fee approaches but never reaches maxFee as amount increases + * - Fee percentage increases for transfers below halfAmount (progressive phase) + * - Fee percentage peaks at halfAmount where fee = maxFee/2 + * - Fee percentage decreases for transfers above halfAmount (regressive phase due to maxFee cap) + * - Absolute fee approaches but never reaches maxFee as amount increases * - Fee approaches 0 as amount approaches 0 - * * Example: - * - If maxFee = 1000 and halfAmount = 1000: - * - Transfer of 100 wei: fee = (1000 * 100^2) / (1000^2 + 100^2) = 9.9 wei (9.9%) - * - Transfer of 1000 wei: fee = (1000 * 1000^2) / (1000^2 + 1000^2) = 500 wei (50%) - * - Transfer of 10000 wei: fee = (1000 * 10000^2) / (1000^2 + 10000^2) = 990 wei (99%) + * - If maxFee = 1000 and halfAmount = 10000: + * - Transfer of 2000 wei: fee = (1000 * 2000^2) / (10000^2 + 2000^2) = 38.5 wei (1.92% of amount) + * - Transfer of 10000 wei: fee = (1000 * 10000^2) / (10000^2 + 10000^2) = 500 wei (5% of amount) + * - Transfer of 50000 wei: fee = (1000 * 50000^2) / (10000^2 + 50000^2) = 961.5 wei (1.92% of amount) * - * This structure encourages smaller transactions while applying higher fees to larger transfers. + * This structure encourages mid-sized transfers while applying lower effective rates to both + * very small and very large transactions. */ contract ProgressiveFee is BaseFee { constructor( @@ -35,9 +37,11 @@ contract ProgressiveFee is BaseFee { function _quoteTransfer( uint256 amount ) internal view override returns (uint256 fee) { + if (amount == 0) { + return 0; + } uint256 amountSquared = amount ** 2; - uint256 denominator = halfAmount ** 2 + amountSquared; - return denominator == 0 ? 0 : (maxFee * amountSquared) / denominator; + return (maxFee * amountSquared) / (halfAmount ** 2 + amountSquared); } function feeType() external pure override returns (FeeType) { diff --git a/solidity/contracts/token/fees/RegressiveFee.sol b/solidity/contracts/token/fees/RegressiveFee.sol index 5d51f98473..112aab1e17 100644 --- a/solidity/contracts/token/fees/RegressiveFee.sol +++ b/solidity/contracts/token/fees/RegressiveFee.sol @@ -5,23 +5,24 @@ import {BaseFee, FeeType} from "./BaseFee.sol"; /** * @title Regressive Fee Structure - * @dev Implements a regressive fee model where the fee percentage decreases as the transfer amount increases. + * @dev Implements a regressive fee model where the fee percentage continuously decreases as the transfer amount increases. * * The fee calculation uses a rational function: fee = (maxFee * amount) / (halfAmount + amount) * * Key characteristics: - * - Higher fee percentage for smaller transfers - * - Lower fee percentage for larger transfers - * - Fee approaches maxFee as amount approaches infinity + * - Fee percentage continuously decreases as amount increases (regressive throughout) + * - At halfAmount, fee = maxFee/2 and fee percentage = maxFee/(2*halfAmount) + * - Absolute fee approaches but never reaches maxFee as amount approaches infinity * - Fee approaches 0 as amount approaches 0 * * Example: - * - If maxFee = 1000 and halfAmount = 1000: - * - Transfer of 100 wei: fee = (1000 * 100) / (1000 + 100) = 90.9 wei (90.9%) - * - Transfer of 1000 wei: fee = (1000 * 1000) / (1000 + 1000) = 500 wei (50%) - * - Transfer of 10000 wei: fee = (1000 * 10000) / (1000 + 10000) = 909 wei (9.09%) + * - If maxFee = 1000 and halfAmount = 5000: + * - Transfer of 1000 wei: fee = (1000 * 1000) / (5000 + 1000) = 166.7 wei (16.67% of amount) + * - Transfer of 5000 wei: fee = (1000 * 5000) / (5000 + 5000) = 500 wei (10% of amount) + * - Transfer of 20000 wei: fee = (1000 * 20000) / (5000 + 20000) = 800 wei (4% of amount) * - * This structure encourages larger transfers while applying higher fees to smaller transactions. + * This structure encourages larger transfers while discouraging smaller transactions with higher + * effective fee rates. */ contract RegressiveFee is BaseFee { constructor( diff --git a/solidity/test/token/Fees.t.sol b/solidity/test/token/Fees.t.sol index 0d40bed31a..00c079c24f 100644 --- a/solidity/test/token/Fees.t.sol +++ b/solidity/test/token/Fees.t.sol @@ -177,6 +177,81 @@ contract ProgressiveFeeTest is BaseFeeTest { "Progressive fee mismatch" ); } + + function test_ProgressiveFee_IncreasingPercentageBeforePeak() public { + // Test that fee percentage increases as amount increases toward halfAmount + ProgressiveFee localProgressiveFee = new ProgressiveFee( + address(token), + 1000, + 10000, + OWNER + ); + + uint256 amount1 = 2000; + uint256 amount2 = 5000; + uint256 amount3 = 10000; + + uint256 fee1 = localProgressiveFee + .quoteTransferRemote(destination, recipient, amount1)[0].amount; + uint256 fee2 = localProgressiveFee + .quoteTransferRemote(destination, recipient, amount2)[0].amount; + uint256 fee3 = localProgressiveFee + .quoteTransferRemote(destination, recipient, amount3)[0].amount; + + // Calculate percentages (scaled by 1e18 for precision) + uint256 percentage1 = (fee1 * 1e18) / amount1; + uint256 percentage2 = (fee2 * 1e18) / amount2; + uint256 percentage3 = (fee3 * 1e18) / amount3; + + // Verify percentages increase before peak + assertLt(percentage1, percentage2, "Percentage should increase"); + assertLt(percentage2, percentage3, "Percentage should increase"); + } + + function test_ProgressiveFee_DecreasingPercentageAfterPeak() public { + // Test that fee percentage decreases as amount increases beyond halfAmount + ProgressiveFee localProgressiveFee = new ProgressiveFee( + address(token), + 1000, + 10000, + OWNER + ); + + uint256 amount1 = 10000; + uint256 amount2 = 20000; + uint256 amount3 = 50000; + + uint256 fee1 = localProgressiveFee + .quoteTransferRemote(destination, recipient, amount1)[0].amount; + uint256 fee2 = localProgressiveFee + .quoteTransferRemote(destination, recipient, amount2)[0].amount; + uint256 fee3 = localProgressiveFee + .quoteTransferRemote(destination, recipient, amount3)[0].amount; + + // Calculate percentages (scaled by 1e18 for precision) + uint256 percentage1 = (fee1 * 1e18) / amount1; + uint256 percentage2 = (fee2 * 1e18) / amount2; + uint256 percentage3 = (fee3 * 1e18) / amount3; + + // Verify percentages decrease after peak + assertGt(percentage1, percentage2, "Percentage should decrease"); + assertGt(percentage2, percentage3, "Percentage should decrease"); + } + + function test_ProgressiveFee_ZeroAmount() public { + // Test that fee is zero when amount is zero + ProgressiveFee localProgressiveFee = new ProgressiveFee( + address(token), + 1000, + 10000, + OWNER + ); + + uint256 fee = localProgressiveFee + .quoteTransferRemote(destination, recipient, 0)[0].amount; + + assertEq(fee, 0, "Fee should be zero for zero amount"); + } } // --- RegressiveFee Tests --- @@ -225,6 +300,36 @@ contract RegressiveFeeTest is BaseFeeTest { "Regressive fee mismatch" ); } + + function test_RegressiveFee_ContinuouslyDecreasingPercentage() public { + // Test that fee percentage continuously decreases as amount increases + RegressiveFee localRegressiveFee = new RegressiveFee( + address(token), + 1000, + 5000, + OWNER + ); + + uint256 amount1 = 1000; + uint256 amount2 = 5000; + uint256 amount3 = 20000; + + uint256 fee1 = localRegressiveFee + .quoteTransferRemote(destination, recipient, amount1)[0].amount; + uint256 fee2 = localRegressiveFee + .quoteTransferRemote(destination, recipient, amount2)[0].amount; + uint256 fee3 = localRegressiveFee + .quoteTransferRemote(destination, recipient, amount3)[0].amount; + + // Calculate percentages (scaled by 1e18 for precision) + uint256 percentage1 = (fee1 * 1e18) / amount1; + uint256 percentage2 = (fee2 * 1e18) / amount2; + uint256 percentage3 = (fee3 * 1e18) / amount3; + + // Verify percentages continuously decrease + assertGt(percentage1, percentage2, "Percentage should decrease"); + assertGt(percentage2, percentage3, "Percentage should decrease"); + } } // --- RoutingFee Tests --- From f073cd8c66298ae6ab1b1b6ba99a8b4237e3e4fb Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 13 Oct 2025 11:48:09 -0400 Subject: [PATCH 25/36] fix: use `previewWithdraw(assetsDeposited)` for excess shares (#7152) --- .../extensions/HypERC4626OwnerCollateral.sol | 4 +- .../HypERC20CollateralVaultDeposit.t.sol | 135 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol index 40255dc04e..64c425b3d9 100644 --- a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol @@ -66,8 +66,10 @@ contract HypERC4626OwnerCollateral is HypERC4626Collateral { * @notice Allows the owner to redeem excess shares */ function sweep() external onlyOwner { + // convert assetsDeposited to shares rounding up to ensure + // the owner cannot withdraw user collateral uint256 excessShares = vault.maxRedeem(address(this)) - - vault.convertToShares(assetDeposited); + vault.previewWithdraw(assetDeposited); uint256 assetsRedeemed = vault.redeem( excessShares, owner(), diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index c8f24ac165..44cb2d7f1d 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -238,6 +238,141 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { ); } + function testERC4626VaultDeposit_ceilingRounding_reservesMoreShares() + public + { + // This test verifies the mathematical difference between convertToShares (floor) + // and previewWithdraw (ceiling) rounding when calculating shares for deposits. + + uint256 transferAmount = 100e18; + uint256 rewardAmount = 1e18; + + // Setup: Transfer from Alice to Bob + vm.prank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + _performRemoteTransfer(0, transferAmount); + + // Add yield to the vault (increases share value) + primaryToken.mintTo(address(vault), rewardAmount); + + // Transfer back from Bob to Alice + vm.prank(BOB); + remoteToken.transferRemote( + ORIGIN, + BOB.addressToBytes32(), + transferAmount + ); + _handleLocalTransfer(transferAmount); + + // At this point, we have excess shares due to the yield + uint256 totalShares = vault.maxRedeem( + address(erc20CollateralVaultDeposit) + ); + uint256 assetDeposited = erc20CollateralVaultDeposit.assetDeposited(); + + // Calculate what convertToShares (floor rounding) would give us + uint256 sharesFloor = vault.convertToShares(assetDeposited); + + // Calculate what previewWithdraw (ceiling rounding) gives us + uint256 sharesCeiling = vault.previewWithdraw(assetDeposited); + + // When there's rounding involved, ceiling should be >= floor + // and the excess shares should be: totalShares - sharesCeiling + uint256 excessSharesWithCeiling = totalShares - sharesCeiling; + uint256 excessSharesWithFloor = totalShares - sharesFloor; + + // Verify the key difference: ceiling rounding calculates more shares to reserve + // for the deposited assets, which means fewer excess shares to sweep + assertLe( + excessSharesWithCeiling, + excessSharesWithFloor, + "Ceiling rounding should reserve more shares for deposits" + ); + + // Perform sweep and verify the amount swept is <= excessSharesWithFloor + // Record logs to capture the event + vm.recordLogs(); + erc20CollateralVaultDeposit.sweep(); + + // Get the logs and extract the ExcessSharesSwept event + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool foundEvent = false; + uint256 sweptShares; + + for (uint256 i = 0; i < logs.length; i++) { + // ExcessSharesSwept event signature: ExcessSharesSwept(uint256,uint256) + if ( + logs[i].topics[0] == + keccak256("ExcessSharesSwept(uint256,uint256)") + ) { + foundEvent = true; + // Decode the event data (amount is first parameter, assetsRedeemed is second) + (sweptShares, ) = abi.decode(logs[i].data, (uint256, uint256)); + break; + } + } + + assertTrue( + foundEvent, + "ExcessSharesSwept event should have been emitted" + ); + assertLe( + sweptShares, + excessSharesWithFloor, + "Swept amount should be <= excessSharesWithFloor" + ); + } + + function testERC4626VaultDeposit_sweep_usesCeilingRounding() public { + // This test verifies that sweep() correctly sweeps excess shares after yield accrual + // and leaves no shares behind when assetDeposited is 0. + + uint256 transferAmount = 100e18; + uint256 rewardAmount = 1e18; + + // Setup: Transfer from Alice to Bob + vm.prank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + _performRemoteTransfer(0, transferAmount); + + // Add yield to the vault (increases share value) + primaryToken.mintTo(address(vault), rewardAmount); + + // Transfer back from Bob to Alice + vm.prank(BOB); + remoteToken.transferRemote( + ORIGIN, + BOB.addressToBytes32(), + transferAmount + ); + _handleLocalTransfer(transferAmount); + + uint256 ownerBalanceBefore = primaryToken.balanceOf( + erc20CollateralVaultDeposit.owner() + ); + + // Call sweep() which should use previewWithdraw (ceiling rounding) + erc20CollateralVaultDeposit.sweep(); + + uint256 ownerBalanceAfter = primaryToken.balanceOf( + erc20CollateralVaultDeposit.owner() + ); + uint256 sweptAmount = ownerBalanceAfter - ownerBalanceBefore; + + // The swept amount should be positive (we did sweep excess shares) + assertGt(sweptAmount, 0, "Should have swept excess shares"); + + // After sweep, we should have no shares remaining (assetDeposited is 0) + uint256 remainingShares = vault.maxRedeem( + address(erc20CollateralVaultDeposit) + ); + assertEq( + remainingShares, + 0, + "Should have no shares remaining after sweep with no deposits" + ); + } + function testERC4626VaultDeposit_TransferFromSender_CorrectMetadata() public { From f73c711f83129a359ba7df6bf273d9615a282594 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 13 Oct 2025 14:13:18 -0400 Subject: [PATCH 26/36] fix: SentTransferRemote should reflect scaled amount in message (#7155) --- solidity/contracts/token/TokenBridgeCctpBase.sol | 2 +- solidity/contracts/token/TokenBridgeCctpV1.sol | 2 +- solidity/contracts/token/bridge/EverclearTokenBridge.sol | 6 ++++-- .../contracts/token/extensions/HypERC4626Collateral.sol | 6 +++--- solidity/contracts/token/libs/TokenRouter.sol | 6 ++++-- solidity/test/token/HypERC20.t.sol | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index c4e3e2a2a5..8d53d9366c 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -146,7 +146,7 @@ abstract contract TokenBridgeCctpBase is _emitAndDispatch( _destination, _recipient, - _amount, + _amount, // no scaling needed for CCTP remainingNativeValue, _message ); diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index a137c91d8b..41749665df 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -157,7 +157,7 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { _message = TokenMessage.format( _recipient, - _outboundAmount(_amount), + _amount, abi.encodePacked(nonce) ); _validateTokenMessageLength(_message); diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index 34a70e4754..076c273e7c 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -242,9 +242,11 @@ abstract contract EverclearBridge is TokenRouter { _amount ); + uint256 scaledAmount = _outboundAmount(_amount); + bytes memory _tokenMessage = TokenMessage.format( _recipient, - _outboundAmount(_amount), + scaledAmount, abi.encode(intent) ); @@ -253,7 +255,7 @@ abstract contract EverclearBridge is TokenRouter { _emitAndDispatch( _destination, _recipient, - _amount, + scaledAmount, remainingNativeValue, _tokenMessage ); diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index dca572de6f..2a074c85e2 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -104,10 +104,10 @@ contract HypERC4626Collateral is TokenRouter { rateUpdateNonce ); - uint256 _outboundAmount = _outboundAmount(_shares); + uint256 _scaledAmount = _outboundAmount(_shares); bytes memory _tokenMessage = TokenMessage.format( _recipient, - _outboundAmount, + _scaledAmount, _tokenMetadata ); @@ -116,7 +116,7 @@ contract HypERC4626Collateral is TokenRouter { _emitAndDispatch( _destination, _recipient, - _amount, + _scaledAmount, msg.value, _tokenMessage ); diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index 5f43f4a78e..83dcf9814d 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -147,10 +147,12 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { msg.value ); + uint256 scaledAmount = _outboundAmount(_amount); + // 2. Prepare the token message with the recipient and amount bytes memory _tokenMessage = TokenMessage.format( _recipient, - _outboundAmount(_amount) + scaledAmount ); // 3. Emit the SentTransferRemote event and 4. dispatch the message @@ -158,7 +160,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { _emitAndDispatch( _destination, _recipient, - _amount, + scaledAmount, remainingNativeValue, _tokenMessage ); diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol index ca7d0d0902..c9a1349692 100644 --- a/solidity/test/token/HypERC20.t.sol +++ b/solidity/test/token/HypERC20.t.sol @@ -845,7 +845,7 @@ contract HypERC20ScaledTest is HypTokenTest { emit SentTransferRemote( DESTINATION, BOB.addressToBytes32(), - TRANSFER_AMT + TRANSFER_AMT * EFFECTIVE_SCALE ); vm.prank(ALICE); From 867997deea0eb2fe8c8d79ee9c61faa82f2f5fa6 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 13 Oct 2025 16:55:26 -0400 Subject: [PATCH 27/36] fix: prevent setting fee recipient to self (#7154) --- .../token/extensions/HypERC4626Collateral.sol | 9 +- solidity/contracts/token/libs/TokenRouter.sol | 48 ++++---- solidity/test/token/HypnativeMovable.t.sol | 116 +++++++++++++++++- 3 files changed, 145 insertions(+), 28 deletions(-) diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index 2a074c85e2..842884def8 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -82,17 +82,16 @@ contract HypERC4626Collateral is TokenRouter { ) public payable override returns (bytes32 messageId) { // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary // Don't use HypERC4626Collateral's implementation of _transferTo since it does a redemption. - uint256 feeRecipientFee = _feeRecipientAmount( + (address _feeRecipient, uint256 feeAmount) = _feeRecipientAndAmount( _destination, _recipient, _amount ); - _transferFromSender(_amount + feeRecipientFee); - if (feeRecipientFee > 0) { - wrappedToken._transferTo(feeRecipient(), feeRecipientFee); + _transferFromSender(_amount + feeAmount); + if (feeAmount > 0) { + wrappedToken._transferTo(_feeRecipient, feeAmount); } - // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides // Deposit the amount into the vault and get the shares for the TokenMessage amount uint256 _shares = _depositIntoVault(_amount); diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index 83dcf9814d..a02fcee685 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -6,6 +6,7 @@ import {TypeCasts} from "../../libs/TypeCasts.sol"; import {GasRouter} from "../../client/GasRouter.sol"; import {TokenMessage} from "./TokenMessage.sol"; import {Quote, ITokenBridge, ITokenFee} from "../../interfaces/ITokenBridge.sol"; +import {Quotes} from "./Quotes.sol"; import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; /** @@ -24,6 +25,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { using TypeCasts for address; using TokenMessage for bytes; using StorageSlot for bytes32; + using Quotes for Quote[]; /** * @dev Emitted on `transferRemote` when a transfer message is dispatched. @@ -80,7 +82,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { /** * @inheritdoc ITokenFee * @notice Implements the standardized fee quoting interface for token transfers based on - * overridable internal functions of _quoteGasPayment, _feeRecipientAmount, and _externalFeeAmount. + * overridable internal functions of _quoteGasPayment, _feeRecipientAndAmount, and _externalFeeAmount. * @param _destination The identifier of the destination chain. * @param _recipient The address of the recipient on the destination chain. * @param _amount The amount or identifier of tokens to be sent to the remote recipient @@ -103,11 +105,12 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { token: address(0), amount: _quoteGasPayment(_destination, _recipient, _amount) }); - quotes[1] = Quote({ - token: token(), - amount: _amount + - _feeRecipientAmount(_destination, _recipient, _amount) - }); + (, uint256 feeAmount) = _feeRecipientAndAmount( + _destination, + _recipient, + _amount + ); + quotes[1] = Quote({token: token(), amount: _amount + feeAmount}); quotes[2] = Quote({ token: token(), amount: _externalFeeAmount(_destination, _recipient, _amount) @@ -175,18 +178,18 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { uint256 _amount, uint256 _msgValue ) internal returns (uint256 externalFee, uint256 remainingNativeValue) { - uint256 feeRecipientFee = _feeRecipientAmount( + (address _feeRecipient, uint256 feeAmount) = _feeRecipientAndAmount( _destination, _recipient, _amount ); externalFee = _externalFeeAmount(_destination, _recipient, _amount); - uint256 charge = _amount + feeRecipientFee + externalFee; + uint256 charge = _amount + feeAmount + externalFee; _transferFromSender(charge); - if (feeRecipientFee > 0) { + if (feeAmount > 0) { // transfer atomically so we don't need to keep track of collateral // and fee balances separately - _transferTo(feeRecipient(), feeRecipientFee); + _transferTo(_feeRecipient, feeAmount); } remainingNativeValue = token() != address(0) ? _msgValue @@ -221,11 +224,12 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { /** * @notice Sets the fee recipient for the router. * @dev Allows for address(0) to be set, which disables fees. - * @param _feeRecipient The address of the fee recipient. + * @param recipient The address that receives fees. */ - function setFeeRecipient(address _feeRecipient) public onlyOwner { - FEE_RECIPIENT_SLOT.getAddressSlot().value = _feeRecipient; - emit FeeRecipientSet(_feeRecipient); + function setFeeRecipient(address recipient) public onlyOwner { + require(recipient != address(this), "Fee recipient cannot be self"); + FEE_RECIPIENT_SLOT.getAddressSlot().value = recipient; + emit FeeRecipientSet(recipient); } /** @@ -264,32 +268,34 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { * @param _destination The identifier of the destination chain. * @param _recipient The address of the recipient on the destination chain. * @param _amount The amount or identifier of tokens to be sent to the remote recipient + * @return _feeRecipient The address of the fee recipient. * @return feeAmount The fee recipient amount. * @dev This function is is not intended to be overridden as storage and logic is contained in TokenRouter. */ - function _feeRecipientAmount( + function _feeRecipientAndAmount( uint32 _destination, bytes32 _recipient, uint256 _amount - ) internal view returns (uint256 feeAmount) { - if (feeRecipient() == address(0)) { - return 0; + ) internal view returns (address _feeRecipient, uint256 feeAmount) { + _feeRecipient = feeRecipient(); + if (_feeRecipient == address(0)) { + return (_feeRecipient, 0); } - Quote[] memory quotes = ITokenFee(feeRecipient()).quoteTransferRemote( + Quote[] memory quotes = ITokenFee(_feeRecipient).quoteTransferRemote( _destination, _recipient, _amount ); if (quotes.length == 0) { - return 0; + return (_feeRecipient, 0); } require( quotes.length == 1 && quotes[0].token == token(), "FungibleTokenRouter: fee must match token" ); - return quotes[0].amount; + feeAmount = quotes[0].amount; } /** diff --git a/solidity/test/token/HypnativeMovable.t.sol b/solidity/test/token/HypnativeMovable.t.sol index 8425e0bf7a..8e9328a174 100644 --- a/solidity/test/token/HypnativeMovable.t.sol +++ b/solidity/test/token/HypnativeMovable.t.sol @@ -12,7 +12,23 @@ import {LinearFee} from "contracts/token/fees/LinearFee.sol"; import "forge-std/Test.sol"; contract MockITokenBridgeEth is ITokenBridge { - constructor() {} + uint256 public quoteLength; + address public quoteToken; + uint256 public quoteAmount; + + constructor() { + quoteLength = 0; + } + + function setQuote( + uint256 _length, + address _token, + uint256 _amount + ) external { + quoteLength = _length; + quoteToken = _token; + quoteAmount = _amount; + } function transferRemote( uint32 destinationDomain, @@ -27,7 +43,15 @@ contract MockITokenBridgeEth is ITokenBridge { bytes32 recipient, uint256 amountOut ) external view override returns (Quote[] memory) { - return new Quote[](0); + Quote[] memory quotes = new Quote[](quoteLength); + if (quoteLength == 1) { + quotes[0] = Quote({token: quoteToken, amount: quoteAmount}); + } else if (quoteLength > 1) { + // Return multiple quotes for testing + quotes[0] = Quote({token: quoteToken, amount: quoteAmount}); + quotes[1] = Quote({token: address(0), amount: 100}); + } + return quotes; } } @@ -113,4 +137,92 @@ contract HypNativeMovableTest is Test { assertEq(address(router).balance, balance - amount); assertEq(address(vtb).balance, amount); } + + function test_setFeeRecipient_cannotSetToSelf() public { + vm.expectRevert("Fee recipient cannot be self"); + router.setFeeRecipient(address(router)); + } + + function test_setFeeRecipient_canSetToOtherAddress() public { + address feeRecipient = address(0x123); + router.setFeeRecipient(feeRecipient); + assertEq(router.feeRecipient(), feeRecipient); + } + + function test_setFeeRecipient_canSetToZeroAddress() public { + router.setFeeRecipient(address(0x123)); + assertEq(router.feeRecipient(), address(0x123)); + + router.setFeeRecipient(address(0)); + assertEq(router.feeRecipient(), address(0)); + } + + function test_feeRecipient_emptyQuotesReturnsZero() public { + MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth(); + // Set to return empty quotes (length 0) + mockFeeRecipient.setQuote(0, address(0), 0); + + router.setFeeRecipient(address(mockFeeRecipient)); + + // Should not revert and return 0 fee + Quote[] memory quotes = router.quoteTransferRemote( + destinationDomain, + bytes32(uint256(uint160(alice))), + 1 ether + ); + + // quotes[1] is the internal fee (amount + fee) + assertEq(quotes[1].amount, 1 ether); // no fee added + } + + function test_feeRecipient_multipleQuotesReverts() public { + MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth(); + // Set to return 2 quotes (invalid) + mockFeeRecipient.setQuote(2, address(0), 0.1 ether); + + router.setFeeRecipient(address(mockFeeRecipient)); + + // Should revert with the fee mismatch error + vm.expectRevert("FungibleTokenRouter: fee must match token"); + router.quoteTransferRemote( + destinationDomain, + bytes32(uint256(uint160(alice))), + 1 ether + ); + } + + function test_feeRecipient_wrongTokenReverts() public { + MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth(); + // Set to return 1 quote but with wrong token (not address(0) which is the native token) + address wrongToken = address(0x456); + mockFeeRecipient.setQuote(1, wrongToken, 0.1 ether); + + router.setFeeRecipient(address(mockFeeRecipient)); + + // Should revert with the fee mismatch error + vm.expectRevert("FungibleTokenRouter: fee must match token"); + router.quoteTransferRemote( + destinationDomain, + bytes32(uint256(uint160(alice))), + 1 ether + ); + } + + function test_feeRecipient_correctTokenSucceeds() public { + MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth(); + // Set to return 1 quote with correct token (address(0) for native) + mockFeeRecipient.setQuote(1, address(0), 0.1 ether); + + router.setFeeRecipient(address(mockFeeRecipient)); + + // Should succeed and return correct fee + Quote[] memory quotes = router.quoteTransferRemote( + destinationDomain, + bytes32(uint256(uint160(alice))), + 1 ether + ); + + // quotes[1] is the internal fee (amount + fee) + assertEq(quotes[1].amount, 1.1 ether); // 1 ether + 0.1 ether fee + } } From a62b66205507d5920312b23976907c3b8f87a7fd Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 13 Oct 2025 17:14:57 -0400 Subject: [PATCH 28/36] fix: CCTP v2 external fee quoting (#7148) --- .../contracts/token/TokenBridgeCctpBase.sol | 30 +++++----- .../contracts/token/TokenBridgeCctpV2.sol | 24 +++++++- solidity/test/token/TokenBridgeCctp.t.sol | 56 ++++++++++++++++++- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 8d53d9366c..0d9b000e81 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -16,6 +16,7 @@ import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; import {TypeCasts} from "../libs/TypeCasts.sol"; import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; +import {AbstractPostDispatchHook} from "../hooks/libs/AbstractPostDispatchHook.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -38,7 +39,7 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter { abstract contract TokenBridgeCctpBase is TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, - IPostDispatchHook + AbstractPostDispatchHook { using Message for bytes; using TypeCasts for bytes32; @@ -284,26 +285,19 @@ abstract contract TokenBridgeCctpBase is return uint8(IPostDispatchHook.HookTypes.CCTP); } - /// @inheritdoc IPostDispatchHook - function supportsMetadata( - bytes calldata /*metadata*/ - ) public pure override returns (bool) { - return true; - } - - /// @inheritdoc IPostDispatchHook - function quoteDispatch( - bytes calldata, - bytes calldata - ) external pure override returns (uint256) { + /// @inheritdoc AbstractPostDispatchHook + function _quoteDispatch( + bytes calldata /*metadata*/, + bytes calldata /*message*/ + ) internal pure override returns (uint256) { return 0; } - /// @inheritdoc IPostDispatchHook - function postDispatch( - bytes calldata /*metadata*/, + /// @inheritdoc AbstractPostDispatchHook + function _postDispatch( + bytes calldata metadata, bytes calldata message - ) external payable override { + ) internal override { bytes32 id = message.id(); require(_isLatestDispatched(id), "Message not dispatched"); @@ -312,6 +306,8 @@ abstract contract TokenBridgeCctpBase is uint32 circleDestination = hyperlaneDomainToCircleDomain(destination); _sendMessageIdToIsm(circleDestination, ism, id); + + _refund(metadata, message, address(this).balance); } /** diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index a6b3393790..021d01168d 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -43,6 +43,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { _tokenMessenger ) { + require(_maxFeeBps < 10_000, "maxFeeBps must be less than 100%"); maxFeeBps = _maxFeeBps; minFinalityThreshold = _minFinalityThreshold; } @@ -52,13 +53,34 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { /** * @inheritdoc TokenRouter * @dev Overrides to indicate v2 fees. + * + * Hyperlane uses a "minimum amount out" approach where users specify the exact amount + * they want the recipient to receive on the destination chain. This provides a better + * UX by guaranteeing predictable outcomes regardless of underlying bridge fee structures. + * + * However, some underlying bridges like CCTP charge fees as a percentage of the input + * amount (amountIn), not the output amount. This requires "reversing" the fee calculation: + * we need to determine what input amount (after fees are deducted) will result in the + * desired output amount reaching the recipient. + * + * The formula solves for the fee needed such that after Circle takes their percentage, + * the recipient receives exactly `amount`: + * + * (amount + fee) * (10_000 - maxFeeBps) / 10_000 = amount + * + * Solving for fee: + * fee = (amount * maxFeeBps) / (10_000 - maxFeeBps) + * + * Example: If amount = 100 USDC and maxFeeBps = 10 (0.1%): + * fee = (100 * 10) / (10_000 - 10) = 1000 / 9990 ≈ 0.1001 USDC + * We deposit 100.1001 USDC, Circle takes 0.1001 USDC, recipient gets exactly 100 USDC. */ function _externalFeeAmount( uint32, bytes32, uint256 amount ) internal view override returns (uint256 feeAmount) { - return (amount * maxFeeBps) / 10_000; + return (amount * maxFeeBps) / (10_000 - maxFeeBps); } function _getCCTPVersion() internal pure override returns (uint32) { diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 45b6850c7f..67264ad1ae 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -32,6 +32,8 @@ import {IMailbox} from "../../contracts/interfaces/IMailbox.sol"; import {ISpecifiesInterchainSecurityModule} from "../../contracts/interfaces/IInterchainSecurityModule.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {LinearFee} from "../../contracts/token/fees/LinearFee.sol"; +import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol"; +import {StandardHookMetadata} from "../../contracts/hooks/libs/StandardHookMetadata.sol"; contract TokenBridgeCctpV1Test is Test { using TypeCasts for address; @@ -670,6 +672,9 @@ contract TokenBridgeCctpV1Test is Test { assertEq(actualId, id); } + // needed for hook refunds + receive() external payable {} + function testFork_postDispatch( bytes32 recipient, bytes calldata body @@ -778,6 +783,53 @@ contract TokenBridgeCctpV1Test is Test { tbOrigin.postDispatch(bytes(""), message); } + function test_hookType() public { + assertEq(tbOrigin.hookType(), uint8(IPostDispatchHook.HookTypes.CCTP)); + } + + function test_supportsMetadata() public { + assertEq(tbOrigin.supportsMetadata(bytes("")), true); + assertEq( + tbOrigin.supportsMetadata( + StandardHookMetadata.format(0, 100_000, address(this)) + ), + true + ); + } + + function test_quoteDispatch() public { + assertEq(tbOrigin.quoteDispatch(bytes(""), bytes("")), 0); + } + + function test_postDispatch_refundsExcessValue( + bytes32 recipient, + bytes calldata body + ) public virtual { + address refundAddress = makeAddr("refundAddress"); + uint256 refundBalanceBefore = refundAddress.balance; + + // Create metadata with refund address using standard hook metadata format + bytes memory metadata = abi.encodePacked( + uint16(1), // variant + uint256(0), // msgValue + uint256(0), // gasLimit + refundAddress // refundAddress + ); + + uint256 excessValue = 1 ether; + + mailboxOrigin.dispatch{value: excessValue}( + destination, + recipient, + body, + metadata, + tbOrigin + ); + + // Verify refund was sent + assertEq(refundAddress.balance, refundBalanceBefore + excessValue); + } + function test_verify_hookMessage(bytes calldata body) public { TestRecipient recipient = new TestRecipient(); recipient.setInterchainSecurityModule(address(tbDestination)); @@ -964,7 +1016,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { version, address(tokenOrigin).addressToBytes32(), recipient, - amount + (amount * maxFee) / 10_000, + amount + (amount * maxFee) / (10_000 - maxFee), sender.addressToBytes32(), maxFee, bytes("") @@ -1401,7 +1453,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); assertEq(quotes[1].token, address(tokenOrigin)); assertEq(quotes[1].amount, amount); - uint256 fastFee = (amount * maxFee) / 10_000; + uint256 fastFee = (amount * maxFee) / (10_000 - maxFee); assertEq(quotes[2].token, address(tokenOrigin)); assertEq(quotes[2].amount, fastFee); } From e7e9cd827eb5e44a97feb83defb0b3a574f6cc32 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Mon, 20 Oct 2025 11:39:29 -0400 Subject: [PATCH 29/36] fix: prevent CCTP v2 nonce manipulation (#7209) --- .../mock/MockCircleMessageTransmitter.sol | 124 +++- .../mock/MockCircleTokenMessenger.sol | 37 +- solidity/contracts/token/CCTP.md | 101 +++ .../contracts/token/TokenBridgeCctpBase.sol | 135 ++-- .../contracts/token/TokenBridgeCctpV1.sol | 81 +-- .../contracts/token/TokenBridgeCctpV2.sol | 72 +-- solidity/test/token/TokenBridgeCctp.t.sol | 582 +++++++++++++++--- 7 files changed, 880 insertions(+), 252 deletions(-) diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index fa35f16363..47cf4f1a30 100644 --- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol +++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol @@ -3,12 +3,24 @@ pragma solidity ^0.8.13; import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; +import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; +import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; import {MockToken} from "./MockToken.sol"; +import {TypedMemView} from "../libs/TypedMemView.sol"; +import {CctpMessageV1} from "../libs/CctpMessageV1.sol"; +import {CctpMessageV2} from "../libs/CctpMessageV2.sol"; +import {TypeCasts} from "../libs/TypeCasts.sol"; contract MockCircleMessageTransmitter is IMessageTransmitter, IMessageTransmitterV2 { + using TypedMemView for bytes; + using TypedMemView for bytes29; + using CctpMessageV1 for bytes29; + using CctpMessageV2 for bytes29; + using TypeCasts for address; + mapping(bytes32 => bool) processedNonces; MockToken token; uint32 public version; @@ -26,10 +38,66 @@ contract MockCircleMessageTransmitter is } function receiveMessage( - bytes memory, + bytes memory message, bytes calldata - ) external pure returns (bool success) { - success = true; + ) external returns (bool success) { + bytes29 cctpMessage = TypedMemView.ref(message, 0); + + // Extract nonce and source domain to check if message was already processed + uint32 sourceDomain; + bytes32 nonceId; + if (version == 0) { + sourceDomain = cctpMessage._sourceDomain(); + uint64 nonce = cctpMessage._nonce(); + nonceId = hashSourceAndNonce(sourceDomain, nonce); + } else { + sourceDomain = cctpMessage._getSourceDomain(); + bytes32 nonce = cctpMessage._getNonce(); + // For V2, use the nonce directly as the nonceId (it's already a bytes32) + nonceId = keccak256(abi.encodePacked(sourceDomain, nonce)); + } + + require(!processedNonces[nonceId], "Message already processed"); + processedNonces[nonceId] = true; + + // Extract recipient based on version + address recipient; + bytes32 sender; + bytes memory messageBody; + + if (version == 0) { + // V1 + recipient = _bytes32ToAddress(cctpMessage._recipient()); + sender = cctpMessage._sender(); + messageBody = cctpMessage._messageBody().clone(); + } else { + // V2 + recipient = _bytes32ToAddress(cctpMessage._getRecipient()); + sender = cctpMessage._getSender(); + messageBody = cctpMessage._getMessageBody().clone(); + } + + if (version == 0) { + // V1: Call handleReceiveMessage + success = IMessageHandler(recipient).handleReceiveMessage( + sourceDomain, + sender, + messageBody + ); + } else { + // V2: Call handleReceiveUnfinalizedMessage + success = IMessageHandlerV2(recipient) + .handleReceiveUnfinalizedMessage( + sourceDomain, + sender, + 1000, // mock finality threshold + messageBody + ); + } + } + + function _bytes32ToAddress(bytes32 _buf) internal pure returns (address) { + return address(uint160(uint256(_buf))); } function hashSourceAndNonce( @@ -70,11 +138,36 @@ contract MockCircleMessageTransmitter is } function sendMessage( - uint32, - bytes32, - bytes calldata message + uint32 destinationDomain, + bytes32 recipient, + bytes calldata messageBody ) public returns (uint64) { - emit MessageSent(message); + // Format a complete CCTP message for the event based on version + bytes memory cctpMessage; + if (version == 0) { + cctpMessage = CctpMessageV1._formatMessage( + version, + 0, // sourceDomain (mock localDomain returns 0) + destinationDomain, + 0, // nonce + address(this).addressToBytes32(), + recipient, + bytes32(0), // destinationCaller (anyone can relay) + messageBody + ); + } else { + cctpMessage = CctpMessageV2._formatMessageForRelay( + version, + 0, // sourceDomain (mock localDomain returns 0) + destinationDomain, + address(this).addressToBytes32(), + recipient, + bytes32(0), // destinationCaller (anyone can relay) + 1000, // mock finality threshold + messageBody + ); + } + emit MessageSent(cctpMessage); return 0; } @@ -90,10 +183,21 @@ contract MockCircleMessageTransmitter is function sendMessage( uint32 destinationDomain, bytes32 recipient, - bytes32, - uint32, + bytes32 destinationCaller, + uint32 minFinalityThreshold, bytes calldata messageBody ) external { - sendMessage(destinationDomain, recipient, messageBody); + // V2 sendMessage: format a complete CCTP V2 message + bytes memory cctpMessage = CctpMessageV2._formatMessageForRelay( + version, + 0, // sourceDomain (mock localDomain returns 0) + destinationDomain, + address(this).addressToBytes32(), + recipient, + destinationCaller, + minFinalityThreshold, + messageBody + ); + emit MessageSent(cctpMessage); } } diff --git a/solidity/contracts/mock/MockCircleTokenMessenger.sol b/solidity/contracts/mock/MockCircleTokenMessenger.sol index b6570271ed..9bfc9852b5 100644 --- a/solidity/contracts/mock/MockCircleTokenMessenger.sol +++ b/solidity/contracts/mock/MockCircleTokenMessenger.sol @@ -3,9 +3,16 @@ pragma solidity ^0.8.13; import {ITokenMessenger, ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol"; import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; +import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; +import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; import {MockToken} from "./MockToken.sol"; -contract MockCircleTokenMessenger is ITokenMessengerV1, ITokenMessengerV2 { +contract MockCircleTokenMessenger is + ITokenMessengerV1, + ITokenMessengerV2, + IMessageHandler, + IMessageHandlerV2 +{ uint64 public nextNonce = 0; MockToken token; uint32 public version; @@ -56,4 +63,32 @@ contract MockCircleTokenMessenger is ITokenMessengerV1, ITokenMessengerV2 { ) external { depositForBurn(_amount, 0, 0, _burnToken); } + + // V1 handler + function handleReceiveMessage( + uint32, + bytes32, + bytes calldata + ) external pure override returns (bool) { + return true; + } + + // V2 handlers + function handleReceiveFinalizedMessage( + uint32, + bytes32, + uint32, + bytes calldata + ) external pure override returns (bool) { + return true; + } + + function handleReceiveUnfinalizedMessage( + uint32, + bytes32, + uint32, + bytes calldata + ) external pure override returns (bool) { + return true; + } } diff --git a/solidity/contracts/token/CCTP.md b/solidity/contracts/token/CCTP.md index 49deb1897c..0cc886077d 100644 --- a/solidity/contracts/token/CCTP.md +++ b/solidity/contracts/token/CCTP.md @@ -107,3 +107,104 @@ flowchart LR class MT_O,MT_D,TM_O,TM_D,Iris,USDC_O,USDC_D cctp class M_O,M_D,Relayer hyperlane ``` + +## Destination Chain Sequence Diagrams + +### 1. Token Message with Hyperlane Relayer + +```mermaid +sequenceDiagram + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp + participant MT as MessageTransmitter + participant TM as TokenMessenger + participant USDC as USDC + participant Recipient as Recipient + + HR->>Mailbox: process([burnMessage, attestation], tokenMessage) + Mailbox->>TBCCTP: verify([burnMessage, attestation], tokenMessage) + TBCCTP->>MT: receiveMessage(burnMessage, attestation) + MT->>TM: handleReceiveMessage(burnMessage) + TM->>USDC: mint(amount, recipient) + USDC-->>Recipient: amount transferred + + Note over Mailbox: Marks message as delivered
in delivered mapping + Mailbox->>TBCCTP: handle(tokenMessage) + TBCCTP-->>Recipient: emit event reflecting tokens were transferred +``` + +### 2. Token Message with CCTP Relayer and Hyperlane Relayer + +```mermaid +sequenceDiagram + participant CR as CCTP Relayer + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp + participant MT as MessageTransmitter + participant TM as TokenMessenger + participant USDC as USDC + participant Recipient as Recipient + + Note over CR: CCTP Relayer submits
burn message first + CR->>TBCCTP: receiveMessage(burnMessage, attestation) + TBCCTP->>MT: receiveMessage(burnMessage, attestation) + MT->>TM: handleReceiveMessage(burnMessage) + TM->>USDC: mint(amount, recipient) + USDC-->>Recipient: amount minted + + Note over HR: Hyperlane Relayer delivers
token message + HR->>Mailbox: process([], tokenMessage) + Mailbox->>TBCCTP: verify([], tokenMessage) + TBCCTP-xMailbox: REVERT: Burn message already processed + Note over Mailbox: Transaction reverts,
handle never called +``` + +### 3. GMP Message with Hyperlane Relayer + +```mermaid +sequenceDiagram + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp (ISM) + participant MT as MessageTransmitter + participant Recipient as Recipient App + + HR->>Mailbox: process([cctpMessage, attestation], hyperlaneMessage) + Mailbox->>TBCCTP: verify([cctpMessage, attestation], hyperlaneMessage) + TBCCTP->>MT: receiveMessage(cctpMessage, attestation) + MT->>TBCCTP: handleReceiveMessage(cctpMessage) + Note over TBCCTP: Verifies message ID matches + + Note over Mailbox: Marks message as delivered
in delivered mapping + Mailbox->>Recipient: handle(hyperlaneMessage) + Note over Recipient: Application receives message +``` + +### 4. GMP Message with CCTP Relayer and Hyperlane Relayer + +```mermaid +sequenceDiagram + participant CR as CCTP Relayer + participant HR as Hyperlane Relayer + participant Mailbox as Mailbox + participant TBCCTP as TokenBridgeCctp (ISM) + participant MT as MessageTransmitter + participant Recipient as Recipient App + + Note over CR: CCTP Relayer submits
message first + CR->>TBCCTP: receiveMessage(cctpMessage, attestation) + TBCCTP->>MT: receiveMessage(cctpMessage, attestation) + MT->>TBCCTP: handleReceiveMessage(cctpMessage) + Note over TBCCTP: Stores message ID + + Note over HR: Hyperlane Relayer delivers
GMP message + HR->>Mailbox: process([], hyperlaneMessage) + Mailbox->>TBCCTP: verify([], hyperlaneMessage) + Note over TBCCTP: CCTP message already processed,
verifies message ID + + Note over Mailbox: Marks message as delivered
in delivered mapping + Mailbox->>Recipient: handle(hyperlaneMessage) + Note over Recipient: Application receives message +``` diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 0d9b000e81..7a0a33c1d4 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -17,7 +17,7 @@ import {TypeCasts} from "../libs/TypeCasts.sol"; import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; import {AbstractPostDispatchHook} from "../hooks/libs/AbstractPostDispatchHook.sol"; - +import {AbstractMessageIdAuthorizedIsm} from "../isms/hook/AbstractMessageIdAuthorizedIsm.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -36,10 +36,27 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter { MovableCollateralRouterStorage private __MOVABLE_COLLATERAL_GAP; } -abstract contract TokenBridgeCctpBase is +struct Domain { + uint32 hyperlane; + uint32 circle; +} + +// need intermediate contract to insert slots between TokenBridgeCctpBase and AbstractMessageIdAuthorizedIsm +abstract contract TokenBridgeCctpIntermediateStorage is TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, AbstractPostDispatchHook +{ + /// @notice Hyperlane domain => Domain struct. + /// We use a struct to avoid ambiguity with domain 0 being unknown. + mapping(uint32 hypDomain => Domain circleDomain) + internal _hyperlaneDomainMap; +} + +// see ./CCTP.md for sequence diagrams of the destination chain control flow +abstract contract TokenBridgeCctpBase is + TokenBridgeCctpIntermediateStorage, + AbstractMessageIdAuthorizedIsm { using Message for bytes; using TypeCasts for bytes32; @@ -55,14 +72,10 @@ abstract contract TokenBridgeCctpBase is // @notice CCTP token messenger contract ITokenMessenger public immutable tokenMessenger; - struct Domain { - uint32 hyperlane; - uint32 circle; - } - - /// @notice Hyperlane domain => Circle domain. - /// We use a struct to avoid ambiguity with domain 0 being unknown. - mapping(uint32 hypDomain => Domain circleDomain) internal _domainMap; + /// @notice Circle domain => Domain struct. + // We use a struct to avoid ambiguity with domain 0 being unknown. + mapping(uint32 circleDomain => Domain hyperlaneDomain) + internal _circleDomainMap; /** * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. @@ -136,12 +149,10 @@ abstract contract TokenBridgeCctpBase is // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination); - bytes memory _message = _bridgeViaCircle( - circleDomain, - _recipient, - _amount + externalFee - ); + uint256 burnAmount = _amount + externalFee; + _bridgeViaCircle(circleDomain, _recipient, burnAmount); + bytes memory _message = TokenMessage.format(_recipient, burnAmount); // 3. Emit the SentTransferRemote event and 4. dispatch the message return _emitAndDispatch( @@ -171,7 +182,14 @@ abstract contract TokenBridgeCctpBase is uint32 _hyperlaneDomain, uint32 _circleDomain ) public onlyOwner { - _domainMap[_hyperlaneDomain] = Domain(_hyperlaneDomain, _circleDomain); + _hyperlaneDomainMap[_hyperlaneDomain] = Domain( + _hyperlaneDomain, + _circleDomain + ); + _circleDomainMap[_circleDomain] = Domain( + _hyperlaneDomain, + _circleDomain + ); emit DomainAdded(_hyperlaneDomain, _circleDomain); } @@ -185,7 +203,7 @@ abstract contract TokenBridgeCctpBase is function hyperlaneDomainToCircleDomain( uint32 _hyperlaneDomain ) public view returns (uint32) { - Domain memory domain = _domainMap[_hyperlaneDomain]; + Domain memory domain = _hyperlaneDomainMap[_hyperlaneDomain]; require( domain.hyperlane == _hyperlaneDomain, "Circle domain not configured" @@ -194,20 +212,24 @@ abstract contract TokenBridgeCctpBase is return domain.circle; } + function circleDomainToHyperlaneDomain( + uint32 _circleDomain + ) public view returns (uint32) { + Domain memory domain = _circleDomainMap[_circleDomain]; + require( + domain.circle == _circleDomain, + "Hyperlane domain not configured" + ); + + return domain.hyperlane; + } + function _getCCTPVersion() internal pure virtual returns (uint32); function _getCircleRecipient( bytes29 cctpMessage ) internal pure virtual returns (address); - function _getCircleSource( - bytes29 cctpMessage - ) internal pure virtual returns (uint32); - - function _getCircleNonce( - bytes29 cctpMessage - ) internal pure virtual returns (bytes32); - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -216,7 +238,7 @@ abstract contract TokenBridgeCctpBase is function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view virtual; + ) internal pure virtual; function _sendMessageIdToIsm( uint32 destinationDomain, @@ -224,11 +246,22 @@ abstract contract TokenBridgeCctpBase is bytes32 messageId ) internal virtual; - // @dev Enforces that the CCTP message source domain and nonce matches the Hyperlane message origin and nonce. + /** + * @dev Verifies that the CCTP message matches the Hyperlane message. + */ function verify( bytes calldata _metadata, bytes calldata _hyperlaneMessage - ) external returns (bool) { + ) + external + override(AbstractMessageIdAuthorizedIsm, IInterchainSecurityModule) + returns (bool) + { + // check if hyperlane message has already been verified by CCTP + if (isVerified(_hyperlaneMessage)) { + return true; + } + // decode return type of CctpService.getCCTPAttestation (bytes memory cctpMessageBytes, bytes memory attestation) = abi.decode( _metadata, @@ -236,15 +269,6 @@ abstract contract TokenBridgeCctpBase is ); bytes29 cctpMessage = TypedMemView.ref(cctpMessageBytes, 0); - - // check if CCTP message source matches the hyperlane message origin - uint32 origin = _hyperlaneMessage.origin(); - uint32 sourceDomain = _getCircleSource(cctpMessage); - require( - sourceDomain == hyperlaneDomainToCircleDomain(origin), - "Invalid source domain" - ); - address circleRecipient = _getCircleRecipient(cctpMessage); // check if CCTP message is a USDC burn message if (circleRecipient == address(tokenMessenger)) { @@ -259,21 +283,32 @@ abstract contract TokenBridgeCctpBase is revert("Invalid circle recipient"); } - bytes32 nonce = _getCircleNonce(cctpMessage); - // Receive only if the nonce hasn't been used before - if (messageTransmitter.usedNonces(nonce) == 0) { - require( - messageTransmitter.receiveMessage( - cctpMessageBytes, - attestation - ), - "Failed to receive message" - ); - } + // for GMP messages, this.verifiedMessages[hyperlaneMessage.id()] will be set + // for token messages, hyperlaneMessage.body().amount() tokens will be delivered to hyperlaneMessage.body().recipient() + return messageTransmitter.receiveMessage(cctpMessageBytes, attestation); + } + + function _receiveMessageId( + uint32 circleSource, + bytes32 circleSender, + bytes32 messageId + ) internal returns (bool) { + // ensure that the message was sent from the hook on the origin chain + uint32 origin = circleDomainToHyperlaneDomain(circleSource); + require( + _mustHaveRemoteRouter(origin) == circleSender, + "Unauthorized circle sender" + ); + + preVerifyMessage(messageId, 0); return true; } + function _isAuthorized() internal view override returns (bool) { + return msg.sender == address(messageTransmitter); + } + function _offchainLookupCalldata( bytes calldata _message ) internal pure override returns (bytes memory) { @@ -294,6 +329,8 @@ abstract contract TokenBridgeCctpBase is } /// @inheritdoc AbstractPostDispatchHook + /// @dev Mirrors the logic in AbstractMessageIdAuthHook._postDispatch + // but using Router table instead of hook <> ISM coupling function _postDispatch( bytes calldata metadata, bytes calldata message @@ -333,5 +370,5 @@ abstract contract TokenBridgeCctpBase is uint32 _destination, bytes32 _recipient, uint256 _amount - ) internal virtual returns (bytes memory message); + ) internal virtual; } diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 41749665df..92411db53a 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -11,10 +11,6 @@ import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol"; import {ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol"; import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol"; -// TokenMessage.metadata := uint8 cctpNonce -uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET + - 8; - // @dev Supports only CCTP V1 contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { using CctpMessageV1 for bytes29; @@ -48,21 +44,6 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { return cctpMessage._recipient().bytes32ToAddress(); } - function _getCircleNonce( - bytes29 cctpMessage - ) internal pure override returns (bytes32) { - bytes32 sourceAndNonceHash = keccak256( - abi.encodePacked(cctpMessage._sourceDomain(), cctpMessage._nonce()) - ); - return sourceAndNonceHash; - } - - function _getCircleSource( - bytes29 cctpMessage - ) internal pure override returns (uint32) { - return cctpMessage._sourceDomain(); - } - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -77,13 +58,6 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { ); bytes calldata tokenMessage = hyperlaneMessage.body(); - _validateTokenMessageLength(tokenMessage); - - require( - uint64(bytes8(TokenMessage.metadata(tokenMessage))) == - cctpMessage._nonce(), - "Invalid nonce" - ); require( TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), @@ -100,24 +74,23 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view override { - bytes32 circleSender = cctpMessage._sender(); - require( - circleSender == _mustHaveRemoteRouter(hyperlaneMessage.origin()), - "Invalid circle sender" - ); - + ) internal pure override { bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32); require(circleMessageId == hyperlaneMessage.id(), "Invalid message id"); } /// @inheritdoc IMessageHandler function handleReceiveMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, - bytes calldata /*body*/ - ) external pure override returns (bool) { - return true; + uint32 sourceDomain, + bytes32 sender, + bytes calldata body + ) external override returns (bool) { + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(body, (bytes32)) + ); } function _sendMessageIdToIsm( @@ -125,43 +98,23 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { bytes32 ism, bytes32 messageId ) internal override { - IMessageTransmitter(messageTransmitter).sendMessageWithCaller( + IMessageTransmitter(messageTransmitter).sendMessage( destinationDomain, ism, - ism, abi.encode(messageId) ); } - function _validateTokenMessageLength( - bytes memory _tokenMessage - ) internal pure { - require( - _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, - "Invalid message body length" - ); - } - function _bridgeViaCircle( uint32 circleDomain, bytes32 _recipient, uint256 _amount - ) internal override returns (bytes memory _message) { - uint64 nonce = ITokenMessengerV1(address(tokenMessenger)) - .depositForBurn( - _amount, - circleDomain, - _recipient, - address(wrappedToken) - ); - - _message = TokenMessage.format( - _recipient, + ) internal override { + ITokenMessengerV1(address(tokenMessenger)).depositForBurn( _amount, - abi.encodePacked(nonce) + circleDomain, + _recipient, + address(wrappedToken) ); - _validateTokenMessageLength(_message); - - return _message; } } diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index 021d01168d..5941061139 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -12,9 +12,6 @@ import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol"; import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; -// TokenMessage.metadata := null -uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET; - // @dev Supports only CCTP V2 contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { using CctpMessageV2 for bytes29; @@ -93,27 +90,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { return cctpMessage._getRecipient().bytes32ToAddress(); } - function _getCircleNonce( - bytes29 cctpMessage - ) internal pure override returns (bytes32) { - return cctpMessage._getNonce(); - } - - function _getCircleSource( - bytes29 cctpMessage - ) internal pure override returns (uint32) { - return cctpMessage._getSourceDomain(); - } - - function _validateTokenMessageLength( - bytes memory tokenMessage - ) internal pure { - require( - tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN, - "Invalid message length" - ); - } - function _validateTokenMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage @@ -128,7 +104,6 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { ); bytes calldata tokenMessage = hyperlaneMessage.body(); - _validateTokenMessageLength(tokenMessage); require( TokenMessage.amount(tokenMessage) == burnMessage._getAmount(), @@ -145,35 +120,39 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { function _validateHookMessage( bytes calldata hyperlaneMessage, bytes29 cctpMessage - ) internal view override { - bytes32 circleSender = cctpMessage._getSender(); - require( - circleSender == _mustHaveRemoteRouter(hyperlaneMessage.origin()), - "Invalid circle sender" - ); - + ) internal pure override { bytes32 circleMessageId = cctpMessage._getMessageBody().index(0, 32); require(circleMessageId == hyperlaneMessage.id(), "Invalid message id"); } // @inheritdoc IMessageHandlerV2 function handleReceiveFinalizedMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, + uint32 sourceDomain, + bytes32 sender, uint32 /*finalityThresholdExecuted*/, - bytes calldata /*messageBody*/ - ) external pure override returns (bool) { - return true; + bytes calldata messageBody + ) external override returns (bool) { + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(messageBody, (bytes32)) + ); } // @inheritdoc IMessageHandlerV2 function handleReceiveUnfinalizedMessage( - uint32 /*sourceDomain*/, - bytes32 /*sender*/, + uint32 sourceDomain, + bytes32 sender, uint32 /*finalityThresholdExecuted*/, - bytes calldata /*messageBody*/ - ) external pure override returns (bool) { - return true; + bytes calldata messageBody + ) external override returns (bool) { + return + _receiveMessageId( + sourceDomain, + sender, + abi.decode(messageBody, (bytes32)) + ); } function _sendMessageIdToIsm( @@ -184,7 +163,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { IMessageTransmitterV2(address(messageTransmitter)).sendMessage( destinationDomain, ism, - ism, + bytes32(0), // allow anyone to relay minFinalityThreshold, abi.encode(messageId) ); @@ -194,7 +173,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { uint32 circleDomain, bytes32 _recipient, uint256 _amount - ) internal override returns (bytes memory message) { + ) internal override { ITokenMessengerV2(address(tokenMessenger)).depositForBurn( _amount, circleDomain, @@ -204,10 +183,5 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { maxFeeBps, minFinalityThreshold ); - - message = TokenMessage.format(_recipient, _amount); - _validateTokenMessageLength(message); - - return message; } } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 67264ad1ae..e4e3b5b9b9 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -422,51 +422,8 @@ contract TokenBridgeCctpV1Test is Test { recipient.verify(metadata, message); _upgrade(recipient); - assert(recipient.verify(metadata, message)); - } - - function test_verify_revertsWhen_invalidNonce() public virtual { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - // invalid nonce := nextNonce + 1 - uint64 badNonce = cctpNonce + 1; - bytes memory cctpMessage = _encodeCctpBurnMessage( - badNonce, - cctpOrigin, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - vm.expectRevert(bytes("Invalid nonce")); - tbDestination.verify(metadata, message); - } - function test_verify_revertsWhen_invalidSourceDomain() public { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - // invalid source domain := destination - uint32 badSourceDomain = cctpDestination; - bytes memory cctpMessage = _encodeCctpBurnMessage( - cctpNonce, - badSourceDomain, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - vm.expectRevert(bytes("Invalid source domain")); - tbDestination.verify(metadata, message); + assert(recipient.verify(metadata, message)); } function test_verify_revertsWhen_invalidMintAmount() public { @@ -531,29 +488,6 @@ contract TokenBridgeCctpV1Test is Test { tbDestination.verify(metadata, message); } - function test_verify_revertsWhen_invalidLength() public { - ( - bytes memory message, - uint64 cctpNonce, - bytes32 recipient - ) = _setupAndDispatch(); - - bytes memory cctpMessage = _encodeCctpBurnMessage( - cctpNonce, - cctpOrigin, - recipient, - amount - ); - bytes memory attestation = bytes(""); - bytes memory metadata = abi.encode(cctpMessage, attestation); - - // a message with invalid length. - bytes memory badMessage = bytes.concat(message, bytes1(uint8(60))); - - vm.expectRevert(); - tbDestination.verify(metadata, badMessage); - } - function test_revertsWhen_versionIsNotSupported() public virtual { tokenMessengerOrigin.setVersion(CCTP_VERSION_2); @@ -653,11 +587,10 @@ contract TokenBridgeCctpV1Test is Test { vm.expectCall( address(messageTransmitterOrigin), abi.encodeCall( - IRelayer.sendMessageWithCaller, + IRelayer.sendMessage, ( cctpDestination, address(tbDestination).addressToBytes32(), - address(tbDestination).addressToBytes32(), abi.encode(id) ) ) @@ -690,6 +623,11 @@ contract TokenBridgeCctpV1Test is Test { uint32 origin = mailbox.localDomain(); bytes32 router = hook.routers(destination); + // Ensure domain mapping exists + uint32 circleDestination = 0; // ethereum circle domain + vm.prank(hook.owner()); + hook.addDomain(destination, circleDestination); + // precompute message ID bytes memory message = Message.formatMessage( 3, @@ -708,7 +646,7 @@ contract TokenBridgeCctpV1Test is Test { hook.messageTransmitter().nextAvailableNonce(), address(hook).addressToBytes32(), router, - router, + bytes32(0), abi.encode(Message.id(message)) ); @@ -750,7 +688,8 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress()); _upgrade(ism); - vm.expectRevert(bytes("Invalid circle sender")); + // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender + vm.expectRevert(bytes("Unauthorized circle sender")); ism.verify(metadata, message); // CCTP message was sent by deployer on origin chain @@ -871,13 +810,14 @@ contract TokenBridgeCctpV1Test is Test { bytes memory cctpMessage = _encodeCctpHookMessage( badSender, address(tbDestination).addressToBytes32(), - message + abi.encode(Message.id(message)) ); bytes memory attestation = bytes(""); bytes memory metadata = abi.encode(cctpMessage, attestation); - vm.expectRevert(bytes("Invalid circle sender")); + // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender + vm.expectRevert(bytes("Unauthorized circle sender")); tbDestination.verify(metadata, message); } @@ -934,6 +874,166 @@ contract TokenBridgeCctpV1Test is Test { vm.expectRevert(bytes("Invalid circle recipient")); tbDestination.verify(metadata, message); } + + // ============ handleReceiveMessage Tests (V1 only) ============ + + function test_handleReceiveMessage(bytes calldata message) public virtual { + bytes32 messageId = Message.id(message); + // Call handleReceiveMessage from the message transmitter + vm.prank(address(messageTransmitterDestination)); + bool result = TokenBridgeCctpV1(address(tbDestination)) + .handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + + assertTrue(result); + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + } + + function test_handleReceiveMessage_revertsWhen_unauthorizedSender( + bytes32 messageId + ) public virtual { + // Try to call from an unauthorized address + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + evil.addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_unauthorizedCaller( + bytes32 messageId + ) public virtual { + // Try to call from a non-message-transmitter address + vm.prank(evil); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") + ); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_unconfiguredDomain( + uint32 badDomain, + bytes32 messageId + ) public virtual { + // Assume the domain is not configured + vm.assume(badDomain != cctpOrigin); + vm.assume(badDomain != cctpDestination); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Hyperlane domain not configured")); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + badDomain, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_unenrolledRouter( + bytes32 badRouter, + bytes32 messageId + ) public virtual { + // Assume the router is different from the enrolled one + vm.assume(badRouter != address(tbOrigin).addressToBytes32()); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + badRouter, + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_messageAlreadyDelivered( + bytes32 messageId + ) public virtual { + // First delivery succeeds + vm.prank(address(messageTransmitterDestination)); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + + // Second delivery of the same message should revert + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: message already verified") + ); + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ); + } + + function test_verify_returnsTrue_afterDirectDelivery( + bytes calldata message + ) public virtual { + bytes32 messageId = Message.id(message); + + // First, deliver the message directly via handleReceiveMessage + vm.prank(address(messageTransmitterDestination)); + assertTrue( + TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + abi.encode(messageId) + ) + ); + + // Verify the message is marked as verified + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + + // Now call verify with empty metadata - should return true without attestation + bytes memory metadata = abi.encode(bytes(""), bytes("")); + assertTrue(tbDestination.verify(metadata, message)); + } + + function test_verify_revertsWhen_tokenMessageAlreadyDelivered() + public + virtual + { + // Setup a token transfer + ( + bytes memory message, + uint64 cctpNonce, + bytes32 recipient + ) = _setupAndDispatch(); + + // Create CCTP message for the token transfer + bytes memory cctpMessage = _encodeCctpBurnMessage( + cctpNonce, + cctpOrigin, + recipient, + amount + ); + + // Deliver the CCTP message directly via receiveMessage (simulates CCTP delivering the burn message) + // This mints the tokens to the recipient + messageTransmitterDestination.receiveMessage(cctpMessage, bytes("")); + + // Now try to verify with the same message - should revert because CCTP already processed it + bytes memory metadata = abi.encode(cctpMessage, bytes("")); + + // The exact revert message depends on the mock implementation + // In a real scenario, Circle's MessageTransmitter would revert with a nonce already used error + vm.expectRevert(); + tbDestination.verify(metadata, message); + } } contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { @@ -1265,7 +1365,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { hook.hyperlaneDomainToCircleDomain(destination), address(hook).addressToBytes32(), ism, - ism, + bytes32(0), minFinalityThreshold, abi.encode(Message.id(message)) ); @@ -1388,7 +1488,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ( cctpDestination, address(tbDestination).addressToBytes32(), - address(tbDestination).addressToBytes32(), + address(0).addressToBytes32(), minFinalityThreshold, abi.encode(id) ) @@ -1429,10 +1529,10 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); } - function test_verify_revertsWhen_invalidNonce() public override { - vm.skip(true); - // cannot assert nonce in v2 - } + // function test_verify_revertsWhen_invalidNonce() public override { + // vm.skip(true); + // // cannot assert nonce in v2 + // } function testFork_verify_upgrade() public override { vm.skip(true); @@ -1457,4 +1557,328 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { assertEq(quotes[2].token, address(tokenOrigin)); assertEq(quotes[2].amount, fastFee); } + + // ============ Override V1 handleReceiveMessage tests (V2 doesn't have this function) ============ + + // function test_handleReceiveMessage(bytes32) public pure override { + // // V2 doesn't have handleReceiveMessage, skip this test + // } + + function test_handleReceiveMessage_revertsWhen_unauthorizedSender( + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + function test_handleReceiveMessage_revertsWhen_unauthorizedCaller( + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + function test_handleReceiveMessage_revertsWhen_unconfiguredDomain( + uint32, + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + function test_handleReceiveMessage_revertsWhen_unenrolledRouter( + bytes32, + bytes32 + ) public pure override { + // V2 doesn't have handleReceiveMessage, skip this test + } + + // ============ handleReceiveFinalizedMessage Tests (V2 only) ============ + + function test_handleReceiveMessage(bytes calldata message) public override { + uint32 finalityThreshold = 2000; + bytes32 messageId = Message.id(message); + // Call handleReceiveFinalizedMessage from the message transmitter + vm.prank(address(messageTransmitterDestination)); + bool result = TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + + assertTrue(result); + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unauthorizedSender( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + evil.addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unauthorizedCaller( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(evil); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") + ); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unconfiguredDomain( + uint32 badDomain, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badDomain != cctpOrigin); + vm.assume(badDomain != cctpDestination); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Hyperlane domain not configured")); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + badDomain, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveFinalizedMessage_revertsWhen_unenrolledRouter( + bytes32 badRouter, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badRouter != address(tbOrigin).addressToBytes32()); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + badRouter, + finalityThreshold, + abi.encode(messageId) + ); + } + + // ============ handleReceiveUnfinalizedMessage Tests (V2 only) ============ + + function test_handleReceiveUnfinalizedMessage( + bytes calldata message, + uint32 finalityThreshold + ) public { + bytes32 messageId = Message.id(message); + // Call handleReceiveUnfinalizedMessage from the message transmitter + vm.prank(address(messageTransmitterDestination)); + bool result = TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + + assertTrue(result); + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unauthorizedSender( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + evil.addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unauthorizedCaller( + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.prank(evil); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") + ); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unconfiguredDomain( + uint32 badDomain, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badDomain != cctpOrigin); + vm.assume(badDomain != cctpDestination); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Hyperlane domain not configured")); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + badDomain, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveUnfinalizedMessage_revertsWhen_unenrolledRouter( + bytes32 badRouter, + bytes32 messageId, + uint32 finalityThreshold + ) public { + vm.assume(badRouter != address(tbOrigin).addressToBytes32()); + + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert(bytes("Unauthorized circle sender")); + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + badRouter, + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_handleReceiveMessage_revertsWhen_messageAlreadyDelivered( + bytes32 messageId + ) public override { + uint32 finalityThreshold = 2000; + // First delivery succeeds + vm.prank(address(messageTransmitterDestination)); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + + // Second delivery of the same message should revert + vm.prank(address(messageTransmitterDestination)); + vm.expectRevert( + bytes("AbstractMessageIdAuthorizedIsm: message already verified") + ); + TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ); + } + + function test_verify_returnsTrue_afterDirectDelivery( + bytes calldata message + ) public override { + bytes32 messageId = Message.id(message); + uint32 finalityThreshold = 2000; + + // First, deliver the message directly via handleReceiveFinalizedMessage + vm.prank(address(messageTransmitterDestination)); + assertTrue( + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveFinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ) + ); + + // Verify the message is marked as verified + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + + // Now call verify with empty metadata - should return true without attestation + bytes memory metadata = abi.encode(bytes(""), bytes("")); + assertTrue(tbDestination.verify(metadata, message)); + } + + function test_verify_returnsTrue_afterUnfinalizedDirectDelivery( + bytes calldata message + ) public { + bytes32 messageId = Message.id(message); + uint32 finalityThreshold = 1500; + + // First, deliver the message directly via handleReceiveUnfinalizedMessage + vm.prank(address(messageTransmitterDestination)); + assertTrue( + TokenBridgeCctpV2(address(tbDestination)) + .handleReceiveUnfinalizedMessage( + cctpOrigin, + address(tbOrigin).addressToBytes32(), + finalityThreshold, + abi.encode(messageId) + ) + ); + + // Verify the message is marked as verified + assertTrue( + TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + ); + + // Now call verify with empty metadata - should return true without attestation + bytes memory metadata = abi.encode(bytes(""), bytes("")); + assertTrue(tbDestination.verify(metadata, message)); + } + + function test_verify_revertsWhen_tokenMessageAlreadyDelivered() + public + override + { + // Setup a token transfer + ( + bytes memory message, + uint64 cctpNonce, + bytes32 recipient + ) = _setupAndDispatch(); + + // Create CCTP V2 message for the token transfer + bytes memory cctpMessage = _encodeCctpBurnMessage( + cctpNonce, + cctpOrigin, + recipient, + amount + ); + + // Deliver the CCTP message directly via receiveMessage (simulates CCTP delivering the burn message) + // This mints the tokens to the recipient + messageTransmitterDestination.receiveMessage(cctpMessage, bytes("")); + + // Now try to verify with the same message - should revert because CCTP already processed it + bytes memory metadata = abi.encode(cctpMessage, bytes("")); + + // The exact revert message depends on the mock implementation + // In a real scenario, Circle's MessageTransmitter would revert with a nonce already used error + vm.expectRevert(); + tbDestination.verify(metadata, message); + } } From 4a0f777bf1dc73f3a6b6315936b8d5ef3e1749f9 Mon Sep 17 00:00:00 2001 From: larryob Date: Tue, 21 Oct 2025 12:07:21 -0400 Subject: [PATCH 30/36] fix: Use new address for `BOB` in Everclear test (#7215) --- solidity/test/token/EverclearTokenBridge.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 3d23ddcede..4006acc338 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -611,7 +611,7 @@ contract BaseEverclearTokenBridgeForkTest is Test { // Test addresses address internal ALICE = makeAddr("alice"); - address internal BOB = makeAddr("bob"); + address internal BOB = makeAddr("bob2"); address internal OWNER = makeAddr("owner"); address internal PROXY_ADMIN = makeAddr("proxyAdmin"); From bf7546b16dc4d8a96c2c3df29ff695402dbb93b2 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 28 Oct 2025 14:10:39 -0400 Subject: [PATCH 31/36] fix: do not donate on receive in `HypNative` (#7262) --- solidity/contracts/token/HypNative.sol | 5 ++--- solidity/test/token/HypNativeLp.t.sol | 10 ---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol index 0636945f0b..3ec35be504 100644 --- a/solidity/contracts/token/HypNative.sol +++ b/solidity/contracts/token/HypNative.sol @@ -73,7 +73,6 @@ contract HypNative is LpCollateralRouter { NativeCollateral._transferTo(_recipient, _amount); } - receive() external payable { - donate(msg.value); - } + // allow receiving native tokens for collateral rebalancing + receive() external payable {} } diff --git a/solidity/test/token/HypNativeLp.t.sol b/solidity/test/token/HypNativeLp.t.sol index 018e5f6a1a..90a5c7213d 100644 --- a/solidity/test/token/HypNativeLp.t.sol +++ b/solidity/test/token/HypNativeLp.t.sol @@ -167,16 +167,6 @@ contract HypNativeLpTest is Test { assertEq(router.maxWithdraw(bob), bobDeposit + (donation * 2) / 3); } - function testReceiveCallsDonate() public { - assertEq(router.totalAssets(), 0); - vm.expectEmit(true, true, true, true); - emit Donation(alice, DONATE_AMOUNT); - vm.prank(alice); - (bool success, ) = address(router).call{value: DONATE_AMOUNT}(""); - assertTrue(success); - assertEq(router.totalAssets(), DONATE_AMOUNT); - } - function testMultipleDepositsAndWithdrawals() public { // Alice deposits vm.prank(alice); From 3fc738399c44b44047e44578999deec011913347 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 28 Oct 2025 14:53:43 -0400 Subject: [PATCH 32/36] fix: prevent token message verifying GMP recipient (#7261) --- .../contracts/token/TokenBridgeCctpBase.sol | 11 ++++- .../contracts/token/TokenBridgeCctpV1.sol | 3 +- .../contracts/token/TokenBridgeCctpV2.sol | 5 ++- solidity/test/token/TokenBridgeCctp.t.sol | 44 +++++++++++++++++-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index 7a0a33c1d4..f803420aeb 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -150,7 +150,7 @@ abstract contract TokenBridgeCctpBase is // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination); uint256 burnAmount = _amount + externalFee; - _bridgeViaCircle(circleDomain, _recipient, burnAmount); + _bridgeViaCircle(circleDomain, _recipient, burnAmount, externalFee); bytes memory _message = TokenMessage.format(_recipient, burnAmount); // 3. Emit the SentTransferRemote event and 4. dispatch the message @@ -272,6 +272,12 @@ abstract contract TokenBridgeCctpBase is address circleRecipient = _getCircleRecipient(cctpMessage); // check if CCTP message is a USDC burn message if (circleRecipient == address(tokenMessenger)) { + // prevent hyperlane message recipient configured with CCTP ISM + // from verifying and handling token messages + require( + _hyperlaneMessage.recipientAddress() == address(this), + "Invalid token message recipient" + ); _validateTokenMessage(_hyperlaneMessage, cctpMessage); } // check if CCTP message is a GMP message to this contract @@ -369,6 +375,7 @@ abstract contract TokenBridgeCctpBase is function _bridgeViaCircle( uint32 _destination, bytes32 _recipient, - uint256 _amount + uint256 _amount, + uint256 _maxFee ) internal virtual; } diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol index 92411db53a..ebd385dce0 100644 --- a/solidity/contracts/token/TokenBridgeCctpV1.sol +++ b/solidity/contracts/token/TokenBridgeCctpV1.sol @@ -108,7 +108,8 @@ contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler { function _bridgeViaCircle( uint32 circleDomain, bytes32 _recipient, - uint256 _amount + uint256 _amount, + uint256 /*_maxFee*/ // not used for CCTP V1 ) internal override { ITokenMessengerV1(address(tokenMessenger)).depositForBurn( _amount, diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol index 5941061139..5d17e41e82 100644 --- a/solidity/contracts/token/TokenBridgeCctpV2.sol +++ b/solidity/contracts/token/TokenBridgeCctpV2.sol @@ -172,7 +172,8 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { function _bridgeViaCircle( uint32 circleDomain, bytes32 _recipient, - uint256 _amount + uint256 _amount, + uint256 _maxFee ) internal override { ITokenMessengerV2(address(tokenMessenger)).depositForBurn( _amount, @@ -180,7 +181,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 { _recipient, address(wrappedToken), bytes32(0), // allow anyone to relay - maxFeeBps, + _maxFee, minFinalityThreshold ); } diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index e4e3b5b9b9..f1e4a2529f 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -24,6 +24,7 @@ import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openze import {CctpMessageV1, BurnMessageV1} from "../../contracts/libs/CctpMessageV1.sol"; import {CctpMessageV2, BurnMessageV2} from "../../contracts/libs/CctpMessageV2.sol"; import {Message} from "../../contracts/libs/Message.sol"; +import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; import {CctpService} from "../../contracts/token/TokenBridgeCctpBase.sol"; import {TestRecipient} from "../../contracts/test/TestRecipient.sol"; import {TokenBridgeCctpBase} from "../../contracts/token/TokenBridgeCctpBase.sol"; @@ -488,6 +489,40 @@ contract TokenBridgeCctpV1Test is Test { tbDestination.verify(metadata, message); } + function test_verify_revertsWhen_invalidTokenMessageRecipient() public { + TestRecipient messageRecipient = new TestRecipient(); + messageRecipient.setInterchainSecurityModule(address(tbDestination)); + + bytes32 tokenRecipient = user.addressToBytes32(); + bytes memory messageBody = TokenMessage.format(tokenRecipient, amount); + + // Create a message with recipient instead of tbDestination + bytes memory invalidMessage = abi.encodePacked( + uint8(3), + uint32(0), + origin, + address(tbOrigin).addressToBytes32(), + destination, + address(messageRecipient).addressToBytes32(), + messageBody + ); + + bytes memory cctpMessage = _encodeCctpBurnMessage( + 0, + cctpOrigin, + tokenRecipient, + amount + ); + bytes memory attestation = bytes(""); + bytes memory metadata = abi.encode(cctpMessage, attestation); + + vm.expectRevert(bytes("Invalid token message recipient")); + tbDestination.verify(metadata, invalidMessage); + + vm.expectRevert(bytes("Invalid token message recipient")); + mailboxDestination.process(metadata, invalidMessage); + } + function test_revertsWhen_versionIsNotSupported() public virtual { tokenMessengerOrigin.setVersion(CCTP_VERSION_2); @@ -688,6 +723,9 @@ contract TokenBridgeCctpV1Test is Test { TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress()); _upgrade(ism); + vm.prank(ism.owner()); + ism.addDomain(origin, 6); + // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender vm.expectRevert(bytes("Unauthorized circle sender")); ism.verify(metadata, message); @@ -1274,7 +1312,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { 0x00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d ), // tokenMessengerDestination bytes32(0), // destinationCaller - maxFee, + fastFee, minFinalityThreshold, bytes("") ); @@ -1404,7 +1442,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { user.addressToBytes32(), address(tokenOrigin), bytes32(0), - maxFee, + fastFee, minFinalityThreshold ) ) @@ -1452,7 +1490,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { user.addressToBytes32(), address(tokenOrigin), bytes32(0), - maxFee, + fastFee, minFinalityThreshold ) ) From 0faa7832d3a57d701347b51d9346ce83b9bd1bca Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 28 Oct 2025 14:58:19 -0400 Subject: [PATCH 33/36] fix: minimize CCTP bytecode size (#7265) --- .../contracts/token/TokenBridgeCctpBase.sol | 44 +++++------ solidity/test/token/TokenBridgeCctp.t.sol | 73 +++---------------- 2 files changed, 28 insertions(+), 89 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index f803420aeb..bb0cc33947 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -17,7 +17,6 @@ import {TypeCasts} from "../libs/TypeCasts.sol"; import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol"; import {TokenRouter} from "./libs/TokenRouter.sol"; import {AbstractPostDispatchHook} from "../hooks/libs/AbstractPostDispatchHook.sol"; -import {AbstractMessageIdAuthorizedIsm} from "../isms/hook/AbstractMessageIdAuthorizedIsm.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -41,22 +40,11 @@ struct Domain { uint32 circle; } -// need intermediate contract to insert slots between TokenBridgeCctpBase and AbstractMessageIdAuthorizedIsm -abstract contract TokenBridgeCctpIntermediateStorage is +// see ./CCTP.md for sequence diagrams of the destination chain control flow +abstract contract TokenBridgeCctpBase is TokenBridgeCctpBaseStorage, AbstractCcipReadIsm, AbstractPostDispatchHook -{ - /// @notice Hyperlane domain => Domain struct. - /// We use a struct to avoid ambiguity with domain 0 being unknown. - mapping(uint32 hypDomain => Domain circleDomain) - internal _hyperlaneDomainMap; -} - -// see ./CCTP.md for sequence diagrams of the destination chain control flow -abstract contract TokenBridgeCctpBase is - TokenBridgeCctpIntermediateStorage, - AbstractMessageIdAuthorizedIsm { using Message for bytes; using TypeCasts for bytes32; @@ -72,11 +60,20 @@ abstract contract TokenBridgeCctpBase is // @notice CCTP token messenger contract ITokenMessenger public immutable tokenMessenger; + /// @notice Hyperlane domain => Domain struct. + /// We use a struct to avoid ambiguity with domain 0 being unknown. + mapping(uint32 hypDomain => Domain circleDomain) + internal _hyperlaneDomainMap; + /// @notice Circle domain => Domain struct. // We use a struct to avoid ambiguity with domain 0 being unknown. mapping(uint32 circleDomain => Domain hyperlaneDomain) internal _circleDomainMap; + /// @notice Maps messageId to whether or not the message has been verified + /// by the CCTP message transmitter + mapping(bytes32 messageId => bool) public isVerified; + /** * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. * @param hyperlaneDomain The Hyperlane domain. @@ -252,13 +249,9 @@ abstract contract TokenBridgeCctpBase is function verify( bytes calldata _metadata, bytes calldata _hyperlaneMessage - ) - external - override(AbstractMessageIdAuthorizedIsm, IInterchainSecurityModule) - returns (bool) - { + ) external returns (bool) { // check if hyperlane message has already been verified by CCTP - if (isVerified(_hyperlaneMessage)) { + if (isVerified[_hyperlaneMessage.id()]) { return true; } @@ -299,6 +292,11 @@ abstract contract TokenBridgeCctpBase is bytes32 circleSender, bytes32 messageId ) internal returns (bool) { + require( + msg.sender == address(messageTransmitter), + "Not message transmitter" + ); + // ensure that the message was sent from the hook on the origin chain uint32 origin = circleDomainToHyperlaneDomain(circleSource); require( @@ -306,15 +304,11 @@ abstract contract TokenBridgeCctpBase is "Unauthorized circle sender" ); - preVerifyMessage(messageId, 0); + isVerified[messageId] = true; return true; } - function _isAuthorized() internal view override returns (bool) { - return msg.sender == address(messageTransmitter); - } - function _offchainLookupCalldata( bytes calldata _message ) internal pure override returns (bytes memory) { diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index f1e4a2529f..87d9c10d29 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -928,7 +928,7 @@ contract TokenBridgeCctpV1Test is Test { assertTrue(result); assertTrue( - TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId) ); } @@ -950,9 +950,7 @@ contract TokenBridgeCctpV1Test is Test { ) public virtual { // Try to call from a non-message-transmitter address vm.prank(evil); - vm.expectRevert( - bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") - ); + vm.expectRevert(bytes("Not message transmitter")); TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( cctpOrigin, address(tbOrigin).addressToBytes32(), @@ -993,29 +991,6 @@ contract TokenBridgeCctpV1Test is Test { ); } - function test_handleReceiveMessage_revertsWhen_messageAlreadyDelivered( - bytes32 messageId - ) public virtual { - // First delivery succeeds - vm.prank(address(messageTransmitterDestination)); - TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( - cctpOrigin, - address(tbOrigin).addressToBytes32(), - abi.encode(messageId) - ); - - // Second delivery of the same message should revert - vm.prank(address(messageTransmitterDestination)); - vm.expectRevert( - bytes("AbstractMessageIdAuthorizedIsm: message already verified") - ); - TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage( - cctpOrigin, - address(tbOrigin).addressToBytes32(), - abi.encode(messageId) - ); - } - function test_verify_returnsTrue_afterDirectDelivery( bytes calldata message ) public virtual { @@ -1033,7 +1008,7 @@ contract TokenBridgeCctpV1Test is Test { // Verify the message is marked as verified assertTrue( - TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId) ); // Now call verify with empty metadata - should return true without attestation @@ -1645,7 +1620,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { assertTrue(result); assertTrue( - TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId) ); } @@ -1668,9 +1643,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { uint32 finalityThreshold ) public { vm.prank(evil); - vm.expectRevert( - bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") - ); + vm.expectRevert(bytes("Not message transmitter")); TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( cctpOrigin, address(tbOrigin).addressToBytes32(), @@ -1733,7 +1706,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { assertTrue(result); assertTrue( - TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId) ); } @@ -1757,9 +1730,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { uint32 finalityThreshold ) public { vm.prank(evil); - vm.expectRevert( - bytes("AbstractMessageIdAuthorizedIsm: sender is not the hook") - ); + vm.expectRevert(bytes("Not message transmitter")); TokenBridgeCctpV2(address(tbDestination)) .handleReceiveUnfinalizedMessage( cctpOrigin, @@ -1806,32 +1777,6 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { ); } - function test_handleReceiveMessage_revertsWhen_messageAlreadyDelivered( - bytes32 messageId - ) public override { - uint32 finalityThreshold = 2000; - // First delivery succeeds - vm.prank(address(messageTransmitterDestination)); - TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( - cctpOrigin, - address(tbOrigin).addressToBytes32(), - finalityThreshold, - abi.encode(messageId) - ); - - // Second delivery of the same message should revert - vm.prank(address(messageTransmitterDestination)); - vm.expectRevert( - bytes("AbstractMessageIdAuthorizedIsm: message already verified") - ); - TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage( - cctpOrigin, - address(tbOrigin).addressToBytes32(), - finalityThreshold, - abi.encode(messageId) - ); - } - function test_verify_returnsTrue_afterDirectDelivery( bytes calldata message ) public override { @@ -1852,7 +1797,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { // Verify the message is marked as verified assertTrue( - TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId) ); // Now call verify with empty metadata - should return true without attestation @@ -1880,7 +1825,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test { // Verify the message is marked as verified assertTrue( - TokenBridgeCctpBase(address(tbDestination)).isVerified(message) + TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId) ); // Now call verify with empty metadata - should return true without attestation From 7c1fc0cfe49263dc48b841293f687af5fc4e726a Mon Sep 17 00:00:00 2001 From: Yorke Rhodes IV Date: Tue, 28 Oct 2025 15:50:47 -0400 Subject: [PATCH 34/36] Fix lint --- solidity/contracts/token/TokenBridgeCctpBase.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index bb0cc33947..b2f7cb509e 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -72,7 +72,7 @@ abstract contract TokenBridgeCctpBase is /// @notice Maps messageId to whether or not the message has been verified /// by the CCTP message transmitter - mapping(bytes32 messageId => bool) public isVerified; + mapping(bytes32 messageId => bool verified) public isVerified; /** * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. From 7f5656b6439f775bae8dcb7a080eca8fb188a3ed Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Tue, 28 Oct 2025 15:51:29 -0400 Subject: [PATCH 35/36] fix: override transfer to with transfer fee behavior (#7264) --- .../contracts/token/TokenBridgeCctpBase.sol | 11 ++++ .../token/bridge/EverclearTokenBridge.sol | 16 +++++- .../contracts/token/libs/TokenCollateral.sol | 1 + solidity/contracts/token/libs/TokenRouter.sol | 22 +++++++- .../test/token/EverclearTokenBridge.t.sol | 56 +++++++++++++++++-- solidity/test/token/TokenBridgeCctp.t.sol | 27 +++++++++ 6 files changed, 126 insertions(+), 7 deletions(-) diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol index b2f7cb509e..f47c40337c 100644 --- a/solidity/contracts/token/TokenBridgeCctpBase.sol +++ b/solidity/contracts/token/TokenBridgeCctpBase.sol @@ -366,6 +366,17 @@ abstract contract TokenBridgeCctpBase is // do not transfer to recipient as the CCTP transfer will do it } + /** + * @inheritdoc TokenRouter + * @dev Overrides to transfer fees directly from the router balance since CCTP handles token delivery. + */ + function _transferFee( + address _recipient, + uint256 _amount + ) internal override { + wrappedToken.safeTransfer(_recipient, _amount); + } + function _bridgeViaCircle( uint32 _destination, bytes32 _recipient, diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol index 076c273e7c..6339b6bb25 100644 --- a/solidity/contracts/token/bridge/EverclearTokenBridge.sol +++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol @@ -404,6 +404,17 @@ contract EverclearTokenBridge is EverclearBridge { // Do nothing (tokens transferred to recipient directly) } + /** + * @inheritdoc TokenRouter + * @dev Transfers fees directly from router balance using ERC20 transfer. + */ + function _transferFee( + address _recipient, + uint256 _amount + ) internal override { + wrappedToken._transferTo(_recipient, _amount); + } + /** * @notice Encodes the intent calldata for ETH transfers * @return The encoded calldata for the everclear intent. @@ -429,16 +440,17 @@ contract EverclearEthBridge is EverclearBridge { using Address for address payable; using TypeCasts for bytes32; + uint256 private constant SCALE = 1; + /** * @notice Constructor to initialize the Everclear ETH bridge * @param _everclearAdapter The address of the Everclear adapter contract */ constructor( IWETH _weth, - uint256 _scale, address _mailbox, IEverclearAdapter _everclearAdapter - ) EverclearBridge(_everclearAdapter, IERC20(_weth), _scale, _mailbox) {} + ) EverclearBridge(_everclearAdapter, IERC20(_weth), SCALE, _mailbox) {} /** * @inheritdoc EverclearBridge diff --git a/solidity/contracts/token/libs/TokenCollateral.sol b/solidity/contracts/token/libs/TokenCollateral.sol index 2435c467a3..e9cf4f7b44 100644 --- a/solidity/contracts/token/libs/TokenCollateral.sol +++ b/solidity/contracts/token/libs/TokenCollateral.sol @@ -24,6 +24,7 @@ library NativeCollateral { /** * @title Handles deposits and withdrawals of WETH collateral. + * @dev TokenRouters must have `token() == address(0)` to use this library. */ library WETHCollateral { function _transferFromSender(IWETH token, uint256 _amount) internal { diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol index a02fcee685..7a96a3b1eb 100644 --- a/solidity/contracts/token/libs/TokenRouter.sol +++ b/solidity/contracts/token/libs/TokenRouter.sol @@ -189,7 +189,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { if (feeAmount > 0) { // transfer atomically so we don't need to keep track of collateral // and fee balances separately - _transferTo(_feeRecipient, feeAmount); + _transferFee(_feeRecipient, feeAmount); } remainingNativeValue = token() != address(0) ? _msgValue @@ -249,6 +249,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { * param _recipient The address of the recipient on the destination chain. * param _amount The amount or identifier of tokens to be sent to the remote recipient * @return feeAmount The external fee amount. + * @dev This fee must be denominated in the `token()` defined by this router. * @dev The default implementation returns 0, meaning no external fees are charged. * This function is intended to be overridden by derived contracts that have additional fees. * Known overrides: @@ -362,6 +363,25 @@ abstract contract TokenRouter is GasRouter, ITokenBridge { uint256 _amountOrId ) internal virtual; + /** + * @dev Should transfer `_amount` of tokens from this token router to the fee recipient. + * @dev Called by `_calculateFeesAndCharge` when fee recipient is set and feeAmount > 0. + * @dev The default implementation delegates to `_transferTo`, which works for most token routers + * where tokens are held by the router (e.g., collateral routers, synthetic token routers). + * @dev Override this function for bridges where tokens are NOT held by the router but fees still + * need to be paid (e.g., CCTP, Everclear). In those cases, use direct token transfers from the + * router's balance collected via `_transferFromSender`. + * Known overrides: + * - TokenBridgeCctpBase: Directly transfers tokens from router balance. + * - EverclearTokenBridge: Directly transfers tokens from router balance. + */ + function _transferFee( + address _recipient, + uint256 _amount + ) internal virtual { + _transferTo(_recipient, _amount); + } + /** * @dev Scales local amount to message amount (up by scale factor). * Known overrides: diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol index 4006acc338..747a2fa75b 100644 --- a/solidity/test/token/EverclearTokenBridge.t.sol +++ b/solidity/test/token/EverclearTokenBridge.t.sol @@ -28,6 +28,7 @@ import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../contracts/in import {Quote} from "../../contracts/interfaces/ITokenBridge.sol"; import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol"; import {IWETH} from "contracts/token/interfaces/IWETH.sol"; +import {LinearFee} from "../../contracts/token/fees/LinearFee.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; /** @@ -400,6 +401,56 @@ contract EverclearTokenBridgeTest is Test { assertEq(sig, feeSignature); } + function testTransferRemoteWithFeeRecipient() public { + // Create a LinearFee contract as the fee recipient + // LinearFee(token, maxFee, halfAmount, owner) + address feeCollector = makeAddr("feeCollector"); + LinearFee feeContract = new LinearFee( + address(token), + 1e6, // maxFee + TRANSFER_AMT / 2, // halfAmount + feeCollector + ); + + // Set fee recipient to the LinearFee contract + vm.prank(OWNER); + bridge.setFeeRecipient(address(feeContract)); + + uint256 initialAliceBalance = token.balanceOf(ALICE); + uint256 initialFeeContractBalance = token.balanceOf( + address(feeContract) + ); + uint256 initialBridgeBalance = token.balanceOf(address(bridge)); + + // Get the expected fee from the feeContract + uint256 expectedFeeRecipientFee = feeContract + .quoteTransferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT)[0].amount; + + vm.prank(ALICE); + bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT); + + // Check Alice paid the transfer amount + external fee + fee recipient fee + assertEq( + token.balanceOf(ALICE), + initialAliceBalance - + TRANSFER_AMT - + FEE_AMOUNT - + expectedFeeRecipientFee + ); + + // Check fee contract received the fee recipient fee (this tests the fix!) + assertEq( + token.balanceOf(address(feeContract)), + initialFeeContractBalance + expectedFeeRecipientFee + ); + + // Check bridge only holds the transfer amount + external fee, not the fee recipient fee + assertEq( + token.balanceOf(address(bridge)), + initialBridgeBalance + TRANSFER_AMT + FEE_AMOUNT + ); + } + function testTransferRemoteOutputAssetNotSet() public { vm.expectRevert("ETB: Output asset not set"); vm.prank(ALICE); @@ -797,10 +848,9 @@ contract EverclearTokenBridgeForkTest is BaseEverclearTokenBridgeForkTest { contract MockEverclearEthBridge is EverclearEthBridge { constructor( IWETH _weth, - uint256 _scale, address _mailbox, IEverclearAdapter _everclearAdapter - ) EverclearEthBridge(_weth, _scale, _mailbox, _everclearAdapter) {} + ) EverclearEthBridge(_weth, _mailbox, _everclearAdapter) {} bytes public lastIntent; function _createIntent( @@ -835,7 +885,6 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { // Deploy ETH bridge implementation MockEverclearEthBridge implementation = new MockEverclearEthBridge( IWETH(ARBITRUM_WETH), - 1, address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox everclearAdapter ); @@ -927,7 +976,6 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest { function testEthBridgeConstructor() public { EverclearEthBridge newBridge = new EverclearEthBridge( IWETH(ARBITRUM_WETH), - 1, address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox everclearAdapter ); diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol index 87d9c10d29..c097a1d72a 100644 --- a/solidity/test/token/TokenBridgeCctp.t.sol +++ b/solidity/test/token/TokenBridgeCctp.t.sol @@ -339,6 +339,12 @@ contract TokenBridgeCctpV1Test is Test { vm.startPrank(user); tokenOrigin.approve(address(tbOrigin), charge); + uint256 initialUserBalance = tokenOrigin.balanceOf(user); + uint256 initialFeeContractBalance = tokenOrigin.balanceOf( + address(feeContract) + ); + uint256 initialBridgeBalance = tokenOrigin.balanceOf(address(tbOrigin)); + uint64 cctpNonce = tokenMessengerOrigin.nextNonce(); vm.expectCall( @@ -358,6 +364,27 @@ contract TokenBridgeCctpV1Test is Test { user.addressToBytes32(), amount ); + + // Verify fee recipient received the fee (tests the fix!) + assertEq( + tokenOrigin.balanceOf(address(feeContract)), + initialFeeContractBalance + feeRecipientFee, + "Fee contract should receive fee" + ); + + // Verify user was charged correctly + assertEq( + tokenOrigin.balanceOf(user), + initialUserBalance - charge, + "User should be charged transfer amount + fees" + ); + + // Verify bridge doesn't hold the fee + assertEq( + tokenOrigin.balanceOf(address(tbOrigin)), + initialBridgeBalance, + "Bridge should not hold fee recipient fee" + ); } function test_verify() public { From f930794d77ad299796e7bac1163877c994b1ccfe Mon Sep 17 00:00:00 2001 From: Lee <6251863+ltyu@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:17:27 -0400 Subject: [PATCH 36/36] feat: Import and use safeApprove for yield routes (#7243) Co-authored-by: Yorke Rhodes IV --- .changeset/sweet-tips-bake.md | 5 +++ solidity/contracts/test/ERC20Test.sol | 12 +++++++ .../token/extensions/HypERC4626Collateral.sol | 5 ++- .../HypERC20CollateralVaultDeposit.t.sol | 32 +++++++++++++++---- 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 .changeset/sweet-tips-bake.md diff --git a/.changeset/sweet-tips-bake.md b/.changeset/sweet-tips-bake.md new file mode 100644 index 0000000000..7b2bfabc17 --- /dev/null +++ b/.changeset/sweet-tips-bake.md @@ -0,0 +1,5 @@ +--- +"@hyperlane-xyz/core": patch +--- + +Update Yield Routes (HypERC4626OwnerCollateral and HypERC4626Collateral) to use safeApprove diff --git a/solidity/contracts/test/ERC20Test.sol b/solidity/contracts/test/ERC20Test.sol index 62e4ec234b..190d6e59f5 100644 --- a/solidity/contracts/test/ERC20Test.sol +++ b/solidity/contracts/test/ERC20Test.sol @@ -294,3 +294,15 @@ contract XERC20LockboxTest is IXERC20Lockbox { withdrawTo(msg.sender, _amount); } } + +contract NonCompliantERC20Test { + // Returns returns void, instead of bool of an ERC20 compliant token + function approve(address _to, uint _value) public {} + + function allowance( + address owner, + address spender + ) public view virtual returns (uint256) { + return 0; + } +} diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index 842884def8..6586504ccc 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -22,6 +22,8 @@ import {ERC20Collateral} from "../libs/TokenCollateral.sol"; import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol"; // ============ External Imports ============ +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -31,6 +33,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; */ contract HypERC4626Collateral is TokenRouter { using ERC20Collateral for IERC20; + using SafeERC20 for IERC20; using TypeCasts for address; using TokenMessage for bytes; using Math for uint256; @@ -65,7 +68,7 @@ contract HypERC4626Collateral is TokenRouter { address _interchainSecurityModule, address _owner ) public initializer { - wrappedToken.approve(address(vault), type(uint256).max); + wrappedToken.safeApprove(address(vault), type(uint256).max); _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); } diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index 44cb2d7f1d..31507f8f2c 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -18,6 +18,7 @@ import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transpa import {HypERC4626} from "../../contracts/token/extensions/HypERC4626.sol"; import {HypERC20} from "../../contracts/token/HypERC20.sol"; +import {NonCompliantERC20Test} from "../../contracts/test/ERC20Test.sol"; import {ERC4626Test} from "../../contracts/test/ERC4626/ERC4626Test.sol"; import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; @@ -34,12 +35,11 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { HypERC4626OwnerCollateral internal erc20CollateralVaultDeposit; ERC4626Test vault; - function setUp() public override { - super.setUp(); - vault = new ERC4626Test(address(primaryToken), "Regular Vault", "RV"); - + function deployErc20CollateralVaultDeposit( + address _vault + ) public returns (HypERC4626OwnerCollateral) { HypERC4626OwnerCollateral implementation = new HypERC4626OwnerCollateral( - vault, + ERC4626(_vault), SCALE, address(localMailbox) ); @@ -54,8 +54,14 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { ) ); localToken = HypERC4626OwnerCollateral(address(proxy)); - erc20CollateralVaultDeposit = HypERC4626OwnerCollateral( - address(localToken) + return HypERC4626OwnerCollateral(address(localToken)); + } + function setUp() public override { + super.setUp(); + vault = new ERC4626Test(address(primaryToken), "Regular Vault", "RV"); + + (erc20CollateralVaultDeposit) = deployErc20CollateralVaultDeposit( + address(vault) ); erc20CollateralVaultDeposit.enrollRemoteRouter( @@ -75,6 +81,18 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest { return IERC20(primaryToken).balanceOf(_account); } + function testERC4626VaultDeposit_Initialize_NoncompliantERC20Token() + public + { + NonCompliantERC20Test nonCompliantToken = new NonCompliantERC20Test(); // Has approval() that returns void, instead of bool + ERC4626Test _vault = new ERC4626Test( + address(nonCompliantToken), + "Noncompliant Token Vault", + "NT" + ); + deployErc20CollateralVaultDeposit(address(_vault)); + } + function _transferRoundTripAndIncreaseYields( uint256 transferAmount, uint256 yieldAmount