Skip to content

Commit ab65752

Browse files
committed
implement dynamic fee validation with maxFeeThouBPS and on-chain fee calculation
1 parent 4adb083 commit ab65752

File tree

10 files changed

+121
-26
lines changed

10 files changed

+121
-26
lines changed

src/actions/SweepCCTPV2Action.sol

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {OtimFee} from "./fee-models/OtimFee.sol";
1313
import {IAction} from "./interfaces/IAction.sol";
1414
import {ISweepCCTPV2Action, INSTRUCTION_TYPEHASH, ARGUMENTS_TYPEHASH} from "./interfaces/ISweepCCTPV2Action.sol";
1515

16-
import {InvalidArguments, BalanceUnderThreshold, CCTPTokenNotSupported} from "./errors/Errors.sol";
16+
import {InvalidArguments, BalanceUnderThreshold, CCTPTokenNotSupported, CCTPMaxFeeTooLow} from "./errors/Errors.sol";
1717

1818
/// @title SweepCCTPV2Action
1919
/// @author Otim Labs, Inc.
@@ -54,7 +54,7 @@ contract SweepCCTPV2Action is IAction, ISweepCCTPV2Action, OtimFee {
5454
arguments.threshold,
5555
arguments.endBalance,
5656
arguments.destinationCaller,
57-
arguments.maxFee,
57+
arguments.maxFeeThouBPS,
5858
arguments.minFinalityThreshold,
5959
hash(arguments.fee)
6060
)
@@ -112,6 +112,20 @@ contract SweepCCTPV2Action is IAction, ISweepCCTPV2Action, OtimFee {
112112
emit CCTPBurnLimitReached(arguments.token, burnLimitPerMessage);
113113
}
114114

115+
// validate maxFeeThouBPS against CCTP's minFee
116+
uint256 calculatedMaxFee;
117+
if (arguments.minFinalityThreshold < 2000) {
118+
uint256 cctpMinFee = tokenMessengerV2.minFee();
119+
if (arguments.maxFeeThouBPS < cctpMinFee) {
120+
revert CCTPMaxFeeTooLow(arguments.maxFeeThouBPS, cctpMinFee);
121+
}
122+
// calculate actual fee based on transfer amount
123+
calculatedMaxFee = tokenMessengerV2.getMinFeeAmount(transferAmount);
124+
} else {
125+
// standard transfers (minFinalityThreshold >= 2000) have no cost
126+
calculatedMaxFee = 0;
127+
}
128+
115129
// approve the transferAmount to the CCTP TokenMessenger contract
116130
// slither-disable-next-line unused-return
117131
IERC20(arguments.token).approve(address(tokenMessengerV2), transferAmount);
@@ -123,7 +137,7 @@ contract SweepCCTPV2Action is IAction, ISweepCCTPV2Action, OtimFee {
123137
arguments.destinationMintRecipient,
124138
arguments.token,
125139
arguments.destinationCaller,
126-
arguments.maxFee,
140+
calculatedMaxFee,
127141
arguments.minFinalityThreshold
128142
);
129143

src/actions/TransferCCTPV2Action.sol

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {OtimFee} from "./fee-models/OtimFee.sol";
1414
import {IAction} from "./interfaces/IAction.sol";
1515
import {ITransferCCTPV2Action, INSTRUCTION_TYPEHASH, ARGUMENTS_TYPEHASH} from "./interfaces/ITransferCCTPV2Action.sol";
1616

17-
import {InvalidArguments, InsufficientBalance, CCTPTokenNotSupported} from "./errors/Errors.sol";
17+
import {InvalidArguments, InsufficientBalance, CCTPTokenNotSupported, CCTPMaxFeeTooLow} from "./errors/Errors.sol";
1818

1919
/// @title TransferCCTPV2Action
2020
/// @author Otim Labs, Inc.
@@ -54,7 +54,7 @@ contract TransferCCTPV2Action is IAction, ITransferCCTPV2Action, Interval, OtimF
5454
arguments.destinationDomain,
5555
arguments.destinationMintRecipient,
5656
arguments.destinationCaller,
57-
arguments.maxFee,
57+
arguments.maxFeeThouBPS,
5858
arguments.minFinalityThreshold,
5959
hash(arguments.schedule),
6060
hash(arguments.fee)
@@ -114,6 +114,20 @@ contract TransferCCTPV2Action is IAction, ITransferCCTPV2Action, Interval, OtimF
114114
emit CCTPBurnLimitReached(arguments.token, burnLimitPerMessage);
115115
}
116116

