Skip to content

Commit f073cd8

Browse files
authored
fix: use previewWithdraw(assetsDeposited) for excess shares (#7152)
1 parent 99dff0f commit f073cd8

File tree

2 files changed

+138
-1
lines changed

2 files changed

+138
-1
lines changed

solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ contract HypERC4626OwnerCollateral is HypERC4626Collateral {
6666
* @notice Allows the owner to redeem excess shares
6767
*/
6868
function sweep() external onlyOwner {
69+
// convert assetsDeposited to shares rounding up to ensure
70+
// the owner cannot withdraw user collateral
6971
uint256 excessShares = vault.maxRedeem(address(this)) -
70-
vault.convertToShares(assetDeposited);
72+
vault.previewWithdraw(assetDeposited);
7173
uint256 assetsRedeemed = vault.redeem(
7274
excessShares,
7375
owner(),

solidity/test/token/HypERC20CollateralVaultDeposit.t.sol

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,141 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
238238
);
239239
}
240240

241+
function testERC4626VaultDeposit_ceilingRounding_reservesMoreShares()
242+
public
243+
{
244+
// This test verifies the mathematical difference between convertToShares (floor)
245+
// and previewWithdraw (ceiling) rounding when calculating shares for deposits.
246+
247+
uint256 transferAmount = 100e18;
248+
uint256 rewardAmount = 1e18;
249+
250+
// Setup: Transfer from Alice to Bob
251+
vm.prank(ALICE);
252+
primaryToken.approve(address(localToken), transferAmount);
253+
_performRemoteTransfer(0, transferAmount);
254+
255+
// Add yield to the vault (increases share value)
256+
primaryToken.mintTo(address(vault), rewardAmount);
257+
258+
// Transfer back from Bob to Alice
259+
vm.prank(BOB);
260+
remoteToken.transferRemote(
261+
ORIGIN,
262+
BOB.addressToBytes32(),
263+
transferAmount
264+
);
265+
_handleLocalTransfer(transferAmount);
266+
267+
// At this point, we have excess shares due to the yield
268+
uint256 totalShares = vault.maxRedeem(
269+
address(erc20CollateralVaultDeposit)
270+
);
271+
uint256 assetDeposited = erc20CollateralVaultDeposit.assetDeposited();
272+
273+
// Calculate what convertToShares (floor rounding) would give us
274+
uint256 sharesFloor = vault.convertToShares(assetDeposited);
275+
276+
// Calculate what previewWithdraw (ceiling rounding) gives us
277+
uint256 sharesCeiling = vault.previewWithdraw(assetDeposited);
278+
279+
// When there's rounding involved, ceiling should be >= floor
280+
// and the excess shares should be: totalShares - sharesCeiling
281+
uint256 excessSharesWithCeiling = totalShares - sharesCeiling;
282+
uint256 excessSharesWithFloor = totalShares - sharesFloor;
283+
284+
// Verify the key difference: ceiling rounding calculates more shares to reserve
285+
// for the deposited assets, which means fewer excess shares to sweep
286+
assertLe(
287+
excessSharesWithCeiling,
288+
excessSharesWithFloor,
289+
"Ceiling rounding should reserve more shares for deposits"
290+
);
291+
292+
// Perform sweep and verify the amount swept is <= excessSharesWithFloor
293+
// Record logs to capture the event
294+
vm.recordLogs();
295+
erc20CollateralVaultDeposit.sweep();
296+
297+
// Get the logs and extract the ExcessSharesSwept event
298+
Vm.Log[] memory logs = vm.getRecordedLogs();
299+
bool foundEvent = false;
300+
uint256 sweptShares;
301+
302+
for (uint256 i = 0; i < logs.length; i++) {
303+
// ExcessSharesSwept event signature: ExcessSharesSwept(uint256,uint256)
304+
if (
305+
logs[i].topics[0] ==
306+
keccak256("ExcessSharesSwept(uint256,uint256)")
307+
) {
308+
foundEvent = true;
309+
// Decode the event data (amount is first parameter, assetsRedeemed is second)
310+
(sweptShares, ) = abi.decode(logs[i].data, (uint256, uint256));
311+
break;
312+
}
313+
}
314+
315+
assertTrue(
316+
foundEvent,
317+
"ExcessSharesSwept event should have been emitted"
318+
);
319+
assertLe(
320+
sweptShares,
321+
excessSharesWithFloor,
322+
"Swept amount should be <= excessSharesWithFloor"
323+
);
324+
}
325+
326+
function testERC4626VaultDeposit_sweep_usesCeilingRounding() public {
327+
// This test verifies that sweep() correctly sweeps excess shares after yield accrual
328+
// and leaves no shares behind when assetDeposited is 0.
329+
330+
uint256 transferAmount = 100e18;
331+
uint256 rewardAmount = 1e18;
332+
333+
// Setup: Transfer from Alice to Bob
334+
vm.prank(ALICE);
335+
primaryToken.approve(address(localToken), transferAmount);
336+
_performRemoteTransfer(0, transferAmount);
337+
338+
// Add yield to the vault (increases share value)
339+
primaryToken.mintTo(address(vault), rewardAmount);
340+
341+
// Transfer back from Bob to Alice
342+
vm.prank(BOB);
343+
remoteToken.transferRemote(
344+
ORIGIN,
345+
BOB.addressToBytes32(),
346+
transferAmount
347+
);
348+
_handleLocalTransfer(transferAmount);
349+
350+
uint256 ownerBalanceBefore = primaryToken.balanceOf(
351+
erc20CollateralVaultDeposit.owner()
352+
);
353+
354+
// Call sweep() which should use previewWithdraw (ceiling rounding)
355+
erc20CollateralVaultDeposit.sweep();
356+
357+
uint256 ownerBalanceAfter = primaryToken.balanceOf(
358+
erc20CollateralVaultDeposit.owner()
359+
);
360+
uint256 sweptAmount = ownerBalanceAfter - ownerBalanceBefore;
361+
362+
// The swept amount should be positive (we did sweep excess shares)
363+
assertGt(sweptAmount, 0, "Should have swept excess shares");
364+
365+
// After sweep, we should have no shares remaining (assetDeposited is 0)
366+
uint256 remainingShares = vault.maxRedeem(
367+
address(erc20CollateralVaultDeposit)
368+
);
369+
assertEq(
370+
remainingShares,
371+
0,
372+
"Should have no shares remaining after sweep with no deposits"
373+
);
374+
}
375+
241376
function testERC4626VaultDeposit_TransferFromSender_CorrectMetadata()
242377
public
243378
{

0 commit comments

Comments
 (0)