Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix dust surplus claim in recovery mode liquidation #787

Merged
merged 8 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions packages/contracts/contracts/LiquidationLibrary.sol
Original file line number Diff line number Diff line change
Expand Up @@ -334,15 +334,20 @@ contract LiquidationLibrary is CdpManagerStorage {
_totalColToSend
);
if (_collSurplus > 0) {
collSurplusPool.increaseSurplusCollShares(
_recoveryState.cdpId,
_borrower,
_collSurplus,
0
);
_recoveryState.totalSurplusCollShares =
_recoveryState.totalSurplusCollShares +
_collSurplus;
if (_checkICRAgainstMCR(_recoveryState.ICR)) {
_cappedColPortion = _collSurplus + _cappedColPortion;
_collSurplus = 0;
} else {
collSurplusPool.increaseSurplusCollShares(
_recoveryState.cdpId,
_borrower,
_collSurplus,
0
);
_recoveryState.totalSurplusCollShares =
_recoveryState.totalSurplusCollShares +
_collSurplus;
}
}
if (_debtToRedistribute > 0) {
_totalDebtToBurn = _totalDebtToBurn - _debtToRedistribute;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ abstract contract BeforeAfter is BaseStorageVariables {
using Pretty for bool;

struct Vars {
uint256 userSurplusBefore;
uint256 userSurplusAfter;
uint256 valueInSystemBefore;
uint256 valueInSystemAfter;
uint256 nicrBefore;
Expand Down Expand Up @@ -82,6 +84,9 @@ abstract contract BeforeAfter is BaseStorageVariables {
function _before(bytes32 _cdpId) internal {
vars.priceBefore = priceFeedMock.fetchPrice();

address ownerToCheck = sortedCdps.getOwnerAddress(_cdpId);
vars.userSurplusBefore = collSurplusPool.getSurplusCollShares(ownerToCheck);

(uint256 debtBefore, ) = cdpManager.getSyncedDebtAndCollShares(_cdpId);

vars.nicrBefore = _cdpId != bytes32(0) ? crLens.quoteRealNICR(_cdpId) : 0;
Expand Down Expand Up @@ -140,6 +145,9 @@ abstract contract BeforeAfter is BaseStorageVariables {
}

function _after(bytes32 _cdpId) internal {
address ownerToCheck = sortedCdps.getOwnerAddress(_cdpId);
vars.userSurplusAfter = collSurplusPool.getSurplusCollShares(ownerToCheck);

vars.priceAfter = priceFeedMock.fetchPrice();

vars.nicrAfter = _cdpId != bytes32(0) ? crLens.quoteRealNICR(_cdpId) : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ abstract contract TargetFunctions is Properties {
_before(_cdpId);

uint256 _icrToLiq = cdpManager.getSyncedICR(_cdpId, priceFeedMock.getPrice());

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call to CdpManager.liquidate within the liquidate function does not explicitly check for the success of the operation before proceeding with further logic. While the success variable is used later to conditionally execute code, it's crucial to handle the failure case immediately after the external call to prevent any unintended behavior. Consider adding a require statement to ensure that the liquidation call was successful before continuing.

(success, returnData) = actor.proxy(
    address(cdpManager),
    abi.encodeWithSelector(CdpManager.liquidate.selector, _cdpId)
);
+ require(success, "Liquidation failed");

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change

(success, returnData) = actor.proxy(
address(cdpManager),
abi.encodeWithSelector(CdpManager.liquidate.selector, _cdpId)
Expand All @@ -234,6 +235,12 @@ abstract contract TargetFunctions is Properties {
_after(_cdpId);

if (success) {
// SURPLUS-CHECK-1 | The surplus is capped at 4 wei | NOTE: Proxy of growth, storage var would further refine
if (_icrToLiq <= cdpManager.MCR()) {
gte(vars.collSurplusPoolBefore + 4, vars.collSurplusPoolAfter, "SURPLUS-CHECK-1");
gte(vars.userSurplusBefore + 4, vars.userSurplusAfter, "SURPLUS-CHECK-2");
}
Comment on lines +238 to +242
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of surplus checks (SURPLUS-CHECK-1 and SURPLUS-CHECK-2) ensures that the surplus does not exceed 4 wei during the liquidation process in recovery mode. This is a critical enhancement for preventing dust surplus and improving the system's resilience against potential flash loan attacks. However, consider the following points for further refinement:

  • Documentation: Enhance code comments to explain the rationale behind the choice of 4 wei as the cap. This will help future maintainers understand the decision-making process.
  • Magic Number: The value 4 is used directly in the code, which can be considered a magic number. Define it as a constant with a descriptive name to improve code readability and maintainability.
  • Testing: Ensure comprehensive testing around these checks, including edge cases where the surplus is exactly at the cap and slightly over it.
- gte(vars.collSurplusPoolBefore + 4, vars.collSurplusPoolAfter, "SURPLUS-CHECK-1");
- gte(vars.userSurplusBefore + 4, vars.userSurplusAfter, "SURPLUS-CHECK-2");
+ const uint SURPLUS_CAP = 4; // Descriptive comment explaining the choice of value
+ gte(vars.collSurplusPoolBefore + SURPLUS_CAP, vars.collSurplusPoolAfter, "SURPLUS-CHECK-1");
+ gte(vars.userSurplusBefore + SURPLUS_CAP, vars.userSurplusAfter, "SURPLUS-CHECK-2");

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
// SURPLUS-CHECK-1 | The surplus is capped at 4 wei | NOTE: Proxy of growth, storage var would further refine
if (_icrToLiq <= cdpManager.MCR()) {
gte(vars.collSurplusPoolBefore + 4, vars.collSurplusPoolAfter, "SURPLUS-CHECK-1");
gte(vars.userSurplusBefore + 4, vars.userSurplusAfter, "SURPLUS-CHECK-2");
}
// SURPLUS-CHECK-1 | The surplus is capped at 4 wei | NOTE: Proxy of growth, storage var would further refine
if (_icrToLiq <= cdpManager.MCR()) {
const uint SURPLUS_CAP = 4; // Descriptive comment explaining the choice of value
gte(vars.collSurplusPoolBefore + SURPLUS_CAP, vars.collSurplusPoolAfter, "SURPLUS-CHECK-1");
gte(vars.userSurplusBefore + SURPLUS_CAP, vars.userSurplusAfter, "SURPLUS-CHECK-2");
}


// if ICR >= TCR then we ignore
// We could check that Liquidated is not above TCR
if (
Expand Down Expand Up @@ -421,6 +428,9 @@ abstract contract TargetFunctions is Properties {
_after(bytes32(0));

if (success) {
// SURPLUS-CHECK-1 | The surplus is capped at 4 wei | NOTE: We use Liquidate for the exact CDP check
gte(vars.collSurplusPoolBefore + 4, vars.collSurplusPoolAfter, "SURPLUS-CHECK-1");

Cdp[] memory cdpsAfter = _getCdpIdsAndICRs();

Cdp[] memory cdpsLiquidated = _cdpIdsAndICRsDiff(cdpsBefore, cdpsAfter);
Expand Down
43 changes: 43 additions & 0 deletions packages/contracts/foundry_test/CdpManager.Liquidation.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -969,4 +969,47 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants {

return (cdpIds, _newPrice);
}

function testSurplusInRMWhenICRBelowMCR() public {
address wallet = users[0];

// set eth per stETH share
collateral.setEthPerShare(1158379174506084879);

// fetch price before open
uint256 oldprice = priceFeedMock.fetchPrice();

// open five cdps
_openTestCDP(wallet, 2e18 + 2e17, ((2e18 * oldprice) / 240e16));
_openTestCDP(wallet, 2e18 + 2e17, ((2e18 * oldprice) / 240e16));
_openTestCDP(wallet, 2e18 + 2e17, ((2e18 * oldprice) / 240e16));
_openTestCDP(wallet, 2e18 + 2e17, ((2e18 * oldprice) / 240e16));
bytes32 underwater = _openTestCDP(wallet, 2e18 + 2e17, ((2e18 * oldprice) / 210e16));

// reduce the price by half to make underwater cdp
priceFeedMock.setPrice(oldprice / 2);

// fetch new price after reduce
uint256 newPrice = priceFeedMock.fetchPrice();

// ensure the system is in recovery mode
assert(cdpManager.getSyncedTCR(newPrice) < CCR);

// liquidate underwater cdp with ICR < MCR
vm.startPrank(wallet);
cdpManager.liquidate(underwater);
vm.stopPrank();

// make sure the cdp is no longer in the sorted list
assert(!sortedCdps.contains(underwater));

// fetch the surplus after the liquidation
uint256 surplus = collSurplusPool.getSurplusCollShares(wallet);

// console log the surplus coll
console.log("Surplus:", surplus);

// ensure that the surplus is zero
assert(surplus == 0);
}
Comment on lines +972 to +1014
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly added test function testSurplusInRMWhenICRBelowMCR aims to verify that no surplus collateral is generated when an underwater CDP (with ICR < MCR) is liquidated in recovery mode. This test is crucial for ensuring the system's integrity and aligns with the PR objectives to enhance the robustness and accuracy of the liquidation process. Here are some observations and recommendations:

  1. Correctness and Logic: The test function correctly simulates the scenario where a CDP becomes underwater due to a price drop, enters recovery mode, and is then liquidated. The assertion that the surplus collateral remains zero after liquidation is a valid check to ensure no surplus is incorrectly generated in this scenario. However, it's important to ensure that the test covers all relevant edge cases, such as slightly above MCR scenarios or right at the boundary of MCR.

  2. Hardcoded Values: The test uses hardcoded values for collateral amounts and price ratios. While this is acceptable for controlled test scenarios, consider parameterizing these values to allow for more extensive and flexible testing scenarios. This can help in testing edge cases more effectively.

  3. Use of console.log: The use of console.log for debugging purposes is helpful during development and testing, but it's generally a good practice to remove or comment out such statements in the final version of the test suite unless there's a specific reason to keep them for ongoing diagnostics.

  4. Documentation and Comments: The test function is straightforward, but adding comments to explain the setup, the specific scenario being tested, and the expected outcomes can improve readability and maintainability, especially for new contributors or when revisiting the tests in the future.

  5. Testing Framework Enhancements: Consider leveraging features of the testing framework, such as setup and teardown hooks, to manage common setup tasks and cleanup, which can help reduce redundancy across test functions and improve test execution efficiency.

Overall, the addition of this test function is a positive step towards ensuring the system behaves as expected under specific liquidation scenarios in recovery mode. Addressing the above points can further enhance the quality and coverage of the test suite.

}
24 changes: 22 additions & 2 deletions packages/contracts/foundry_test/EchidnaToFoundry.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ contract EToFoundry is
{
modifier setup() override {
_;
address sender = uint160(msg.sender) % 3 == 0 ? address(USER1) : uint160(msg.sender) % 3 == 1
? address(USER2)
: address(USER3);
actor = actors[sender];
}

function setUp() public {
_setUp();
_setUpActors();
actor = actors[USER1];
vm.startPrank(address(actor));
actor = actors[address(USER1)];
}

function _checkTotals() internal {
Comment on lines 25 to 40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 NOTE
This review was outside the diff hunks, and no overlapping diff hunk was found. Original lines [663-663]

AWS Access Key ID Value detected in a comment. This is a sensitive credential and should not be hardcoded here. Even though it's in a comment, it's best practice to avoid including potentially sensitive information in your codebase. Consider removing or obfuscating this information.


📝 NOTE
This review was outside the diff hunks, and no overlapping diff hunk was found. Original lines [694-694]

AWS Access Key ID Value detected in a comment. This is a sensitive credential and should not be hardcoded here. Even though it's in a comment, it's best practice to avoid including potentially sensitive information in your codebase. Consider removing or obfuscating this information.


📝 NOTE
This review was outside the diff hunks, and no overlapping diff hunk was found. Original lines [724-724]

AWS Access Key ID Value detected in a comment. This is a sensitive credential and should not be hardcoded here. Even though it's in a comment, it's best practice to avoid including potentially sensitive information in your codebase. Consider removing or obfuscating this information.


📝 NOTE
This review was outside the diff hunks, and no overlapping diff hunk was found. Original lines [942-942]

AWS Access Key ID Value detected in a comment. This is a sensitive credential and should not be hardcoded here. Even though it's in a comment, it's best practice to avoid including potentially sensitive information in your codebase. Consider removing or obfuscating this information.

Expand Down Expand Up @@ -221,9 +224,17 @@ contract EToFoundry is
function _logStakes() internal {
bytes32 currentCdp = sortedCdps.getFirst();

console2.log("=== LogStakes ===");

uint256 currentPrice = priceFeedMock.fetchPrice();
uint256 currentPricePerShare = collateral.getPooledEthByShares(1 ether);
console2.log("currentPrice", currentPrice);
console2.log("currentPricePerShare", currentPricePerShare);

while (currentCdp != bytes32(0)) {
emit DebugBytes32(currentCdp);
console2.log("CdpId", vm.toString(currentCdp));
console2.log("===============================");
console2.log("cdpManager.getCdpStake(currentCdp)", cdpManager.getCdpStake(currentCdp));
console2.log(
"cdpManager.getSyncedCdpCollShares(currentCdp)",
Expand All @@ -239,7 +250,16 @@ contract EToFoundry is
"cdpManager.getSyncedNominalICR(currentCdp)",
cdpManager.getSyncedNominalICR(currentCdp)
);
console2.log(
"cdpManager.getCachedICR(currentCdp, currentPrice)",
cdpManager.getCachedICR(currentCdp, currentPrice)
);
console2.log(
"cdpManager.getSyncedICR(currentCdp, currentPrice)",
cdpManager.getSyncedICR(currentCdp, currentPrice)
);
currentCdp = sortedCdps.getNext(currentCdp);
console2.log("");
}

console2.log(
Expand Down
66 changes: 66 additions & 0 deletions packages/contracts/foundry_test/FlashLoanAttack.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import "../contracts/Interfaces/IERC3156FlashLender.sol";
* FlashLoan ReEntrancy Attack
*/

interface IActivePool {
function feeRecipientAddress() external view returns (address);

function feeBps() external view returns (uint256);
}

contract FlashAttack {
IERC20 public immutable want;
IERC3156FlashLender public immutable lender;
Expand Down Expand Up @@ -45,6 +51,37 @@ contract FlashAttack {
}
}

contract FlashFeeEscapeBorrower {
IERC20 public immutable want;
IERC3156FlashLender public immutable lender;
uint256 public counter;

constructor(IERC20 _want, IERC3156FlashLender _lender, uint256 _amt) {
want = _want;
lender = _lender;

// Approve to repay
IERC20(_want).approve(address(_lender), type(uint256).max);
counter = _amt;
}

function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32) {
require(token == address(want));

if (IERC20(want).balanceOf(address(this)) < counter) {
lender.flashLoan(IERC3156FlashBorrower(address(this)), address(want), amount, data);
}

return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
}
Comment on lines +54 to +83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FlashFeeEscapeBorrower contract is designed to simulate an attack scenario where a borrower attempts to escape fees through flash loans. The constructor and onFlashLoan() function are well-structured. However, it's critical to ensure that the onFlashLoan() function properly handles reentrancy attacks, given its external visibility and interaction with external contracts (lender.flashLoan()).

Consider adding reentrancy guards to the onFlashLoan() function to prevent potential reentrancy attacks.


contract FlashLoanAttack is eBTCBaseFixture {
function setUp() public override {
// Base setup
Expand Down Expand Up @@ -160,4 +197,33 @@ contract FlashLoanAttack is eBTCBaseFixture {
abi.encodePacked(uint256(0))
);
}

function testFeeEscapeAttack() public {
uint256 _feeBps = IActivePool(address(activePool)).feeBps();
uint256 amount = 10000 / IActivePool(address(activePool)).feeBps();
uint256 totalAmount = amount * _feeBps;

uint256 fee = activePool.flashFee(address(collateral), amount);

vm.assume(fee == 0);

FlashFeeEscapeBorrower attacker = new FlashFeeEscapeBorrower(
IERC20(address(collateral)),
IERC3156FlashLender(address(activePool)),
amount
);

dealCollateral(address(activePool), totalAmount * 2);

address _feeRecipient = IActivePool(address(activePool)).feeRecipientAddress();
uint256 _feeBefore = IERC20(address(collateral)).balanceOf(_feeRecipient);
activePool.flashLoan(
IERC3156FlashBorrower(address(attacker)),
address(collateral),
amount,
abi.encodePacked(amount)
);
uint256 _feeAfter = IERC20(address(collateral)).balanceOf(_feeRecipient);
require(_feeAfter == _feeBefore, "!flash fee should be zero due to division loss");
}
}
Loading