IllIllI - Illuminate redemptions don't account for protocol pauses/temporary blocklistings
Opened this issue · 3 comments
IllIllI
high
Illuminate redemptions don't account for protocol pauses/temporary blocklistings
Summary
Illuminate redemptions don't account for protocol pauses/temporary blocklistings
Vulnerability Detail
By the time that Illuminate PTs have reached maturity, it's assumed that all external PTs will have been converted to underlying, so that the pool of combined underlying from the various protocols can be split on a per-Illuminate-PT-share basis. Unfortunately this may not be the case. Some of the protocol PTs that Illuminate supports as principals allow their own admins to pause [activity](# https://docs.notional.finance/developer-documentation/on-chain/notional-governance-reference#pauseability), and Illuminate has no way to protect users from redeeming while these protocol pauses are in effect. Unredeemed external PTs contribute zero underlying to the Illuminate PT's underlying balance, and when a user redeemes an Illuminate PT, the PT is burned for its share of what's available, not the total of what could be available in the future.
Impact
Permanent freezing of funds
If a external PT is paused, or its PT is otherwise unable to be redeemed for the full amount when the user requests it, that unredeemed amount of underlying is not claimable (since the user's Illuminate PT is burned), and the user loses that amount of principal. If the external PT is later able to be redeemed, the remaining users will be given the principal that should have gon to the original user.
Code Snippet
Holdings only increase when external PTs are redeemed successfully:
// File: src/Redeemer.sol : Redeemer.redeem() #1
325 // Calculate how much underlying was redeemed
326 uint256 redeemed = IERC20(u).balanceOf(address(this)) - starting;
327
328 // Update the holding for this market
329: holdings[u][m] = holdings[u][m] + redeemed;
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L325-L329
// File: src/Redeemer.sol : Redeemer.redeem() #2
385 // Get the amount received
386 uint256 redeemed = IERC20(u).balanceOf(address(this)) - starting;
387
388 // Verify that underlying are received 1:1 - cannot trust the adapter
389 if (redeemed < amount) {
390 revert Exception(13, 0, 0, address(0), address(0));
391 }
392
393 // Update the holdings for this market
394: holdings[u][m] = holdings[u][m] + redeemed;
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L385-L394
And user redemptions of Illuminate PTs does not rely on external PT balances, only on the shares of what's available in the currently stored holdings balance at any point after maturity:
// File: src/Redeemer.sol : Redeemer.redeem() #3
413 // Verify the token has matured
414 if (block.timestamp < token.maturity()) {
415 revert Exception(7, block.timestamp, m, address(0), address(0));
416 }
417
418 // Get the amount of tokens to be redeemed from the sender
419 uint256 amount = token.balanceOf(msg.sender);
420
421 // Calculate how many tokens the user should receive
422 @> uint256 redeemed = (amount * holdings[u][m]) / token.totalSupply();
423
424 // Update holdings of underlying
425 holdings[u][m] = holdings[u][m] - redeemed;
426
427 // Burn the user's principal tokens
428: token.authBurn(msg.sender, amount);
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L413-L428
// File: src/Redeemer.sol : Redeemer.authRedeem() #4
457 // Make sure the market has matured
458 uint256 maturity = pt.maturity();
459 if (block.timestamp < maturity) {
460 revert Exception(7, maturity, 0, address(0), address(0));
461 }
462
463 // Calculate the amount redeemed
464 @> uint256 redeemed = (a * holdings[u][m]) / pt.totalSupply();
465
466 // Update holdings of underlying
467 holdings[u][m] = holdings[u][m] - redeemed;
468
469 // Burn the user's principal tokens
470: pt.authBurn(f, a);
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L449-L470
// File: src/Redeemer.sol : Redeemer.autoRedeem() #5
485 function autoRedeem(
486 address u,
487 uint256 m,
488 address[] calldata f
489 ) external returns (uint256) {
490 // Get the principal token for the given market
491 IERC5095 pt = IERC5095(IMarketPlace(marketPlace).token(u, m, 0));
492
493 // Make sure the market has matured
494 uint256 maturity = pt.maturity();
495 if (block.timestamp < maturity) {
496 revert Exception(7, maturity, 0, address(0), address(0));
497 }
...
514 uint256 amount = pt.balanceOf(f[i]);
515
516 // Calculate how many tokens the user should receive
517 @> uint256 redeemed = (amount * holdings[u][m]) / pt.totalSupply();
518
519 // Calculate the fees to be received (currently .025%)
520 uint256 fee = redeemed / feenominator;
521
522 // Verify allowance
523 if (allowance < amount) {
524 revert Exception(20, allowance, amount, address(0), address(0));
525 }
526
527 // Burn the tokens from the user
528: pt.authBurn(f[i], amount);
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L485-L528
The Illuminate admin has no way to pause/disable redemption for users that try to redeem via ERC5095.redeem()/withdraw()
or via autoRedeem()
.
autoRedeem()
doesn't use the unpaused
modifier, and does not rely on the normal redeem()
for redemption:
// File: src/Redeemer.sol : Redeemer.u #6
485 function autoRedeem(
486 address u,
487 uint256 m,
488 address[] calldata f
489 @> ) external returns (uint256) {
490 // Get the principal token for the given market
491 IERC5095 pt = IERC5095(IMarketPlace(marketPlace).token(u, m, 0));
492
493 // Make sure the market has matured
494 uint256 maturity = pt.maturity();
495 if (block.timestamp < maturity) {
496: revert Exception(7, maturity, 0, address(0), address(0));
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L485-L496
The ERC5095 also does not use the unpaused
modifier. It uses authRedeem()
for its post-maturity redemptions (the pre-maturity redemptions also are not pausable)...:
// File: src/tokens/ERC5095.sol : ERC5095.redeem() #7
284 function redeem(
285 uint256 s,
286 address r,
287 address o
288 @> ) external override returns (uint256) {
...
318 // Post-maturity
319 } else {
320 if (o == msg.sender) {
321 return
322 @> IRedeemer(redeemer).authRedeem(
323 underlying,
324 maturity,
325 msg.sender,
326 r,
327 s
328 );
329 } else {
330 uint256 allowance = _allowance[o][msg.sender];
331 if (allowance < s) {
332 revert Exception(20, allowance, s, address(0), address(0));
333 }
334 _allowance[o][msg.sender] = allowance - s;
335 return
336 @> IRedeemer(redeemer).authRedeem(
337 underlying,
338 maturity,
339 o,
340 r,
341 s
342 );
343 }
344 }
345: }
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/tokens/ERC5095.sol#L284-L345
...and authRedeem()
does not use the modifier either:
// File: src/Redeemer.sol : Redeemer.authRedeem() #8
443 function authRedeem(
444 address u,
445 uint256 m,
446 address f,
447 address t,
448 uint256 a
449 )
450 external
451 @> authorized(IMarketPlace(marketPlace).token(u, m, 0))
452 returns (uint256)
453 {
454 // Get the principal token for the given market
455 IERC5095 pt = IERC5095(IMarketPlace(marketPlace).token(u, m, 0));
456
457 // Make sure the market has matured
458 uint256 maturity = pt.maturity();
459 if (block.timestamp < maturity) {
460 revert Exception(7, maturity, 0, address(0), address(0));
461: }
https://github.com/sherlock-audit/2022-10-illuminate/blob/main/src/Redeemer.sol#L443-L461
Tool used
Manual Review
Recommendation
This is hard to solve without missing corner cases, because each external PT may have its own idosyncratic reasons for delays, and there may be losses/slippage involved when redeeming for underlying. I believe the only way that wouldn't allow griefing, would be to track the number of external PTs of each type that were deposited for minting Illuminate PTs on a per-market basis, and require()
that the number of each that have been redeemed equals the minting count, before allowing the redemption of any Illuminate PTs for that market. You would also need an administrator override that bypasses this check for specific external PTs of specific maturities. All of this assumes that none of the external PTs have rebasing functionality. Also, add the unpaused
modifier to both Redeemer.autoRedeem()
and Redeemer.authRedeem()
.
Unsure if this is being used as the main ticket for the authRedeem/autoRedeem when paused, as that issue is mentioned alongside the primary description of external PTs that may have been paused, preventing redemption in time for iPT maturity.
If this ticket is primarily regarding the latter, i'd probably contest and claim that we would have ample time between external PT maturity and our own maturity to pause ourselves and remediate any issues should there be external pauses.
This current implementation of manual pausing saves significant gas when compared to the suggested remediation given the additional storage required for individual market checks, so as long as we are not irresponsible / our keepers are operating, there are no additional risks introduced here that are remediated by checking balances and requiring 1:1 amounts.
Escalate for 1 USDC
Reminder @Evert0x
You've created a valid escalation for 1 USDC!
To remove the escalation from consideration: Delete your comment.
To change the amount you've staked on this escalation: Edit your comment (do not create a new comment).
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.