Skip to content

Commit 7f5656b

Browse files
authored
fix: override transfer to with transfer fee behavior (#7264)
1 parent 7c1fc0c commit 7f5656b

File tree

6 files changed

+126
-7
lines changed

6 files changed

+126
-7
lines changed

solidity/contracts/token/TokenBridgeCctpBase.sol

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,17 @@ abstract contract TokenBridgeCctpBase is
366366
// do not transfer to recipient as the CCTP transfer will do it
367367
}
368368

369+
/**
370+
* @inheritdoc TokenRouter
371+
* @dev Overrides to transfer fees directly from the router balance since CCTP handles token delivery.
372+
*/
373+
function _transferFee(
374+
address _recipient,
375+
uint256 _amount
376+
) internal override {
377+
wrappedToken.safeTransfer(_recipient, _amount);
378+
}
379+
369380
function _bridgeViaCircle(
370381
uint32 _destination,
371382
bytes32 _recipient,

solidity/contracts/token/bridge/EverclearTokenBridge.sol

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,17 @@ contract EverclearTokenBridge is EverclearBridge {
404404
// Do nothing (tokens transferred to recipient directly)
405405
}
406406

407+
/**
408+
* @inheritdoc TokenRouter
409+
* @dev Transfers fees directly from router balance using ERC20 transfer.
410+
*/
411+
function _transferFee(
412+
address _recipient,
413+
uint256 _amount
414+
) internal override {
415+
wrappedToken._transferTo(_recipient, _amount);
416+
}
417+
407418
/**
408419
* @notice Encodes the intent calldata for ETH transfers
409420
* @return The encoded calldata for the everclear intent.
@@ -429,16 +440,17 @@ contract EverclearEthBridge is EverclearBridge {
429440
using Address for address payable;
430441
using TypeCasts for bytes32;
431442

443+
uint256 private constant SCALE = 1;
444+
432445
/**
433446
* @notice Constructor to initialize the Everclear ETH bridge
434447
* @param _everclearAdapter The address of the Everclear adapter contract
435448
*/
436449
constructor(
437450
IWETH _weth,
438-
uint256 _scale,
439451
address _mailbox,
440452
IEverclearAdapter _everclearAdapter
441-
) EverclearBridge(_everclearAdapter, IERC20(_weth), _scale, _mailbox) {}
453+
) EverclearBridge(_everclearAdapter, IERC20(_weth), SCALE, _mailbox) {}
442454

