Skip to content

Commit c99fc92

Browse files
committed
Merge pull request #1617 from LayerZero-Labs/ovault-audit/paladin
feat: audit feedback for ovault (#1617)
2 parents 6cc0bb1 + b8258f1 commit c99fc92

File tree

7 files changed

+161
-25
lines changed

7 files changed

+161
-25
lines changed

examples/ovault-evm/test/mocks/MockOVault.sol

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ contract MockOVaultUpgradeable is OVaultUpgradeable {
2828
_disableInitializers();
2929
}
3030

31-
function __OVault_init(string memory _name, string memory _symbol, address _asset) internal onlyInitializing {
32-
__ERC4626_init(IERC20(_asset));
33-
__ERC20_init(_name, _symbol);
31+
function initialize(string memory _name, string memory _symbol, address _asset) internal onlyInitializing {
32+
__OVault_init(_name, _symbol, _asset);
3433
}
3534

3635
function totalAssets() public view override returns (uint256) {

packages/ovault-evm/contracts/OVault.sol

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,46 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
55
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
66
import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
77

8+
/**
9+
* @notice From OpenZeppelin:
10+
11+
* @dev Implementation of the ERC-4626 "Tokenized Vault Standard" as defined in
12+
* https://eips.ethereum.org/EIPS/eip-4626[ERC-4626].
13+
*
14+
* This extension allows the minting and burning of "shares" (represented using the ERC-20 inheritance) in exchange for
15+
* underlying "assets" through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends
16+
* the ERC-20 standard. Any additional extensions included along it would affect the "shares" token represented by this
17+
* contract and not the "assets" token which is an independent contract.
18+
*
19+
* [CAUTION]
20+
* ====
21+
* In empty (or nearly empty) ERC-4626 vaults, deposits are at high risk of being stolen through frontrunning
22+
* with a "donation" to the vault that inflates the price of a share. This is variously known as a donation or inflation
23+
* attack and is essentially a problem of slippage. Vault deployers can protect against this attack by making an initial
24+
* deposit of a non-trivial amount of the asset, such that price manipulation becomes infeasible. Withdrawals may
25+
* similarly be affected by slippage. Users can protect against this attack as well as unexpected slippage in general by
26+
* verifying the amount received is as expected, using a wrapper that performs these checks such as
27+
* https://github.com/fei-protocol/ERC4626#erc4626router-and-base[ERC4626Router].
28+
*
29+
* Since v4.9, this implementation introduces configurable virtual assets and shares to help developers mitigate that risk.
30+
* The `_decimalsOffset()` corresponds to an offset in the decimal representation between the underlying asset's decimals
31+
* and the vault decimals. This offset also determines the rate of virtual shares to virtual assets in the vault, which
32+
* itself determines the initial exchange rate. While not fully preventing the attack, analysis shows that the default
33+
* offset (0) makes it non-profitable even if an attacker is able to capture value from multiple user deposits, as a result
34+
* of the value being captured by the virtual shares (out of the attacker's donation) matching the attacker's expected gains.
35+
* With a larger offset, the attack becomes orders of magnitude more expensive than it is profitable. More details about the
36+
* underlying math can be found xref:erc4626.adoc#inflation-attack[here].
37+
*
38+
* The drawback of this approach is that the virtual shares do capture (a very small) part of the value being accrued
39+
* to the vault. Also, if the vault experiences losses, the users try to exit the vault, the virtual shares and assets
40+
* will cause the first user to exit to experience reduced losses in detriment to the last users that will experience
41+
* bigger losses. Developers willing to revert back to the pre-v4.9 behavior just need to override the
42+
* `_convertToShares` and `_convertToAssets` functions.
43+
*
44+
* To learn more, check out our xref:ROOT:erc4626.adoc[ERC-4626 guide].
45+
* ====
46+
*/
47+
848
contract OVault is ERC4626 {
949
constructor(
1050
string memory _name,

packages/ovault-evm/contracts/OVaultComposer.sol

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ pragma solidity ^0.8.22;
44
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
55
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
66
import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol";
7-
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
87

98
import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
109

@@ -20,7 +19,6 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
2019
using OFTComposeMsgCodec for bytes;
2120
using OFTComposeMsgCodec for bytes32;
2221
using SafeERC20 for IERC20;
23-
using SafeERC20 for IERC20;
2422

2523
address public immutable ASSET_OFT; // any OFT
2624
address public immutable SHARE_OFT; // lockbox adapter
@@ -68,9 +66,6 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
6866
// Approve the shareOFTAdapter with the share tokens held by this contract
6967
IERC20(IOFT(_shareOFT).token()).approve(_shareOFT, type(uint256).max);
7068

71-
ASSET_DECIMAL_CONVERSION_RATE = IOFT(_assetOFT).decimalConversionRate();
72-
SHARE_DECIMAL_CONVERSION_RATE = IOFT(_shareOFT).decimalConversionRate();
73-
7469
REFUND_OVERPAY_ADDRESS = _refundOverpayAddress;
7570
ASSET_ERC20 = address(OVAULT.asset());
7671
}
@@ -126,9 +121,9 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
126121
}
127122

128123
/// @dev Try to execute the action on the target OFT. If we hit an error then it rolls back the storage changes.
129-
try this.executeOVaultActionWithSlippageCheck(_refundOFT, amount, sendParam.minAmountLD) returns (
130-
uint256 vaultAmount
131-
) {
124+
try
125+
this.executeOVaultActionWithSlippageCheck(_refundOFT, sendParam.dstEid, amount, sendParam.minAmountLD)
126+
returns (uint256 vaultAmount) {
132127
/// @dev Setting the target amount to the actual value of the action (i.e. deposit or redeem)
133128
sendParam.amountLD = vaultAmount;
134129
} catch (bytes memory errMsg) {
@@ -156,8 +151,15 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
156151
SendParam memory _sendParam,
157152
address _refundAddress
158153
) external payable nonReentrant {
154+
if (_sendParam.dstEid == HUB_EID && msg.value > 0) revert NoMsgValueOnSameChainOVaultAction();
155+
159156
IERC20(ASSET_ERC20).safeTransferFrom(msg.sender, address(this), assetAmountLD);
160-
_sendParam.amountLD = _executeOVaultActionWithSlippageCheck(ASSET_OFT, assetAmountLD, _sendParam.minAmountLD);
157+
_sendParam.amountLD = _executeOVaultActionWithSlippageCheck(
158+
ASSET_OFT,
159+
_sendParam.dstEid,
160+
assetAmountLD,
161+
_sendParam.minAmountLD
162+
);
161163
_send(SHARE_OFT, _sendParam, msg.value, _refundAddress);
162164
}
163165

@@ -166,8 +168,15 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
166168
SendParam memory _sendParam,
167169
address _refundAddress
168170
) external payable nonReentrant {
171+
if (_sendParam.dstEid == HUB_EID && msg.value > 0) revert NoMsgValueOnSameChainOVaultAction();
172+
169173
IERC20(OVAULT).safeTransferFrom(msg.sender, address(this), shareAmountLD);
170-
_sendParam.amountLD = _executeOVaultActionWithSlippageCheck(SHARE_OFT, shareAmountLD, _sendParam.minAmountLD);
174+
_sendParam.amountLD = _executeOVaultActionWithSlippageCheck(
175+
SHARE_OFT,
176+
_sendParam.dstEid,
177+
shareAmountLD,
178+
_sendParam.minAmountLD
179+
);
171180
_send(ASSET_OFT, _sendParam, msg.value, _refundAddress);
172181
}
173182

@@ -195,12 +204,13 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
195204
/// @dev External call for try...catch logic in lzCompose()
196205
function executeOVaultActionWithSlippageCheck(
197206
address _refundOFT,
207+
uint32 _dstEid,
198208
uint256 _amount,
199209
uint256 _minAmountLD
200210
) external nonReentrant returns (uint256 vaultAmount) {
201211
if (msg.sender != address(this)) revert OnlySelf(msg.sender);
202212

203-
vaultAmount = _executeOVaultActionWithSlippageCheck(_refundOFT, _amount, _minAmountLD);
213+
vaultAmount = _executeOVaultActionWithSlippageCheck(_refundOFT, _dstEid, _amount, _minAmountLD);
204214
}
205215

206216
/// @dev External call for try...catch logic in lzCompose()
@@ -212,7 +222,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
212222
address _receiver = _sendParam.to.bytes32ToAddress();
213223
uint256 _amountLD = _sendParam.amountLD;
214224
IERC20 token = IERC20(IOFT(_oft).token());
215-
token.transfer(_receiver, _amountLD);
225+
token.safeTransfer(_receiver, _amountLD);
216226
if (msg.value > 0) {
217227
(bool sent, ) = _receiver.call{ value: msg.value }("");
218228
require(sent, "Failed to send Ether");
@@ -249,7 +259,6 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
249259
function retry(bytes32 _guid, bool _removeExtraOptions) external payable nonReentrant {
250260
FailedMessage memory failedMessage = failedMessages[_guid];
251261
if (_failedGuidState(failedMessage) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid);
252-
if (_failedGuidState(failedMessage) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid);
253262

254263
SendParam memory sendParam = failedMessage.sendParam;
255264
uint256 prepaidMsgValue = failedMessage.msgValue;
@@ -277,6 +286,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
277286

278287
failedMessage.sendParam.amountLD = _executeOVaultActionWithSlippageCheck(
279288
failedMessage.refundOFT,
289+
failedMessage.sendParam.dstEid, //dstEid
280290
failedMessage.refundSendParam.amountLD,
281291
failedMessage.sendParam.minAmountLD
282292
);
@@ -327,12 +337,13 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
327337

328338
function _executeOVaultActionWithSlippageCheck(
329339
address _refundOFT,
340+
uint32 _dstEid,
330341
uint256 _amount,
331342
uint256 _minAmountLD
332343
) internal returns (uint256) {
333344
(uint256 vaultAmount, address targetOFT) = _executeOVaultAction(_refundOFT, _amount);
334345

335-
_checkSlippage(targetOFT, vaultAmount, _minAmountLD);
346+
_checkSlippage(targetOFT, _dstEid, vaultAmount, _minAmountLD);
336347

337348
return vaultAmount;
338349
}
@@ -396,8 +407,10 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard {
396407
/// @dev Remove dust before slippage check to be equivalent to OFTCore::_debitView()
397408
/// @dev If the OFT has a Fee or anything that changes the tokens such that:
398409
/// @dev dstChain.receivedAmount != srcChain.sentAmount, this will have to be overridden.
399-
function _checkSlippage(address _oft, uint256 _amount, uint256 _minAmountLD) internal view virtual {
400-
uint256 vaultAmountLD = _removeDust(_oft, _amount);
410+
function _checkSlippage(address _oft, uint32 _dstEid, uint256 _amount, uint256 _minAmountLD) internal view virtual {
411+
/// @dev In the event of a same chain deposit. Dust is never removed since we do not call OFT.send() but ERC20.transfer()
412+
uint256 vaultAmountLD = HUB_EID == _dstEid ? _amount : _removeDust(_oft, _amount);
413+
401414
uint256 amountReceivedLD = vaultAmountLD; /// @dev Perform your adjustments here if needed (ex: Fee)
402415
if (amountReceivedLD < _minAmountLD) {
403416
revert NotEnoughTargetTokens(amountReceivedLD, _minAmountLD);

packages/ovault-evm/contracts/OVaultUpgradeable.sol

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,54 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
55
import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
66
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
77

8+
/**
9+
* @notice From OpenZeppelin:
10+
11+
* @dev Implementation of the ERC-4626 "Tokenized Vault Standard" as defined in
12+
* https://eips.ethereum.org/EIPS/eip-4626[ERC-4626].
13+
*
14+
* This extension allows the minting and burning of "shares" (represented using the ERC-20 inheritance) in exchange for
15+
* underlying "assets" through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends
16+
* the ERC-20 standard. Any additional extensions included along it would affect the "shares" token represented by this
17+
* contract and not the "assets" token which is an independent contract.
18+
*
19+
* [CAUTION]
20+
* ====
21+
* In empty (or nearly empty) ERC-4626 vaults, deposits are at high risk of being stolen through frontrunning
22+
* with a "donation" to the vault that inflates the price of a share. This is variously known as a donation or inflation
23+
* attack and is essentially a problem of slippage. Vault deployers can protect against this attack by making an initial
24+
* deposit of a non-trivial amount of the asset, such that price manipulation becomes infeasible. Withdrawals may
25+
* similarly be affected by slippage. Users can protect against this attack as well as unexpected slippage in general by
26+
* verifying the amount received is as expected, using a wrapper that performs these checks such as
27+
* https://github.com/fei-protocol/ERC4626#erc4626router-and-base[ERC4626Router].
28+
*
29+
* Since v4.9, this implementation introduces configurable virtual assets and shares to help developers mitigate that risk.
30+
* The `_decimalsOffset()` corresponds to an offset in the decimal representation between the underlying asset's decimals
31+
* and the vault decimals. This offset also determines the rate of virtual shares to virtual assets in the vault, which
32+
* itself determines the initial exchange rate. While not fully preventing the attack, analysis shows that the default
33+
* offset (0) makes it non-profitable even if an attacker is able to capture value from multiple user deposits, as a result
34+
* of the value being captured by the virtual shares (out of the attacker's donation) matching the attacker's expected gains.
35+
* With a larger offset, the attack becomes orders of magnitude more expensive than it is profitable. More details about the
36+
* underlying math can be found xref:erc4626.adoc#inflation-attack[here].
37+
*
38+
* The drawback of this approach is that the virtual shares do capture (a very small) part of the value being accrued
39+
* to the vault. Also, if the vault experiences losses, the users try to exit the vault, the virtual shares and assets
40+
* will cause the first user to exit to experience reduced losses in detriment to the last users that will experience
41+
* bigger losses. Developers willing to revert back to the pre-v4.9 behavior just need to override the
42+
* `_convertToShares` and `_convertToAssets` functions.
43+
*
44+
* To learn more, check out our xref:ROOT:erc4626.adoc[ERC-4626 guide].
45+
* ====
46+
*/
847
contract OVaultUpgradeable is ERC4626Upgradeable {
948
/// @custom:oz-upgrades-unsafe-allow constructor
1049
constructor() {
1150
_disableInitializers();
1251
}
52+
53+
function __OVault_init(string memory _name, string memory _symbol, address _asset) public initializer {
54+
__ERC4626_init(IERC20(_asset));
55+
__ERC20_init(_name, _symbol);
56+
__Context_init();
57+
}
1358
}

packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,14 @@ interface IOVaultComposer is IOAppComposer {
4545
error OnlyEndpoint(address caller);
4646
error OnlySelf(address caller);
4747
error OnlyOFT(address oft);
48-
error OnlyAsset(address asset);
49-
error OnlyShare(address share);
5048

5149
error CanNotRefund(bytes32 guid);
5250
error CanNotRetry(bytes32 guid);
5351
error CanNotSwap(bytes32 guid);
54-
error CanNotWithdraw(bytes32 guid);
5552

5653
error NotEnoughTargetTokens(uint256 amountLD, uint256 minAmountLD);
5754
error NoMsgValueWhenSkippingRetry();
55+
error NoMsgValueOnSameChainOVaultAction();
5856

5957
/// ========================== GLOBAL VARIABLE FUNCTIONS =====================================
6058
function ASSET_OFT() external view returns (address);

packages/ovault-evm/test/composer/OVaultComposer_ProxySend.t.sol

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ contract OVaultComposerProxySendTest is OVaultComposerBaseTest {
2323
vm.deal(userA, 100 ether);
2424
}
2525

26+
function test_target_is_hub_reverts_when_msg_value_provided() public {
27+
SendParam memory sendParam = SendParam(ARB_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, "", "", "");
28+
assetOFT_arb.mint(address(userA), TOKENS_TO_SEND);
29+
30+
vm.expectRevert(abi.encodeWithSelector(IOVaultComposer.NoMsgValueOnSameChainOVaultAction.selector));
31+
OVaultComposerArb.depositSend{ value: 1 wei }(TOKENS_TO_SEND, sendParam, userA);
32+
33+
vm.expectRevert(abi.encodeWithSelector(IOVaultComposer.NoMsgValueOnSameChainOVaultAction.selector));
34+
OVaultComposerArb.redeemSend{ value: 1 wei }(TOKENS_TO_SEND, sendParam, userA);
35+
}
36+
2637
function test_depositSend_target_hub() public {
2738
SendParam memory sendParam = SendParam(ARB_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, "", "", "");
2839
assetOFT_arb.mint(address(userA), TOKENS_TO_SEND);
@@ -36,6 +47,21 @@ contract OVaultComposerProxySendTest is OVaultComposerBaseTest {
3647
assertEq(assetOFT_arb.balanceOf(userA), 0);
3748
}
3849

50+
function test_depositSend_target_hub_can_have_dust() public {
51+
uint256 amountWithDust = TOKENS_TO_SEND + 1; // 1 extra token to test dust handling
52+
53+
SendParam memory sendParam = SendParam(ARB_EID, addressToBytes32(userA), amountWithDust, 0, "", "", "");
54+
assetOFT_arb.mint(address(userA), amountWithDust);
55+
56+
vm.startPrank(userA);
57+
assetOFT_arb.approve(address(OVaultComposerArb), amountWithDust);
58+
OVaultComposerArb.depositSend(amountWithDust, sendParam, userA);
59+
vm.stopPrank();
60+
61+
assertEq(oVault_arb.balanceOf(userA), amountWithDust);
62+
assertEq(assetOFT_arb.balanceOf(userA), 0);
63+
}
64+
3965
function test_redeemSend_target_hub() public {
4066
SendParam memory sendParam = SendParam(ARB_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, "", "", "");
4167
assetOFT_arb.mint(address(oVault_arb), TOKENS_TO_SEND);
@@ -50,6 +76,22 @@ contract OVaultComposerProxySendTest is OVaultComposerBaseTest {
5076
assertEq(assetOFT_arb.balanceOf(userA), TOKENS_TO_SEND);
5177
}
5278

79+
function test_redeemSend_target_hub_can_have_dust() public {
80+
uint256 amountWithDust = TOKENS_TO_SEND + 1; // 1 extra token to test dust handling
81+
82+
SendParam memory sendParam = SendParam(ARB_EID, addressToBytes32(userA), amountWithDust, 0, "", "", "");
83+
assetOFT_arb.mint(address(oVault_arb), amountWithDust);
84+
oVault_arb.mint(address(userA), amountWithDust);
85+
86+
vm.startPrank(userA);
87+
oVault_arb.approve(address(OVaultComposerArb), amountWithDust);
88+
OVaultComposerArb.redeemSend(amountWithDust, sendParam, userA);
89+
vm.stopPrank();
90+
91+
assertEq(oVault_arb.balanceOf(userA), 0);
92+
assertEq(assetOFT_arb.balanceOf(userA), amountWithDust);
93+
}
94+
5395
function test_depositSend_target_not_hub() public {
5496
SendParam memory sendParam = SendParam(POL_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, "", "", "");
5597
assetOFT_arb.mint(address(userA), TOKENS_TO_SEND);

packages/ovault-evm/test/mocks/MockOVault.sol

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ contract MockOVaultUpgradeable is OVaultUpgradeable {
2828
_disableInitializers();
2929
}
3030

31-
function __OVault_init(string memory _name, string memory _symbol, address _asset) internal onlyInitializing {
32-
__ERC4626_init(IERC20(_asset));
33-
__ERC20_init(_name, _symbol);
31+
function initialize(string memory _name, string memory _symbol, address _asset) internal onlyInitializing {
32+
__OVault_init(_name, _symbol, _asset);
3433
}
3534

3635
function totalAssets() public view override returns (uint256) {

0 commit comments

Comments
 (0)