Skip to content

Commit c100f7d

Browse files
authored
improve: enforce OFT fee payment by caller (#1082)
* enforce payment of Oft fee by caller Signed-off-by: Ihor Farion <ihor@umaproject.org> * revert version bump Signed-off-by: Ihor Farion <ihor@umaproject.org> * fix forge tests Signed-off-by: Ihor Farion <ihor@umaproject.org> * fix hardhat tests Signed-off-by: Ihor Farion <ihor@umaproject.org> * remove unused imports Signed-off-by: Ihor Farion <ihor@umaproject.org> * only wrap native token if the l2 token is not OFT Signed-off-by: Ihor Farion <ihor@umaproject.org> --------- Signed-off-by: Ihor Farion <ihor@umaproject.org>
1 parent f4edd22 commit c100f7d

File tree

8 files changed

+89
-48
lines changed

8 files changed

+89
-48
lines changed

contracts/Arbitrum_SpokePool.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ contract Arbitrum_SpokePool is SpokePool, CircleCCTPAdapter {
9191
if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) {
9292
_transferUsdc(withdrawalRecipient, amountToReturn);
9393
} else if (oftMessenger != address(0)) {
94-
_transferViaOFT(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn);
94+
_fundedTransferViaOft(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn);
9595
} else {
9696
// Check that the Ethereum counterpart of the L2 token is stored on this contract.
9797
address ethereumTokenToBridge = whitelistedTokens[l2TokenAddress];

contracts/Polygon_SpokePool.sol

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pragma solidity ^0.8.0;
44
import "./SpokePool.sol";
55
import "./PolygonTokenBridger.sol";
66
import "./external/interfaces/WETH9Interface.sol";
7+
import { IOFT } from "./interfaces/IOFT.sol";
78
import "./interfaces/SpokePoolInterface.sol";
89
import "./libraries/CircleCCTPAdapter.sol";
910

@@ -237,9 +238,11 @@ contract Polygon_SpokePool is IFxMessageProcessor, SpokePool, CircleCCTPAdapter
237238
emit SetPolygonTokenBridger(address(_polygonTokenBridger));
238239
}
239240

240-
function _preExecuteLeafHook(address) internal override {
241-
// Wraps MATIC --> WMATIC before distributing tokens from this contract.
242-
_wrap();
241+
function _preExecuteLeafHook(address l2TokenAddress) internal override {
242+
// Wraps MATIC --> WMATIC before distributing tokens from this contract. Only wrap if the token is not an OFT token
243+
if (_getOftMessenger(l2TokenAddress) == address(0)) {
244+
_wrap();
245+
}
243246
}
244247

245248
function _bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) internal override {
@@ -253,7 +256,7 @@ contract Polygon_SpokePool is IFxMessageProcessor, SpokePool, CircleCCTPAdapter
253256
if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) {
254257
_transferUsdc(withdrawalRecipient, amountToReturn);
255258
} else if (oftMessenger != address(0)) {
256-
_transferViaOFT(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn);
259+
_fundedTransferViaOft(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn);
257260
} else {
258261
PolygonIERC20Upgradeable(l2TokenAddress).safeIncreaseAllowance(
259262
address(polygonTokenBridger),

contracts/SpokePool.sol

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import "./upgradeable/MultiCallerUpgradeable.sol";
1212
import "./upgradeable/EIP712CrossChainUpgradeable.sol";
1313
import "./upgradeable/AddressLibUpgradeable.sol";
1414
import "./libraries/AddressConverters.sol";
15-
import { IOFT } from "./interfaces/IOFT.sol";
15+
import { IOFT, SendParam, MessagingFee } from "./interfaces/IOFT.sol";
1616
import { OFTTransportAdapter } from "./libraries/OFTTransportAdapter.sol";
1717

1818
import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
@@ -203,6 +203,8 @@ abstract contract SpokePool is
203203
event SetOFTMessenger(address indexed token, address indexed messenger);
204204

205205
error OFTTokenMismatch();
206+
/// @notice Thrown when the native fee sent by the caller is insufficient to cover the OFT transfer.
207+
error OFTFeeUnderpaid();
206208

207209
/**
208210
* @notice Construct the SpokePool. Normally, logic contracts used in upgradeable proxies shouldn't
@@ -1750,6 +1752,30 @@ abstract contract SpokePool is
17501752
return oftMessengers[_token];
17511753
}
17521754

1755+
/**
1756+
* @notice Perform an OFT transfer where the caller supplies the native fee via msg.value.
1757+
* @dev Supports overpayment: any excess native token is refunded to msg.sender before executing the transfer.
1758+
* This function does not re-quote and uses the provided `fee` with `_sendOftTransfer`.
1759+
* Must be invoked from a payable context.
1760+
* @param _token ERC-20 token to transfer.
1761+
* @param _messenger OFT messenger contract on the current chain for `_token`.
1762+
* @param _to Destination address on the remote chain.
1763+
* @param _amount Amount of tokens to transfer.
1764+
*/
1765+
function _fundedTransferViaOft(IERC20 _token, IOFT _messenger, address _to, uint256 _amount) internal {
1766+
(SendParam memory sendParam, MessagingFee memory fee) = _buildOftTransfer(_messenger, _to, _amount);
1767+
1768+
if (fee.nativeFee > msg.value) {
1769+
revert OFTFeeUnderpaid();
1770+
}
1771+
// Refund any overpayment to the caller using a safe native transfer.
1772+
uint256 refund = msg.value - fee.nativeFee;
1773+
if (refund > 0) {
1774+
AddressLibUpgradeable.sendValue(payable(msg.sender), refund);
1775+
}
1776+
_sendOftTransfer(_token, _messenger, sendParam, fee);
1777+
}
1778+
17531779
// Implementing contract needs to override this to ensure that only the appropriate cross chain admin can execute
17541780
// certain admin functions. For L2 contracts, the cross chain admin refers to some L1 address or contract, and for
17551781
// L1, this would just be the same admin of the HubPool.

contracts/Universal_SpokePool.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ contract Universal_SpokePool is OwnableUpgradeable, SpokePool, CircleCCTPAdapter
185185
if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) {
186186
_transferUsdc(withdrawalRecipient, amountToReturn);
187187
} else if (oftMessenger != address(0)) {
188-
_transferViaOFT(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn);
188+
_fundedTransferViaOft(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn);
189189
} else {
190190
revert NotImplemented();
191191
}

contracts/libraries/OFTTransportAdapter.sol

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,25 @@ contract OFTTransportAdapter {
6767
* @param _amount amount to send.
6868
*/
6969
function _transferViaOFT(IERC20 _token, IOFT _messenger, address _to, uint256 _amount) internal {
70+
(SendParam memory sendParam, MessagingFee memory fee) = _buildOftTransfer(_messenger, _to, _amount);
71+
_sendOftTransfer(_token, _messenger, sendParam, fee);
72+
}
73+
74+
/**
75+
* @notice Build OFT send params and quote the native fee.
76+
* @dev Sets `minAmountLD == amountLD` to disallow silent deductions (e.g. dust removal) by OFT.
77+
* The fee is quoted for payment in native token.
78+
* @param _messenger OFT messenger contract on the current chain for the token being sent.
79+
* @param _to Destination address on the remote chain.
80+
* @param _amount Amount of tokens to transfer.
81+
* @return sendParam The encoded OFT send parameters.
82+
* @return fee The quoted MessagingFee required for the transfer.
83+
*/
84+
function _buildOftTransfer(
85+
IOFT _messenger,
86+
address _to,
87+
uint256 _amount
88+
) internal view returns (SendParam memory, MessagingFee memory) {
7089
bytes32 to = _to.toBytes32();
7190

7291
SendParam memory sendParam = SendParam(
@@ -90,13 +109,32 @@ contract OFTTransportAdapter {
90109

91110
// `false` in the 2nd param here refers to `bool _payInLzToken`. We will pay in native token, so set to `false`
92111
MessagingFee memory fee = _messenger.quoteSend(sendParam, false);
112+
113+
return (sendParam, fee);
114+
}
115+
116+
/**
117+
* @notice Execute an OFT transfer using pre-built params and fee.
118+
* @dev Verifies fee bounds and equality of sent/received amounts. Pays native fee from this contract.
119+
* @param _token ERC-20 token to transfer.
120+
* @param _messenger OFT messenger contract on the current chain for `_token`.
121+
* @param sendParam Pre-built OFT send parameters.
122+
* @param fee Quoted MessagingFee to pay for this transfer.
123+
*/
124+
function _sendOftTransfer(
125+
IERC20 _token,
126+
IOFT _messenger,
127+
SendParam memory sendParam,
128+
MessagingFee memory fee
129+
) internal {
93130
// Create a stack variable to optimize gas usage on subsequent reads
94131
uint256 nativeFee = fee.nativeFee;
95132
if (nativeFee > OFT_FEE_CAP) revert OftFeeCapExceeded();
96133
if (nativeFee > address(this).balance) revert OftInsufficientBalanceForFee();
97134
if (fee.lzTokenFee != 0) revert OftLzFeeNotZero();
98135

99136
// Approve the exact _amount for `_messenger` to spend. Fee will be paid in native token
137+
uint256 _amount = sendParam.amountLD;
100138
_token.forceApprove(address(_messenger), _amount);
101139

102140
(, OFTReceipt memory oftReceipt) = _messenger.send{ value: nativeFee }(sendParam, fee, address(this));

test/evm/foundry/local/Universal_SpokePool.t.sol

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
66
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
77

88
import { Universal_SpokePool, IHelios } from "../../../../contracts/Universal_SpokePool.sol";
9+
import "../../../../contracts/SpokePool.sol";
910
import "../../../../contracts/libraries/CircleCCTPAdapter.sol";
1011
import "../../../../contracts/test/MockCCTP.sol";
1112
import { IOFT, SendParam, MessagingFee } from "../../../../contracts/interfaces/IOFT.sol";
@@ -26,11 +27,7 @@ contract MockHelios is IHelios {
2627
headTimestamp = _timestamp;
2728
}
2829

29-
function getStorageSlot(
30-
uint256,
31-
address,
32-
bytes32 _key
33-
) external view returns (bytes32) {
30+
function getStorageSlot(uint256, address, bytes32 _key) external view returns (bytes32) {
3431
return storageSlots[_key];
3532
}
3633
}
@@ -62,7 +59,7 @@ contract MockUniversalSpokePool is Universal_SpokePool {
6259
)
6360
{}
6461

65-
function test_bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) external {
62+
function test_bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) external payable {
6663
_bridgeTokensToHubPool(amountToReturn, l2TokenAddress);
6764
}
6865
}
@@ -365,13 +362,10 @@ contract UniversalSpokePoolTest is Test {
365362
spokePool.executeMessage(nonce, value, 100);
366363
nonce++; // Increment nonce for the next message
367364

368-
// Fund the spokePool with enough native currency to attempt the transaction but less than the high fee
369-
// The check for OFT_FEE_CAP happens before the balance check.
370-
deal(address(spokePool), spokePool.OFT_FEE_CAP());
371-
372-
// Expect the OftFeeCapExceeded error from OFTTransportAdapter
365+
// Expect the OftFeeCapExceeded error from OFTTransportAdapter.
366+
// Provide msg.value to cover the quoted fee so the cap guard is triggered.
373367
vm.expectRevert(OFTTransportAdapter.OftFeeCapExceeded.selector);
374-
spokePool.test_bridgeTokensToHubPool(usdcMintAmount, address(usdt));
368+
spokePool.test_bridgeTokensToHubPool{ value: highNativeFee }(usdcMintAmount, address(usdt));
375369
}
376370

377371
function testBridgeTokensToHubPool_oft() public {
@@ -423,12 +417,9 @@ contract UniversalSpokePoolTest is Test {
423417
spokePool.executeMessage(nonce, value, 100);
424418
nonce++;
425419

426-
// Ensure spokePool has less balance than nativeFee
427-
deal(address(spokePool), nativeFee - 1);
428-
429-
// Expect revert due to insufficient balance for fee
430-
vm.expectRevert(OFTTransportAdapter.OftInsufficientBalanceForFee.selector);
431-
spokePool.test_bridgeTokensToHubPool(usdcMintAmount, address(usdt));
420+
// Expect revert due to caller underpaying the required OFT native fee
421+
vm.expectRevert(SpokePool.OFTFeeUnderpaid.selector);
422+
spokePool.test_bridgeTokensToHubPool{ value: nativeFee - 1 }(usdcMintAmount, address(usdt));
432423
}
433424

434425
function testBridgeTokensToHubPool_oft_incorrectAmountReceived() public {

test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -235,16 +235,8 @@ describe("Arbitrum Spoke Pool", function () {
235235
] as MessagingFeeStructOutput;
236236
l2OftMessenger.quoteSend.returns(msgFeeStruct);
237237

238-
// Fund the spoke pool with enough ETH to cover the fee as `executeRelayerRefundLeaf` is not payable.
239-
await hre.network.provider.send("hardhat_setBalance", [
240-
arbitrumSpokePool.address,
241-
// @dev Notice, this value is very sensitive to change. It wants a hex string WITHOUT leading zeroes after 0x, whereas
242-
// BigNumber's .toHexString() can easily return a leading zero (probably because just converting from bytes)
243-
toWei("2").toHexString(),
244-
]);
245-
246238
await expect(
247-
arbitrumSpokePool.executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]))
239+
arbitrumSpokePool.executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]), { value: nativeFee })
248240
).to.be.revertedWith("OftLzFeeNotZero");
249241
});
250242