443455
/**
444456
* @inheritdoc EverclearBridge

solidity/contracts/token/libs/TokenCollateral.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ library NativeCollateral {
2424

2525
/**
2626
* @title Handles deposits and withdrawals of WETH collateral.
27+
* @dev TokenRouters must have `token() == address(0)` to use this library.
2728
*/
2829
library WETHCollateral {
2930
function _transferFromSender(IWETH token, uint256 _amount) internal {

solidity/contracts/token/libs/TokenRouter.sol

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge {
189189
if (feeAmount > 0) {
190190
// transfer atomically so we don't need to keep track of collateral
191191
// and fee balances separately
192-
_transferTo(_feeRecipient, feeAmount);
192+
_transferFee(_feeRecipient, feeAmount);
193193
}
194194
remainingNativeValue = token() != address(0)
195195
? _msgValue
@@ -249,6 +249,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge {
249249
* param _recipient The address of the recipient on the destination chain.
250250
* param _amount The amount or identifier of tokens to be sent to the remote recipient
251251
* @return feeAmount The external fee amount.
252+
* @dev This fee must be denominated in the `token()` defined by this router.
252253
* @dev The default implementation returns 0, meaning no external fees are charged.
253254
* This function is intended to be overridden by derived contracts that have additional fees.
254255
* Known overrides:
@@ -362,6 +363,25 @@ abstract contract TokenRouter is GasRouter, ITokenBridge {
362363
uint256 _amountOrId
363364
) internal virtual;
364365

366+
/**
367+
* @dev Should transfer `_amount` of tokens from this token router to the fee recipient.
368+
* @dev Called by `_calculateFeesAndCharge` when fee recipient is set and feeAmount > 0.
369+
* @dev The default implementation delegates to `_transferTo`, which works for most token routers
370+
* where tokens are held by the router (e.g., collateral routers, synthetic token routers).
371+
* @dev Override this function for bridges where tokens are NOT held by the router but fees still
372+
* need to be paid (e.g., CCTP, Everclear). In those cases, use direct token transfers from the
373+
* router's balance collected via `_transferFromSender`.
374+
* Known overrides:
375+
* - TokenBridgeCctpBase: Directly transfers tokens from router balance.
376+
* - EverclearTokenBridge: Directly transfers tokens from router balance.
377+
*/
378+
function _transferFee(
379+
address _recipient,
380+
uint256 _amount
381+
) internal virtual {
382+
_transferTo(_recipient, _amount);
383+
}
384+
365385
/**
366386
* @dev Scales local amount to message amount (up by scale factor).
367387
* Known overrides:

solidity/test/token/EverclearTokenBridge.t.sol

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../contracts/in
2828
import {Quote} from "../../contracts/interfaces/ITokenBridge.sol";
2929
import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol";
3030
import {IWETH} from "contracts/token/interfaces/IWETH.sol";
31+
import {LinearFee} from "../../contracts/token/fees/LinearFee.sol";
3132

3233
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
3334
/**
@@ -400,6 +401,56 @@ contract EverclearTokenBridgeTest is Test {
400401
assertEq(sig, feeSignature);
401402
}
402403

404+
function testTransferRemoteWithFeeRecipient() public {
405+
// Create a LinearFee contract as the fee recipient
406+
// LinearFee(token, maxFee, halfAmount, owner)
407+
address feeCollector = makeAddr("feeCollector");
408+
LinearFee feeContract = new LinearFee(
409+
address(token),
410+
1e6, // maxFee
411+
TRANSFER_AMT / 2, // halfAmount
412+
feeCollector
413+
);
414+
415+
// Set fee recipient to the LinearFee contract
416+
vm.prank(OWNER);
417+
bridge.setFeeRecipient(address(feeContract));
418+
419+
uint256 initialAliceBalance = token.balanceOf(ALICE);
420+
uint256 initialFeeContractBalance = token.balanceOf(
421+
address(feeContract)
422+
);
423+
uint256 initialBridgeBalance = token.balanceOf(address(bridge));
424+
425+
// Get the expected fee from the feeContract
426+
uint256 expectedFeeRecipientFee = feeContract
427+
.quoteTransferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT)[0].amount;
428+
429+
vm.prank(ALICE);
430+
bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT);
431+
432+
// Check Alice paid the transfer amount + external fee + fee recipient fee
433+
assertEq(
434+
token.balanceOf(ALICE),
435+
initialAliceBalance -
436+
TRANSFER_AMT -
437+
FEE_AMOUNT -
438+
expectedFeeRecipientFee
439+
);
440+
441+
// Check fee contract received the fee recipient fee (this tests the fix!)
442+
assertEq(
443+
token.balanceOf(address(feeContract)),
444+
initialFeeContractBalance + expectedFeeRecipientFee
445+
);
446+
447+
// Check bridge only holds the transfer amount + external fee, not the fee recipient fee
448+
assertEq(
449+
token.balanceOf(address(bridge)),
450+
initialBridgeBalance + TRANSFER_AMT + FEE_AMOUNT
451+
);
452+
}
453+
403454
function testTransferRemoteOutputAssetNotSet() public {
404455
vm.expectRevert("ETB: Output asset not set");
405456
vm.prank(ALICE);
@@ -797,10 +848,9 @@ contract EverclearTokenBridgeForkTest is BaseEverclearTokenBridgeForkTest {
797848
contract MockEverclearEthBridge is EverclearEthBridge {
798849
constructor(
799850
IWETH _weth,
800-
uint256 _scale,
801851
address _mailbox,
802852
IEverclearAdapter _everclearAdapter
803-
) EverclearEthBridge(_weth, _scale, _mailbox, _everclearAdapter) {}
853+
) EverclearEthBridge(_weth, _mailbox, _everclearAdapter) {}
804854

805855
bytes public lastIntent;
806856
function _createIntent(
@@ -835,7 +885,6 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest {
835885
// Deploy ETH bridge implementation
836886
MockEverclearEthBridge implementation = new MockEverclearEthBridge(
837887
IWETH(ARBITRUM_WETH),
838-
1,
839888
address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
840889
everclearAdapter
841890
);
@@ -927,7 +976,6 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest {
927976
function testEthBridgeConstructor() public {
928977
EverclearEthBridge newBridge = new EverclearEthBridge(
929978
IWETH(ARBITRUM_WETH),
930-
1,
931979
address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
932980
everclearAdapter
933981
);

solidity/test/token/TokenBridgeCctp.t.sol

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,12 @@ contract TokenBridgeCctpV1Test is Test {
339339
vm.startPrank(user);
340340
tokenOrigin.approve(address(tbOrigin), charge);
341341

342+
uint256 initialUserBalance = tokenOrigin.balanceOf(user);
343+
uint256 initialFeeContractBalance = tokenOrigin.balanceOf(
344+
address(feeContract)
345+
);
346+
uint256 initialBridgeBalance = tokenOrigin.balanceOf(address(tbOrigin));
347+
342348
uint64 cctpNonce = tokenMessengerOrigin.nextNonce();
343349

344350
vm.expectCall(
@@ -358,6 +364,27 @@ contract TokenBridgeCctpV1Test is Test {
358364
user.addressToBytes32(),
359365
amount
360366
);
367+
368+
// Verify fee recipient received the fee (tests the fix!)
369+
assertEq(
370+
tokenOrigin.balanceOf(address(feeContract)),
371+
initialFeeContractBalance + feeRecipientFee,
372+
"Fee contract should receive fee"
373+
);
374+
375+
// Verify user was charged correctly
376+
assertEq(
377+
tokenOrigin.balanceOf(user),
378+
initialUserBalance - charge,
379+
"User should be charged transfer amount + fees"
380+
);
381+
382+
// Verify bridge doesn't hold the fee
383+
assertEq(
384+
tokenOrigin.balanceOf(address(tbOrigin)),
385+
initialBridgeBalance,
386+
"Bridge should not hold fee recipient fee"
387+
);
361388
}
362389

363390
function test_verify() public {

0 commit comments

Comments
 (0)