Skip to content

Commit 84b33d3

Browse files
committed
fix: audit issues & updated test cases
1 parent e5afcf7 commit 84b33d3

File tree

2 files changed

+197
-4
lines changed

2 files changed

+197
-4
lines changed

src/modules/frax/FraxModule.sol

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/Reentran
66
import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
77
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
88

9-
import { WithdrawalRequest } from "../../interfaces/ICashModule.sol";
9+
import { IBridgeModule } from "../../interfaces/IBridgeModule.sol";
10+
import { SafeData, WithdrawalRequest } from "../../interfaces/ICashModule.sol";
1011
import { IEtherFiSafe } from "../../interfaces/IEtherFiSafe.sol";
1112
import { IFraxCustodian } from "../../interfaces/IFraxCustodian.sol";
1213
import { IFraxRemoteHop, MessagingFee } from "../../interfaces/IFraxRemoteHop.sol";
@@ -19,7 +20,7 @@ import { ModuleCheckBalance } from "../ModuleCheckBalance.sol";
1920
* @notice Module for interacting with FraxUSD
2021
* @dev Extends ModuleBase to provide FraxUSD integration for Safes
2122
*/
22-
contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient {
23+
contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient, IBridgeModule {
2324
using MessageHashUtils for bytes32;
2425
using SafeCast for uint256;
2526

@@ -45,6 +46,10 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
4546
/// @notice Layerzero ethereum EID
4647
uint32 public constant ETHEREUM_EID = 30_101;
4748

49+
/// @notice Dust threshold for LayerZero OFT decimal conversion (1e12)
50+
/// @dev Amounts must be multiples of this value to avoid dust being locked
51+
uint256 public constant DUST_THRESHOLD = 1e12;
52+
4853
/// @notice TypeHash for deposit function signature
4954
bytes32 public constant DEPOSIT_SIG = keccak256("deposit");
5055

@@ -54,6 +59,9 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
5459
/// @notice TypeHash for async withdraw function signature
5560
bytes32 public constant REQUEST_ASYNC_WITHDRAW_SIG = keccak256("requestAsyncWithdraw");
5661

62+
/// @notice TypeHash for cancel async withdraw function signature
63+
bytes32 public constant CANCEL_ASYNC_WITHDRAW_SIG = keccak256("cancelAsyncWithdraw");
64+
5765
/// @notice Cross-chain withdrawal requests for each Safe
5866
mapping(address safe => AsyncWithdrawal withdrawal) private withdrawals;
5967

@@ -69,6 +77,18 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
6977
/// @notice Error thrown when no matching withdrawal is found for the safe
7078
error CannotFindMatchingWithdrawalForSafe();
7179

80+
/// @notice Thrown when a caller lacks the proper authorization for an operation
81+
error Unauthorized();
82+
83+
/// @notice Error for Invalid Owner quorum signatures
84+
error InvalidSignatures();
85+
86+
/// @notice Error thrown when withdrawal amount contains dust (not a multiple of DUST_THRESHOLD)
87+
error AmountContainsDust();
88+
89+
/// @notice Error thrown when custodian has insufficient balance for synchronous deposit
90+
error InsufficientCustodianBalance();
91+
7292
/// @notice Emitted when safe deposits USDC into Frax USD
7393
event Deposit(address indexed safe, address indexed inputToken, uint256 inputAmount, uint256 outputAmount);
7494

@@ -81,6 +101,9 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
81101
/// @notice Emitted when safe executes an async withdrawal from FraxUSD
82102
event AsyncWithdrawalExecuted(address indexed safe, uint256 amountToWithdraw, uint32 dstEid, address to);
83103

104+
/// @notice Emitted when an async withdrawal request is cancelled
105+
event AsyncWithdrawalCancelled(address indexed safe, uint256 amountToWithdraw, uint32 dstEid, address to);
106+
84107
/**
85108
* @notice Contract constructor
86109
* @param _etherFiDataProvider Address of the EtherFiDataProvider contract
@@ -91,7 +114,7 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
91114
* @custom:throws InvalidInput If any provided address is zero
92115
*/
93116
constructor(address _etherFiDataProvider, address _fraxusd, address _custodian, address _remoteHop) ModuleBase(_etherFiDataProvider) ModuleCheckBalance(_etherFiDataProvider) {
94-
if (_etherFiDataProvider == address(0) || _fraxusd == address(0) || _remoteHop == address(0)) revert InvalidInput();
117+
if (_etherFiDataProvider == address(0) || _fraxusd == address(0) || _custodian == address(0) || _remoteHop == address(0)) revert InvalidInput();
95118

96119
fraxusd = _fraxusd;
97120
custodian = _custodian;
@@ -134,13 +157,20 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
134157
* @param amountToDeposit The amount of asset tokens to deposit
135158
* @param minReturnAmount The minimum amount of asset to return (18 decimals - FraxUSD)
136159
* @custom:throws InvalidInput If amount or min return is zero
160+
* @custom:throws InsufficientCustodianBalance If custodian doesn't have enough balance for synchronous deposit
137161
* @custom:throws InsufficientReturnAmount If the FraxUSD received is less than expected
138162
*/
139163
function _deposit(address safe, address assetToDeposit, uint256 amountToDeposit, uint256 minReturnAmount) internal {
140164
if (amountToDeposit == 0 || assetToDeposit == address(0)) revert InvalidInput();
141165

142166
_checkAmountAvailable(safe, assetToDeposit, amountToDeposit);
143167

168+
// Validate that custodian has sufficient balance for synchronous deposit
169+
// The custodian needs at least minReturnAmount of fraxusd tokens to fulfill the deposit synchronously
170+
// If it doesn't have enough, it will attempt a cross-chain mint which requires native fees and is async
171+
uint256 custodianBalance = ERC20(fraxusd).balanceOf(custodian);
172+
if (custodianBalance < minReturnAmount) revert InsufficientCustodianBalance();
173+
144174
address[] memory to = new address[](2);
145175
bytes[] memory data = new bytes[](2);
146176
uint256[] memory values = new uint256[](2);
@@ -282,6 +312,44 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
282312
delete withdrawals[safe];
283313
}
284314

315+
/**
316+
* @notice Cancels an async withdrawal request for a safe
317+
* @param safe Address of the EtherFiSafe
318+
* @param signers Array of addresses of safe owners that signed the transaction
319+
* @param signatures Array of signatures from the signers
320+
*/
321+
function cancelAsyncWithdraw(address safe, address[] calldata signers, bytes[] calldata signatures) external nonReentrant onlyEtherFiSafe(safe) {
322+
_checkCancelAsyncWithdrawSignature(safe, signers, signatures);
323+
324+
AsyncWithdrawal memory withdrawal = withdrawals[safe];
325+
if (withdrawal.recipient == address(0)) revert NoAsyncWithdrawalQueued();
326+
327+
SafeData memory data = cashModule.getData(safe);
328+
// If there is a withdrawal pending from this module on Cash Module, cancel it
329+
if (data.pendingWithdrawalRequest.recipient == address(this)) cashModule.cancelWithdrawalByModule(safe);
330+
331+
if (withdrawal.recipient != address(0)) {
332+
emit AsyncWithdrawalCancelled(safe, withdrawal.amount, ETHEREUM_EID, withdrawal.recipient);
333+
delete withdrawals[safe];
334+
}
335+
}
336+
337+
/**
338+
* @notice Cancels an async withdrawal request by the cash module
339+
* @dev This function is intended to be called by the cash module to cancel an async withdrawal
340+
* @param safe Address of the EtherFiSafe
341+
*/
342+
function cancelBridgeByCashModule(address safe) external {
343+
if (msg.sender != etherFiDataProvider.getCashModule()) revert Unauthorized();
344+
345+
AsyncWithdrawal memory withdrawal = withdrawals[safe];
346+
// Return if no withdrawal found for Frax
347+
if (withdrawal.recipient == address(0)) return;
348+
349+
emit AsyncWithdrawalCancelled(safe, withdrawal.amount, ETHEREUM_EID, withdrawal.recipient);
350+
delete withdrawals[safe];
351+
}
352+
285353
/**
286354
* @dev Creates a digest hash for the async withdraw operation
287355
* @param _recipient Recipient address from Frax api
@@ -292,15 +360,29 @@ contract FraxModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient
292360
return keccak256(abi.encodePacked(REQUEST_ASYNC_WITHDRAW_SIG, block.chainid, address(this), _useNonce(safe), safe, abi.encode(fraxusd, _recipient, _withdrawAmount))).toEthSignedMessageHash();
293361
}
294362

363+
/**
364+
* @dev Verifies that the transaction has been properly signed by the required signers
365+
* @param safe Address of the EtherFiSafe
366+
* @param signers Array of addresses that signed the transaction
367+
* @param signatures Array of signatures from the signers
368+
* @custom:throws InvalidSignatures if the signatures are invalid
369+
*/
370+
function _checkCancelAsyncWithdrawSignature(address safe, address[] calldata signers, bytes[] calldata signatures) internal {
371+
bytes32 digestHash = keccak256(abi.encodePacked(CANCEL_ASYNC_WITHDRAW_SIG, block.chainid, address(this), IEtherFiSafe(safe).useNonce(), safe)).toEthSignedMessageHash();
372+
if (!IEtherFiSafe(safe).checkSignatures(digestHash, signers, signatures)) revert InvalidSignatures();
373+
}
374+
295375
/**
296376
* @notice Function to request an async withdrawal
297377
* @param safe Address for user safe
298378
* @param _recipient Recipient address from Frax api
299379
* @param _withdrawAmount Amount to withdraw asynchronously
300-
* @custom:throws InvalidInput If the amount is zero
380+
* @custom:throws InvalidInput If the amount is zero or recipient is zero
381+
* @custom:throws AmountContainsDust If the amount is not a multiple of DUST_THRESHOLD
301382
*/
302383
function _requestAsyncWithdraw(address safe, address _recipient, uint256 _withdrawAmount) internal {
303384
if (_withdrawAmount == 0 || _recipient == address(0)) revert InvalidInput();
385+
if (_withdrawAmount % DUST_THRESHOLD != 0) revert AmountContainsDust();
304386

305387
cashModule.requestWithdrawalByModule(safe, fraxusd, _withdrawAmount);
306388

test/safe/modules/frax/FraxModule.t.sol

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ contract FraxModuleTest is SafeTestSetup {
5454
uint256 amountToDeposit = 1000 * 10 ** 6; // 1000 USDC (6 decimals)
5555
uint256 minReturnAmount = 1000 * 10 ** 18;
5656
deal(address(usdc), address(safe), amountToDeposit);
57+
// Ensure custodian has sufficient balance for synchronous deposit
58+
deal(address(fraxusd), custodian, minReturnAmount);
5759

5860
bytes32 digestHash = keccak256(
5961
abi.encodePacked(
@@ -468,6 +470,115 @@ contract FraxModuleTest is SafeTestSetup {
468470
fraxModule.requestAsyncWithdraw(address(safe), depositAddress, 0, owner1, signature1);
469471
}
470472

473+
function test_requestAsyncWithdrawal_revertsWithAmountContainingDust() public {
474+
uint256 amount = 1000 * 10 ** 18 + 1; // Not a multiple of DUST_THRESHOLD (1e12)
475+
deal(address(fraxusd), address(safe), amount);
476+
477+
bytes32 digestHash = keccak256(abi.encodePacked(fraxModule.REQUEST_ASYNC_WITHDRAW_SIG(), block.chainid, address(fraxModule), fraxModule.getNonce(address(safe)), safe, abi.encode(fraxusd, depositAddress, amount))).toEthSignedMessageHash();
478+
479+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Pk, digestHash);
480+
bytes memory signature = abi.encodePacked(r, s, v);
481+
482+
vm.expectRevert(FraxModule.AmountContainsDust.selector);
483+
fraxModule.requestAsyncWithdraw(address(safe), depositAddress, amount, owner1, signature);
484+
}
485+
486+
function test_requestAsyncWithdrawal_succeedsWithValidDustAmount() public {
487+
// Amount that is a multiple of DUST_THRESHOLD (1e12)
488+
uint256 amount = 1000 * 10 ** 18; // 1000 * 1e18 = 1000 * 1e6 * 1e12, which is a multiple of 1e12
489+
deal(address(fraxusd), address(safe), amount);
490+
491+
bytes32 digestHash = keccak256(abi.encodePacked(fraxModule.REQUEST_ASYNC_WITHDRAW_SIG(), block.chainid, address(fraxModule), fraxModule.getNonce(address(safe)), safe, abi.encode(fraxusd, depositAddress, amount))).toEthSignedMessageHash();
492+
493+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Pk, digestHash);
494+
bytes memory signature = abi.encodePacked(r, s, v);
495+
496+
vm.expectEmit(true, true, true, true);
497+
emit FraxModule.AsyncWithdrawalRequested(address(safe), amount, fraxModule.ETHEREUM_EID(), depositAddress);
498+
fraxModule.requestAsyncWithdraw(address(safe), depositAddress, amount, owner1, signature);
499+
}
500+
501+
function test_deposit_revertsWithInsufficientCustodianBalance() public {
502+
uint256 amountToDeposit = 1000 * 10 ** 6; // 1000 USDC (6 decimals)
503+
uint256 minReturnAmount = 1000 * 10 ** 18;
504+
deal(address(usdc), address(safe), amountToDeposit);
505+
// Custodian has less than minReturnAmount
506+
deal(address(fraxusd), custodian, minReturnAmount - 1);
507+
508+
bytes32 digestHash = keccak256(abi.encodePacked(fraxModule.DEPOSIT_SIG(), block.chainid, address(fraxModule), fraxModule.getNonce(address(safe)), address(safe), abi.encode(address(usdc), amountToDeposit, minReturnAmount))).toEthSignedMessageHash();
509+
510+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Pk, digestHash);
511+
bytes memory signature = abi.encodePacked(r, s, v);
512+
513+
vm.expectRevert(FraxModule.InsufficientCustodianBalance.selector);
514+
fraxModule.deposit(address(safe), address(usdc), amountToDeposit, minReturnAmount, owner1, signature);
515+
}
516+
517+
function test_executeAsyncWithdraw_revertsWithInsufficientNativeFee() public {
518+
uint256 amount = 1000 * 10 ** 18;
519+
deal(address(fraxusd), address(safe), amount);
520+
deal(address(safe), 1 ether);
521+
522+
_requestAsyncWithdrawal(amount);
523+
524+
(uint64 withdrawalDelay,,) = cashModule.getDelays();
525+
vm.warp(block.timestamp + withdrawalDelay);
526+
527+
MessagingFee memory fee = fraxModule.quoteAsyncWithdraw(depositAddress, amount);
528+
529+
// Try with insufficient fee
530+
vm.expectRevert(FraxModule.InsufficientNativeFee.selector);
531+
fraxModule.executeAsyncWithdraw{ value: fee.nativeFee - 1 }(address(safe));
532+
}
533+
534+
function test_cancelAsyncWithdraw_success() public {
535+
uint256 amount = 1000 * 10 ** 18;
536+
deal(address(fraxusd), address(safe), amount);
537+
538+
_requestAsyncWithdrawal(amount);
539+
540+
// Verify withdrawal exists
541+
FraxModule.AsyncWithdrawal memory withdrawalBefore = fraxModule.getPendingWithdrawal(address(safe));
542+
assertEq(withdrawalBefore.amount, amount);
543+
544+
// Cancel by safe owners - use safe.nonce() to match what useNonce() will return
545+
bytes32 digestHash = keccak256(abi.encodePacked(fraxModule.CANCEL_ASYNC_WITHDRAW_SIG(), block.chainid, address(fraxModule), safe.nonce(), address(safe))).toEthSignedMessageHash();
546+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Pk, digestHash);
547+
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(owner2Pk, digestHash);
548+
549+
address[] memory signers = new address[](2);
550+
signers[0] = owner1;
551+
signers[1] = owner2;
552+
bytes[] memory signatures = new bytes[](2);
553+
signatures[0] = abi.encodePacked(r, s, v);
554+
signatures[1] = abi.encodePacked(r1, s1, v1);
555+
556+
vm.expectEmit(true, true, true, true);
557+
emit FraxModule.AsyncWithdrawalCancelled(address(safe), amount, fraxModule.ETHEREUM_EID(), depositAddress);
558+
fraxModule.cancelAsyncWithdraw(address(safe), signers, signatures);
559+
560+
// Verify withdrawal is deleted
561+
FraxModule.AsyncWithdrawal memory withdrawalAfter = fraxModule.getPendingWithdrawal(address(safe));
562+
assertEq(withdrawalAfter.amount, 0);
563+
assertEq(withdrawalAfter.recipient, address(0));
564+
}
565+
566+
function test_cancelAsyncWithdraw_revertsWhenNoWithdrawalQueued() public {
567+
bytes32 digestHash = keccak256(abi.encodePacked(fraxModule.CANCEL_ASYNC_WITHDRAW_SIG(), block.chainid, address(fraxModule), fraxModule.getNonce(address(safe)), address(safe))).toEthSignedMessageHash();
568+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Pk, digestHash);
569+
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(owner2Pk, digestHash);
570+
571+
address[] memory signers = new address[](2);
572+
signers[0] = owner1;
573+
signers[1] = owner2;
574+
bytes[] memory signatures = new bytes[](2);
575+
signatures[0] = abi.encodePacked(r, s, v);
576+
signatures[1] = abi.encodePacked(r1, s1, v1);
577+
578+
vm.expectRevert(FraxModule.NoAsyncWithdrawalQueued.selector);
579+
fraxModule.cancelAsyncWithdraw(address(safe), signers, signatures);
580+
}
581+
471582
function _requestAsyncWithdrawal(uint256 amount) internal {
472583
bytes32 digestHash = keccak256(abi.encodePacked(fraxModule.REQUEST_ASYNC_WITHDRAW_SIG(), block.chainid, address(fraxModule), fraxModule.getNonce(address(safe)), safe, abi.encode(fraxusd, depositAddress, amount))).toEthSignedMessageHash();
473584

0 commit comments

Comments
 (0)