@@ -256,31 +248,22 @@ describe("Arbitrum Spoke Pool", function () {
256248
] as MessagingFeeStructOutput;
257249
l2OftMessenger.quoteSend.returns(msgFeeStruct);
258250

259-
// Fund the spoke pool with enough ETH to cover the fee as `executeRelayerRefundLeaf` is not payable.
260-
await hre.network.provider.send("hardhat_setBalance", [
261-
arbitrumSpokePool.address,
262-
// @dev Notice, this value is very sensitive to change. It wants a hex string WITHOUT leading zeroes after 0x, whereas
263-
// BigNumber's .toHexString() can easily return a leading zero (probably because just converting from bytes)
264-
highNativeFee.toHexString(),
265-
]);
266-
267251
await expect(
268-
arbitrumSpokePool.executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]))
252+
arbitrumSpokePool.executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]), { value: highNativeFee })
269253
).to.be.revertedWith("OftFeeCapExceeded");
270254
});
271255

272-
it("reverts with OftInsufficientBalanceForFee if spoke pool has not enough ETH for fee", async function () {
256+
it("reverts with OFTFeeUnderpaid if caller does not supply the required OFT fee", async function () {
273257
const nativeFee = toWei("0.1");
274258
const msgFeeStruct: MessagingFeeStructOutput = [
275259
nativeFee, // nativeFee
276260
BigNumber.from(0), // lzTokenFee
277261
] as MessagingFeeStructOutput;
278262
l2OftMessenger.quoteSend.returns(msgFeeStruct);
279263

280-
// Note: the `executeRelayerRefundLeaf` is not payable, so the native fee must be covered by the contract's balance
281264
await expect(
282265
arbitrumSpokePool.executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]))
283-
).to.be.revertedWith("OftInsufficientBalanceForFee");
266+
).to.be.revertedWith("OFTFeeUnderpaid");
284267
});
285268

286269
it("reverts with OftIncorrectAmountReceivedLD if OFT receipt has wrong received amount", async function () {

test/evm/hardhat/chain-specific-spokepools/Polygon_SpokePool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ describe("Polygon Spoke Pool", function () {
195195
expect(l2OftMessenger.send).to.have.been.calledWith(sendParam, msgFeeStruct, polygonSpokePool.address);
196196
});
197197

198-
it("reverts with OftInsufficientBalanceForFee if spoke pool has not enough ETH for OFT fee", async function () {
198+
it("reverts with OFTFeeUnderpaid if caller does not supply the required OFT fee", async function () {
199199
l2OftMessenger.token.returns(l2UsdtContract.address);
200200
const setOftMessengerData = polygonSpokePool.interface.encodeFunctionData("setOftMessenger", [
201201
l2UsdtContract.address,
@@ -221,7 +221,7 @@ describe("Polygon Spoke Pool", function () {
221221

222222
await expect(
223223
polygonSpokePool.executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]))
224-
).to.be.revertedWith("OftInsufficientBalanceForFee");
224+
).to.be.revertedWith("OFTFeeUnderpaid");
225225
});
226226

227227
it("Only correct caller can set the cross domain admin", async function () {

0 commit comments

Comments
 (0)