117+
// validate maxFeeThouBPS against CCTP's minFee (only for fast transfers)
118+
uint256 calculatedMaxFee;
119+
if (arguments.minFinalityThreshold < 2000) {
120+
uint256 cctpMinFee = tokenMessengerV2.minFee();
121+
if (arguments.maxFeeThouBPS < cctpMinFee) {
122+
revert CCTPMaxFeeTooLow(arguments.maxFeeThouBPS, cctpMinFee);
123+
}
124+
// calculate actual fee based on transfer amount
125+
calculatedMaxFee = tokenMessengerV2.getMinFeeAmount(transferAmount);
126+
} else {
127+
// standard transfers (minFinalityThreshold >= 2000) have no cost
128+
calculatedMaxFee = 0;
129+
}
130+
117131
// approve the transferAmount to the CCTP TokenMessenger contract
118132
// slither-disable-next-line unused-return
119133
IERC20(arguments.token).approve(address(tokenMessengerV2), transferAmount);
@@ -125,7 +139,7 @@ contract TransferCCTPV2Action is IAction, ITransferCCTPV2Action, Interval, OtimF
125139
arguments.destinationMintRecipient,
126140
arguments.token,
127141
arguments.destinationCaller,
128-
arguments.maxFee,
142+
calculatedMaxFee,
129143
arguments.minFinalityThreshold
130144
);
131145

src/actions/errors/Errors.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ error InstructionAlreadyDeactivated();
1515
error CallOnceFailed(address target, bytes4 selector, bytes result);
1616

1717
error CCTPTokenNotSupported();
18+
error CCTPMaxFeeTooLow(uint32 userMaxFeeThouBPS, uint256 cctpMinFee);
1819

1920
error MaxDepositTooLow();
2021
error MaxWithdrawTooLow();

src/actions/external/ITokenMessengerV2.sol

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,14 @@ interface ITokenMessengerV2 {
4343
uint32 minFinalityThreshold,
4444
bytes calldata hookData
4545
) external;
46+
47+
/// @notice Returns the minimum fee for a given amount
48+
/// @param amount The amount for which to calculate the minimum fee
49+
/// @return The minimum fee for the given amount
50+
function getMinFeeAmount(uint256 amount) external view returns (uint256);
51+
52+
/// @notice Minimum fee for all transfers in 1/1000 basis points
53+
/// @return The minimum fee in thousandth basis points (e.g., 10 = 0.01%)
54+
function minFee() external view returns (uint256);
4655
}
4756

src/actions/interfaces/ISweepCCTPV2Action.sol

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ pragma solidity ^0.8.26;
44
import {IOtimFee} from "../fee-models/interfaces/IOtimFee.sol";
55

66
bytes32 constant INSTRUCTION_TYPEHASH = keccak256(
7-
"Instruction(uint256 salt,uint256 maxExecutions,address action,SweepCCTPV2 sweepCCTPV2)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)SweepCCTPV2(address token,uint32 destinationDomain,bytes32 destinationMintRecipient,uint256 threshold,uint256 endBalance,bytes32 destinationCaller,uint256 maxFee,uint32 minFinalityThreshold,Fee fee)"
7+
"Instruction(uint256 salt,uint256 maxExecutions,address action,SweepCCTPV2 sweepCCTPV2)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)SweepCCTPV2(address token,uint32 destinationDomain,bytes32 destinationMintRecipient,uint256 threshold,uint256 endBalance,bytes32 destinationCaller,uint32 maxFeeThouBPS,uint32 minFinalityThreshold,Fee fee)"
88
);
99

1010
bytes32 constant ARGUMENTS_TYPEHASH = keccak256(
11-
"SweepCCTPV2(address token,uint32 destinationDomain,bytes32 destinationMintRecipient,uint256 threshold,uint256 endBalance,bytes32 destinationCaller,uint256 maxFee,uint32 minFinalityThreshold,Fee fee)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)"
11+
"SweepCCTPV2(address token,uint32 destinationDomain,bytes32 destinationMintRecipient,uint256 threshold,uint256 endBalance,bytes32 destinationCaller,uint32 maxFeeThouBPS,uint32 minFinalityThreshold,Fee fee)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)"
1212
);
1313

