Skip to content

Commit a62b662

Browse files
authored
fix: CCTP v2 external fee quoting (#7148)
1 parent 867997d commit a62b662

File tree

3 files changed

+90
-20
lines changed

3 files changed

+90
-20
lines changed

solidity/contracts/token/TokenBridgeCctpBase.sol

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol";
1616
import {TypeCasts} from "../libs/TypeCasts.sol";
1717
import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol";
1818
import {TokenRouter} from "./libs/TokenRouter.sol";
19+
import {AbstractPostDispatchHook} from "../hooks/libs/AbstractPostDispatchHook.sol";
1920

2021
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
2122
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
@@ -38,7 +39,7 @@ abstract contract TokenBridgeCctpBaseStorage is TokenRouter {
3839
abstract contract TokenBridgeCctpBase is
3940
TokenBridgeCctpBaseStorage,
4041
AbstractCcipReadIsm,
41-
IPostDispatchHook
42+
AbstractPostDispatchHook
4243
{
4344
using Message for bytes;
4445
using TypeCasts for bytes32;
@@ -284,26 +285,19 @@ abstract contract TokenBridgeCctpBase is
284285
return uint8(IPostDispatchHook.HookTypes.CCTP);
285286
}
286287

287-
/// @inheritdoc IPostDispatchHook
288-
function supportsMetadata(
289-
bytes calldata /*metadata*/
290-
) public pure override returns (bool) {
291-
return true;
292-
}
293-
294-
/// @inheritdoc IPostDispatchHook
295-
function quoteDispatch(
296-
bytes calldata,
297-
bytes calldata
298-
) external pure override returns (uint256) {
288+
/// @inheritdoc AbstractPostDispatchHook
289+
function _quoteDispatch(
290+
bytes calldata /*metadata*/,
291+
bytes calldata /*message*/
292+
) internal pure override returns (uint256) {
299293
return 0;
300294
}
301295

302-
/// @inheritdoc IPostDispatchHook
303-
function postDispatch(
304-
bytes calldata /*metadata*/,
296+
/// @inheritdoc AbstractPostDispatchHook
297+
function _postDispatch(
298+
bytes calldata metadata,
305299
bytes calldata message
306-
) external payable override {
300+
) internal override {
307301
bytes32 id = message.id();
308302
require(_isLatestDispatched(id), "Message not dispatched");
309303

@@ -312,6 +306,8 @@ abstract contract TokenBridgeCctpBase is
312306
uint32 circleDestination = hyperlaneDomainToCircleDomain(destination);
313307

314308
_sendMessageIdToIsm(circleDestination, ism, id);
309+
310+
_refund(metadata, message, address(this).balance);
315311
}
316312

317313
/**

solidity/contracts/token/TokenBridgeCctpV2.sol

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 {
4343
_tokenMessenger
4444
)
4545
{
46+
require(_maxFeeBps < 10_000, "maxFeeBps must be less than 100%");
4647
maxFeeBps = _maxFeeBps;
4748
minFinalityThreshold = _minFinalityThreshold;
4849
}
@@ -52,13 +53,34 @@ contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 {
5253
/**
5354
* @inheritdoc TokenRouter
5455
* @dev Overrides to indicate v2 fees.
56+
*
57+
* Hyperlane uses a "minimum amount out" approach where users specify the exact amount
58+
* they want the recipient to receive on the destination chain. This provides a better
59+
* UX by guaranteeing predictable outcomes regardless of underlying bridge fee structures.
60+
*
61+
* However, some underlying bridges like CCTP charge fees as a percentage of the input
62+
* amount (amountIn), not the output amount. This requires "reversing" the fee calculation:
63+
* we need to determine what input amount (after fees are deducted) will result in the
64+
* desired output amount reaching the recipient.
65+
*
66+
* The formula solves for the fee needed such that after Circle takes their percentage,
67+
* the recipient receives exactly `amount`:
68+
*
69+
* (amount + fee) * (10_000 - maxFeeBps) / 10_000 = amount
70+
*
71+
* Solving for fee:
72+
* fee = (amount * maxFeeBps) / (10_000 - maxFeeBps)
73+
*
74+
* Example: If amount = 100 USDC and maxFeeBps = 10 (0.1%):
75+
* fee = (100 * 10) / (10_000 - 10) = 1000 / 9990 ≈ 0.1001 USDC
76+
* We deposit 100.1001 USDC, Circle takes 0.1001 USDC, recipient gets exactly 100 USDC.
5577
*/
5678
function _externalFeeAmount(
5779
uint32,
5880
bytes32,
5981
uint256 amount
6082
) internal view override returns (uint256 feeAmount) {
61-
return (amount * maxFeeBps) / 10_000;
83+
return (amount * maxFeeBps) / (10_000 - maxFeeBps);
6284
}
6385

6486
function _getCCTPVersion() internal pure override returns (uint32) {

solidity/test/token/TokenBridgeCctp.t.sol

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {IMailbox} from "../../contracts/interfaces/IMailbox.sol";
3232
import {ISpecifiesInterchainSecurityModule} from "../../contracts/interfaces/IInterchainSecurityModule.sol";
3333
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
3434
import {LinearFee} from "../../contracts/token/fees/LinearFee.sol";
35+
import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol";
36+
import {StandardHookMetadata} from "../../contracts/hooks/libs/StandardHookMetadata.sol";
3537

3638
contract TokenBridgeCctpV1Test is Test {
3739
using TypeCasts for address;
@@ -670,6 +672,9 @@ contract TokenBridgeCctpV1Test is Test {
670672
assertEq(actualId, id);
671673
}
672674

675+
// needed for hook refunds
676+
receive() external payable {}
677+
673678
function testFork_postDispatch(
674679
bytes32 recipient,
675680
bytes calldata body
@@ -778,6 +783,53 @@ contract TokenBridgeCctpV1Test is Test {
778783
tbOrigin.postDispatch(bytes(""), message);
779784
}
780785

786+
function test_hookType() public {
787+
assertEq(tbOrigin.hookType(), uint8(IPostDispatchHook.HookTypes.CCTP));
788+
}
789+
790+
function test_supportsMetadata() public {
791+
assertEq(tbOrigin.supportsMetadata(bytes("")), true);
792+
assertEq(
793+
tbOrigin.supportsMetadata(
794+
StandardHookMetadata.format(0, 100_000, address(this))
795+
),
796+
true
797+
);
798+
}
799+
800+
function test_quoteDispatch() public {
801+
assertEq(tbOrigin.quoteDispatch(bytes(""), bytes("")), 0);
802+
}
803+
804+
function test_postDispatch_refundsExcessValue(
805+
bytes32 recipient,
806+
bytes calldata body
807+
) public virtual {
808+
address refundAddress = makeAddr("refundAddress");
809+
uint256 refundBalanceBefore = refundAddress.balance;
810+
811+
// Create metadata with refund address using standard hook metadata format
812+
bytes memory metadata = abi.encodePacked(
813+
uint16(1), // variant
814+
uint256(0), // msgValue
815+
uint256(0), // gasLimit
816+
refundAddress // refundAddress
817+
);
818+
819+
uint256 excessValue = 1 ether;
820+
821+
mailboxOrigin.dispatch{value: excessValue}(
822+
destination,
823+
recipient,
824+
body,
825+
metadata,
826+
tbOrigin
827+
);
828+
829+
// Verify refund was sent
830+
assertEq(refundAddress.balance, refundBalanceBefore + excessValue);
831+
}
832+
781833
function test_verify_hookMessage(bytes calldata body) public {
782834
TestRecipient recipient = new TestRecipient();
783835
recipient.setInterchainSecurityModule(address(tbDestination));
@@ -964,7 +1016,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test {
9641016
version,
9651017
address(tokenOrigin).addressToBytes32(),
9661018
recipient,
967-
amount + (amount * maxFee) / 10_000,
1019+
amount + (amount * maxFee) / (10_000 - maxFee),
9681020
sender.addressToBytes32(),
9691021
maxFee,
9701022
bytes("")
@@ -1401,7 +1453,7 @@ contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test {
14011453
);
14021454
assertEq(quotes[1].token, address(tokenOrigin));
14031455
assertEq(quotes[1].amount, amount);
1404-
uint256 fastFee = (amount * maxFee) / 10_000;
1456+
uint256 fastFee = (amount * maxFee) / (10_000 - maxFee);
14051457
assertEq(quotes[2].token, address(tokenOrigin));
14061458
assertEq(quotes[2].amount, fastFee);
14071459
}

0 commit comments

Comments
 (0)