Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions solidity/contracts/token/TokenBridgeCctpBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions solidity/contracts/token/bridge/EverclearTokenBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions solidity/contracts/token/libs/TokenCollateral.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 21 additions & 1 deletion solidity/contracts/token/libs/TokenRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
56 changes: 52 additions & 4 deletions solidity/test/token/EverclearTokenBridge.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -835,7 +885,6 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest {
// Deploy ETH bridge implementation
MockEverclearEthBridge implementation = new MockEverclearEthBridge(
IWETH(ARBITRUM_WETH),
1,
address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
everclearAdapter
);
Expand Down Expand Up @@ -927,7 +976,6 @@ contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest {
function testEthBridgeConstructor() public {
EverclearEthBridge newBridge = new EverclearEthBridge(
IWETH(ARBITRUM_WETH),
1,
address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
everclearAdapter
);
Expand Down
27 changes: 27 additions & 0 deletions solidity/test/token/TokenBridgeCctp.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,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(
Expand All @@ -357,6 +363,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 {
Expand Down
Loading