1414
/// @title ISweepCCTPV2Action
@@ -22,7 +22,7 @@ interface ISweepCCTPV2Action is IOtimFee {
2222
/// @param threshold - the sweep threshold
2323
/// @param endBalance - the account's balance after the sweep
2424
/// @param destinationCaller - the address allowed to call receiveMessage on destination (bytes32(0) for anyone)
25-
/// @param maxFee - the maximum fee for the transfer in burn token units
25+
/// @param maxFeeThouBPS - max fee in 1/1000 BPS (e.g., 10 = 0.01%, 100 = 0.1%)
2626
/// @param minFinalityThreshold - minimum finality threshold (e.g., 1000=fast, 2000=standard)
2727
/// @param fee - the fee to be paid
2828
struct SweepCCTPV2 {
@@ -32,7 +32,7 @@ interface ISweepCCTPV2Action is IOtimFee {
3232
uint256 threshold;
3333
uint256 endBalance;
3434
bytes32 destinationCaller;
35-
uint256 maxFee;
35+
uint32 maxFeeThouBPS;
3636
uint32 minFinalityThreshold;
3737
Fee fee;
3838
}

src/actions/interfaces/ITransferCCTPV2Action.sol

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import {IInterval} from "../schedules/interfaces/IInterval.sol";
55
import {IOtimFee} from "../fee-models/interfaces/IOtimFee.sol";
66

77
bytes32 constant INSTRUCTION_TYPEHASH = keccak256(
8-
"Instruction(uint256 salt,uint256 maxExecutions,address action,TransferCCTPV2 transferCCTPV2)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)Schedule(uint256 startAt,uint256 startBy,uint256 interval,uint256 timeout)TransferCCTPV2(address token,uint256 amount,uint32 destinationDomain,bytes32 destinationMintRecipient,bytes32 destinationCaller,uint256 maxFee,uint32 minFinalityThreshold,Schedule schedule,Fee fee)"
8+
"Instruction(uint256 salt,uint256 maxExecutions,address action,TransferCCTPV2 transferCCTPV2)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)Schedule(uint256 startAt,uint256 startBy,uint256 interval,uint256 timeout)TransferCCTPV2(address token,uint256 amount,uint32 destinationDomain,bytes32 destinationMintRecipient,bytes32 destinationCaller,uint32 maxFeeThouBPS,uint32 minFinalityThreshold,Schedule schedule,Fee fee)"
99
);
1010

1111
bytes32 constant ARGUMENTS_TYPEHASH = keccak256(
12-
"TransferCCTPV2(address token,uint256 amount,uint32 destinationDomain,bytes32 destinationMintRecipient,bytes32 destinationCaller,uint256 maxFee,uint32 minFinalityThreshold,Schedule schedule,Fee fee)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)Schedule(uint256 startAt,uint256 startBy,uint256 interval,uint256 timeout)"
12+
"TransferCCTPV2(address token,uint256 amount,uint32 destinationDomain,bytes32 destinationMintRecipient,bytes32 destinationCaller,uint32 maxFeeThouBPS,uint32 minFinalityThreshold,Schedule schedule,Fee fee)Fee(address token,uint256 maxBaseFeePerGas,uint256 maxPriorityFeePerGas,uint256 executionFee)Schedule(uint256 startAt,uint256 startBy,uint256 interval,uint256 timeout)"
1313
);
1414

1515
/// @title ITransferCCTPV2Action
@@ -22,7 +22,7 @@ interface ITransferCCTPV2Action is IInterval, IOtimFee {
2222
/// @param destinationDomain - the destination domain for the CCTP transfer
2323
/// @param destinationMintRecipient - the address of the mint recipient for the CCTP transfer (in bytes32 format)
2424
/// @param destinationCaller - the address allowed to call receiveMessage on destination (bytes32(0) for anyone)
25-
/// @param maxFee - the maximum fee for the transfer in burn token units
25+
/// @param maxFeeThouBPS - max fee in 1/1000 BPS (e.g., 10 = 0.01%, 100 = 0.1%)
2626
/// @param minFinalityThreshold - minimum finality threshold (e.g., 1000=fast, 2000=standard)
2727
/// @param schedule - the schedule parameters for the transfer
2828
/// @param fee - the fee to be paid
@@ -32,7 +32,7 @@ interface ITransferCCTPV2Action is IInterval, IOtimFee {
3232
uint32 destinationDomain;
3333
bytes32 destinationMintRecipient;
3434
bytes32 destinationCaller;
35-
uint256 maxFee;
35+
uint32 maxFeeThouBPS;
3636
uint32 minFinalityThreshold;
3737
Schedule schedule;
3838
Fee fee;

test/actions/SweepCCTPV2.t.sol

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ contract SweepCCTPV2Test is InstructionForkTestContext {
4646
threshold: DEFAULT_THRESHOLD,
4747
endBalance: DEFAULT_END_BALANCE,
4848
destinationCaller: bytes32(0),
49-
maxFee: 1e6,
49+
maxFeeThouBPS: 10,
5050
minFinalityThreshold: 1000,
5151
fee: DEFAULT_FEE
5252
});
@@ -79,6 +79,28 @@ contract SweepCCTPV2Test is InstructionForkTestContext {
7979
// Note: In V2, this returns the TokenMessenger address, not a separate remote address
8080
DEFAULT_DESTINATION_TOKEN_MESSENGER = bytes32(uint256(uint160(SEPOLIA_TOKEN_MESSENGER_V2)));
8181

82+
// mock CCTP V2 fee functions
83+
vm.mockCall(
84+
SEPOLIA_TOKEN_MESSENGER_V2,
85+
abi.encodeWithSignature("minFee()"),
86+
abi.encode(10) // 0.01% in 1/1000 BPS
87+
);
88+
vm.mockCall(
89+
SEPOLIA_TOKEN_MESSENGER_V2,
90+
abi.encodeWithSelector(bytes4(keccak256("getMinFeeAmount(uint256)"))),
91+
abi.encode(uint256(0)) // default: no fee
92+
);
93+
vm.mockCall(
94+
SEPOLIA_TOKEN_MESSENGER_V2,
95+
abi.encodeWithSignature("getMinFeeAmount(uint256)", 50000001),
96+
abi.encode(uint256(5000))
97+
);
98+
vm.mockCall(
99+
SEPOLIA_TOKEN_MESSENGER_V2,
100+
abi.encodeWithSignature("getMinFeeAmount(uint256)", 100000001),
101+
abi.encode(uint256(10000))
102+
);
103+
82104
DEFAULT_ACTION = address(sweepCCTPV2Action);
83105
DEFAULT_ARGS = abi.encode(DEFAULT_ACTION_ARGS);
84106
}
@@ -102,7 +124,7 @@ contract SweepCCTPV2Test is InstructionForkTestContext {
102124
/// @notice test that sweeping USDC via CCTP V2 with standard transfer works as expected
103125
function test_sweepCCTPV2_standardTransfer() public {
104126
DEFAULT_ACTION_ARGS.minFinalityThreshold = 2000;
105-
DEFAULT_ACTION_ARGS.maxFee = 0;
127+
DEFAULT_ACTION_ARGS.maxFeeThouBPS = 0;
106128

107129
buildInstruction(DEFAULT_SALT, DEFAULT_MAX_EXECUTIONS, DEFAULT_ACTION, abi.encode(DEFAULT_ACTION_ARGS));
108130

test/actions/TransferCCTPV2.t.sol

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ contract TransferCCTPV2Test is InstructionForkTestContext {
4646
destinationDomain: DEFAULT_DESTINATION_DOMAIN,
4747
destinationMintRecipient: bytes32(uint256(1)),
4848
destinationCaller: bytes32(0),
49-
maxFee: 1e6,
49+
maxFeeThouBPS: 10,
5050
minFinalityThreshold: 1000,
5151
schedule: DEFAULT_SCHEDULE,
5252
fee: DEFAULT_FEE
@@ -80,6 +80,23 @@ contract TransferCCTPV2Test is InstructionForkTestContext {
8080
// Note: In V2, this returns the TokenMessenger address, not a separate remote address
8181
DEFAULT_DESTINATION_TOKEN_MESSENGER = bytes32(uint256(uint160(SEPOLIA_TOKEN_MESSENGER_V2)));
8282

83+
// mock CCTP V2 fee functions
84+
vm.mockCall(
85+
SEPOLIA_TOKEN_MESSENGER_V2,
86+
abi.encodeWithSignature("minFee()"),
87+
abi.encode(10) // 0.01% in 1/1000 BPS
88+
);
89+
vm.mockCall(
90+
SEPOLIA_TOKEN_MESSENGER_V2,
91+
abi.encodeWithSelector(bytes4(keccak256("getMinFeeAmount(uint256)"))),
92+
abi.encode(uint256(0)) // default: no fee
93+
);
94+
vm.mockCall(
95+
SEPOLIA_TOKEN_MESSENGER_V2,
96+
abi.encodeWithSignature("getMinFeeAmount(uint256)", 100e6),
97+
abi.encode(uint256(10000))
98+
);
99+
83100
USER_START_BALANCE = 5_000_000e6;
84101

85102
DEFAULT_ACTION = address(transferCCTPV2Action);
@@ -105,7 +122,7 @@ contract TransferCCTPV2Test is InstructionForkTestContext {
105122
/// @notice test that transferring USDC via CCTP V2 with standard transfer works as expected
106123
function test_transferCCTPV2_standardTransfer() public {
107124
DEFAULT_ACTION_ARGS.minFinalityThreshold = 2000;
108-
DEFAULT_ACTION_ARGS.maxFee = 0;
125+
DEFAULT_ACTION_ARGS.maxFeeThouBPS = 0;
109126

110127
buildInstruction(DEFAULT_SALT, DEFAULT_MAX_EXECUTIONS, DEFAULT_ACTION, abi.encode(DEFAULT_ACTION_ARGS));
111128

test/gas-estimation/EstimateSweepCCTPV2GasConstant.t.sol

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ contract EstimateSweepCCTPV2GasConstant is InstructionForkTestContext {
5151
);
5252

5353
actionManager.addAction(address(sweepCCTPV2Action));
54+
55+
// mock CCTP V2 fee functions
56+
vm.mockCall(
57+
SEPOLIA_TOKEN_MESSENGER_V2,
58+
abi.encodeWithSignature("minFee()"),
59+
abi.encode(10) // 0.01% in 1/1000 BPS
60+
);
61+
vm.mockCall(
62+
SEPOLIA_TOKEN_MESSENGER_V2,
63+
abi.encodeWithSelector(bytes4(keccak256("getMinFeeAmount(uint256)"))),
64+
abi.encode(uint256(0)) // return 0 to avoid CCTP "maxFee < amount" validation
65+
);
5466
}
5567

5668
// check that the SWEEP_CCTP_V2_ACTION_GAS_CONSTANT doesn't result in an underpayment of the fee
@@ -69,16 +81,12 @@ contract EstimateSweepCCTPV2GasConstant is InstructionForkTestContext {
6981
arguments.destinationDomain = 2; // OP Sepolia
7082
arguments.destinationMintRecipient = bytes32(uint256(1));
7183
arguments.destinationCaller = bytes32(0);
72-
arguments.maxFee = 1e6;
84+
arguments.maxFeeThouBPS = 10;
7385
arguments.minFinalityThreshold = 1000;
7486
arguments.threshold = threshold;
7587
arguments.endBalance = endBalance;
7688
// fuzz test must pass argument validation
7789
vm.assume(endBalance <= threshold);
78-
// assume threshold is greater than maxFee for CCTP V2 validation
79-
vm.assume(threshold > arguments.maxFee);
80-
// assume transfer amount (threshold - endBalance) is greater than maxFee for CCTP V2 validation
81-
vm.assume(threshold - endBalance > arguments.maxFee);
8290
// assume a reasonable threshold (less than whale balance)
8391
vm.assume(threshold < IERC20(SEPOLIA_USDC).balanceOf(SEPOLIA_USDC_WHALE));
8492
// assume threshold is at least 2 USDC to have meaningful transfer amounts

test/gas-estimation/EstimateTransferCCTPV2GasConstant.t.sol

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ contract EstimateTransferCCTPV2GasConstant is InstructionForkTestContext {
5151
);
5252

5353
actionManager.addAction(address(transferCCTPV2Action));
54+
55+
// mock CCTP V2 fee functions
56+
vm.mockCall(
57+
SEPOLIA_TOKEN_MESSENGER_V2,
58+
abi.encodeWithSignature("minFee()"),
59+
abi.encode(10) // 0.01% in 1/1000 BPS
60+
);
61+
vm.mockCall(
62+
SEPOLIA_TOKEN_MESSENGER_V2,
63+
abi.encodeWithSelector(bytes4(keccak256("getMinFeeAmount(uint256)"))),
64+
abi.encode(uint256(0)) // return 0 to avoid CCTP "maxFee < amount" validation
65+
);
5466
}
5567

5668
// check that the TRANSFER_CCTP_V2_ACTION_GAS_CONSTANT doesn't result in an underpayment of the fee
@@ -72,11 +84,9 @@ contract EstimateTransferCCTPV2GasConstant is InstructionForkTestContext {
7284
arguments.destinationDomain = 2; // OP Sepolia
7385
arguments.destinationMintRecipient = bytes32(uint256(1));
7486
arguments.destinationCaller = bytes32(0);
75-
arguments.maxFee = 1e6;
87+
arguments.maxFeeThouBPS = 10;
7688
arguments.minFinalityThreshold = 1000;
7789

78-
// assume amount is greater than maxFee for CCTP V2 validation
79-
vm.assume(amount > arguments.maxFee);
8090
// assume a reasonable amount (less than whale balance, at least 2 USDC)
8191
vm.assume(amount >= 2e6 && amount < IERC20(SEPOLIA_USDC).balanceOf(SEPOLIA_USDC_WHALE));
8292
arguments.amount = amount;

0 commit comments

Comments
 (0)