diff --git "a/data/contracts.json" "b/data/contracts.json" --- "a/data/contracts.json" +++ "b/data/contracts.json" @@ -126,8 +126,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getRevision", @@ -142,8 +142,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "initialize", @@ -176,8 +176,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "burn", @@ -210,21 +210,26 @@ "description": "The new liquidity index of the reserve" } ], - "returns": "", + "returns": "Returns true if successfully burns x ATokens from the account of u and sends x underlying tokens to t", "output_property": "Burns amountScaled aTokens from user, transfers amount underlying tokens to receiverOfUnderlying. Emits Transfer and Burn events. Reverts if msg.sender is not LendingPool, if amountScaled == 0, or if user has insufficient balance.", "events": [ "Transfer", "Burn" ], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Additive burn (rounding vulnerability)", "severity": "Medium", "description": "Due to rounding in conversions to AToken, if the conversion rate is high enough, one can withdraw a small amount that will result in the system transferring underlying tokens but burning zero ATokens of the user's account", "mitigation": "Fixed by adding require(amountScaled != 0) check to prevent zero-value burns" }, - "rule_broken_english": "When a user burns x amount of ATokens, the user's AToken balance should decrease by x, and the user should receive x amount of underlying tokens. Due to rounding errors in conversion, a user could receive underlying tokens without burning any ATokens, breaking the invariant that burning decreases AToken balance proportionally to the underlying amount transferred.", - "rule_broken_specs": "Pre-condition: User has AToken balance B. Operation: burn(user, receiver, amount, index). Expected post-condition: User's AToken balance = B - amount (within rounding tolerance ε). Actual vulnerability: When amount.rayDiv(index) rounds down to 0, the burn operation transfers amount underlying tokens but burns 0 ATokens, resulting in user AToken balance unchanged = B, violating the expected post-condition where the balance should be B - amount." + "property": "Burning is additive, it can be performed either all at once or in steps.", + "property_specification": { + "precondition": "User has AToken balance B", + "operation": "burn(user, receiver, amount, index)", + "expected_postcondition": "User's AToken balance = B - amount (within rounding tolerance ε)", + "actual_vulnerability": "When amount.rayDiv(index) rounds down to 0, the burn operation transfers amount underlying tokens but burns 0 ATokens, resulting in user AToken balance unchanged = B, violating the expected post-condition where the balance should be B - amount." + } }, { "name": "mint", @@ -260,8 +265,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "mintToTreasury", @@ -292,8 +297,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "transferOnLiquidation", @@ -329,8 +334,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "balanceOf", @@ -351,8 +356,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "scaledBalanceOf", @@ -373,8 +378,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getScaledUserBalanceAndSupply", @@ -395,8 +400,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "totalSupply", @@ -411,8 +416,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "scaledTotalSupply", @@ -427,8 +432,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "transferUnderlyingTo", @@ -456,8 +461,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "permit", @@ -508,8 +513,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_transfer (4 params)", @@ -547,8 +552,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_transfer (override)", @@ -579,8 +584,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null } ], "modifiers": [ @@ -666,8 +671,8 @@ "description": "Due to rounding in conversions to AToken, if the conversion rate is high enough, one can withdraw a small amount that will result in the system transferring underlying tokens but burning zero ATokens of the user's account. This results in system loss of assets and user gain of assets.", "status": "Fixed", "mitigation": "Added require(amountScaled != 0, Errors.CT_INVALID_BURN_AMOUNT) to prevent zero-value burns", - "rule_broken_english": "When a user burns x amount of ATokens, the user's AToken balance should decrease by x, and the user should receive x amount of underlying tokens. Due to rounding errors in conversion, a user could receive underlying tokens without burning any ATokens, breaking the invariant that burning decreases AToken balance proportionally to the underlying amount transferred.", - "rule_broken_specs": "Pre-condition: User has AToken balance B. Operation: burn(user, receiver, amount, index). Expected post-condition: User's AToken balance = B - amount (within rounding tolerance ε). Actual vulnerability: When amount.rayDiv(index) rounds down to 0, the burn operation transfers amount underlying tokens but burns 0 ATokens, resulting in user AToken balance unchanged = B, violating the expected post-condition where the balance should be B - amount." + "property": "When a user burns x amount of ATokens, the user's AToken balance should decrease by x, and the user should receive x amount of underlying tokens. Due to rounding errors in conversion, a user could receive underlying tokens without burning any ATokens, breaking the invariant that burning decreases AToken balance proportionally to the underlying amount transferred.", + "property_specification": "precondition: User has AToken balance B. Operation: burn(user, receiver, amount, index). Expected post-condition: User's AToken balance = B - amount (within rounding tolerance ε). Actual vulnerability: When amount.rayDiv(index) rounds down to 0, the burn operation transfers amount underlying tokens but burns 0 ATokens, resulting in user AToken balance unchanged = B, violating the expected post-condition where the balance should be B - amount." } ], "events": [ @@ -780,8 +785,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getRevision", @@ -796,8 +801,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getAverageStableRate", @@ -812,8 +817,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getUserLastUpdated", @@ -834,8 +839,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getUserStableRate", @@ -856,8 +861,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "balanceOf", @@ -878,8 +883,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "mint", @@ -918,15 +923,20 @@ "Transfer", "Mint" ], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Additive mint (Stable debt token)", "severity": "Medium", "description": "Due to rounding in conversions to stable debt token, if the conversion rate is high enough, one can deposit a small amount that will result in the system transferring underlying tokens but minting debt tokens to the user's account", "mitigation": "Fixed to avoid transfer on mint/burn of zero stable debt tokens" }, - "rule_broken_english": "When a user mints x amount of stable debt tokens, the user's debt balance should increase by x, and the protocol should receive x amount of underlying tokens from the user. Due to rounding errors in conversion, a user could receive debt tokens without transferring underlying tokens, or could transfer underlying tokens without receiving debt tokens, breaking the invariant that minting increases debt balance proportionally to underlying amount transferred.", - "rule_broken_specs": "Pre-condition: User has debt balance B. Operation: mint(user, onBehalfOf, amount, rate). Expected post-condition: User's debt balance = B + amount (within rounding tolerance ε). Actual vulnerability: When amount conversion rounds down to 0 in intermediate calculations, the mint operation may mint zero debt tokens while still transferring underlying tokens (or vice versa), resulting in user debt balance unchanged = B, violating the expected post-condition where the balance should be B + amount." + "property": "Minting is additive, it can be performed either all at once or in steps", + "property_specification": { + "precondition": "User has debt balance B", + "operation": "mint(user, onBehalfOf, amount, rate)", + "expected_postcondition": "User's debt balance = B + amount (within rounding tolerance ε)", + "actual_vulnerability": "When amount conversion rounds down to 0 in intermediate calculations, the mint operation may mint zero debt tokens while still transferring underlying tokens (or vice versa), resulting in user debt balance unchanged = B, violating the expected post-condition where the balance should be B + amount." + } }, { "name": "burn", @@ -958,8 +968,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_calculateBalanceIncrease", @@ -980,8 +990,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getSupplyData", @@ -996,8 +1006,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getTotalSupplyAndAvgRate", @@ -1012,8 +1022,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "totalSupply", @@ -1028,8 +1038,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getTotalSupplyLastUpdated", @@ -1044,8 +1054,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "principalBalanceOf", @@ -1066,8 +1076,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_calcTotalSupply", @@ -1088,8 +1098,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_mint", @@ -1120,8 +1130,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_burn", @@ -1152,8 +1162,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null } ], "structs": [ @@ -1244,8 +1254,8 @@ "description": "Due to rounding in conversions to stable debt token, if the conversion rate is high enough, one can deposit a small amount that will result in the system transferring underlying tokens but minting debt tokens to the user's account. This results in system loss of assets and user gain of assets.", "status": "Fixed", "mitigation": "Fixed to avoid transfer on mint/burn of zero stable debt tokens", - "rule_broken_english": "When a user mints x amount of stable debt tokens, the user's debt balance should increase by x, and the protocol should receive x amount of underlying tokens from the user. Due to rounding errors in conversion, a user could receive debt tokens without transferring underlying tokens, or could transfer underlying tokens without receiving debt tokens, breaking the invariant that minting increases debt balance proportionally to underlying amount transferred.", - "rule_broken_specs": "Pre-condition: User has debt balance B. Operation: mint(user, onBehalfOf, amount, rate). Expected post-condition: User's debt balance = B + amount (within rounding tolerance ε). Actual vulnerability: When amount conversion rounds down to 0 in intermediate calculations, the mint operation may mint zero debt tokens while still transferring underlying tokens (or vice versa), resulting in user debt balance unchanged = B, violating the expected post-condition where the balance should be B + amount." + "property": "When a user mints x amount of stable debt tokens, the user's debt balance should increase by x, and the protocol should receive x amount of underlying tokens from the user. Due to rounding errors in conversion, a user could receive debt tokens without transferring underlying tokens, or could transfer underlying tokens without receiving debt tokens, breaking the invariant that minting increases debt balance proportionally to underlying amount transferred.", + "property_specification": "precondition: User has debt balance B. Operation: mint(user, onBehalfOf, amount, rate). Expected post-condition: User's debt balance = B + amount (within rounding tolerance ε). Actual vulnerability: When amount conversion rounds down to 0 in intermediate calculations, the mint operation may mint zero debt tokens while still transferring underlying tokens (or vice versa), resulting in user debt balance unchanged = B, violating the expected post-condition where the balance should be B + amount." } ], "events": [ @@ -1357,8 +1367,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "initialize", @@ -1405,8 +1415,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "deposit", @@ -1435,8 +1445,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "depositATokens", @@ -1465,8 +1475,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "depositWithSig", @@ -1505,8 +1515,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "depositATokensWithSig", @@ -1545,8 +1555,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "mint", @@ -1575,8 +1585,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "mintWithATokens", @@ -1605,8 +1615,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "mintWithSig", @@ -1645,8 +1655,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "mintWithATokensWithSig", @@ -1685,8 +1695,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "withdraw", @@ -1720,8 +1730,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "withdrawATokens", @@ -1755,8 +1765,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "withdrawWithSig", @@ -1795,8 +1805,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "withdrawATokensWithSig", @@ -1835,8 +1845,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "redeem", @@ -1870,8 +1880,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "redeemAsATokens", @@ -1905,8 +1915,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "redeemWithSig", @@ -1945,8 +1955,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "redeemWithATokensWithSig", @@ -1985,8 +1995,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "maxDeposit", @@ -2007,8 +2017,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "maxMint", @@ -2029,8 +2039,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "maxWithdraw", @@ -2051,8 +2061,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "maxRedeem", @@ -2073,8 +2083,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "previewDeposit", @@ -2093,15 +2103,20 @@ "returns": "uint256 - Amount of shares that would be minted.", "output_property": "Calculates shares based on the minimum of the deposit amount and the max suppliable to Aave. The EIP-4626 standard requires this function not to account for deposit limits; however, this implementation does, violating that specification.", "events": [], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Non-compliance with EIP4626 standard - previewDeposit", "severity": "Informational", "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool)." }, - "rule_broken_english": "The EIP-4626 standard requires that previewDeposit returns the same number of shares for a given deposit amount regardless of any deposit limits. In this implementation, previewDeposit returns a value that depends on the maximum deposit limit, which violates the standard.", - "rule_broken_specs": "Pre-condition: The vault has a maximum deposit limit L. Operation: A user calls previewDeposit(x) for an amount x > L. Expected post-condition: previewDeposit(x) returns the same number of shares as it would for x = L (since the function should not account for limits). Actual vulnerability: previewDeposit(x) returns the number of shares for L (due to min(x, L)), which is different from what it would return for an amount x' where x' > L, implying the function accounts for the limit, violating the EIP." + "property": "MUST return as close to, and no more than, the exact amount of Vault shares that would be minted in a deposit() call in the same transaction", + "property_specification": { + "precondition": "The vault has a maximum deposit limit L", + "Operation": "A user calls previewDeposit(x) for an amount x > L", + "Expected post-condition": "previewDeposit(x) returns the same number of shares as it would for x = L (since the function should not account for limits)", + "Actual vulnerability": "previewDeposit(x) returns the number of shares for L (due to min(x, L)), which is different from what it would return for an amount x' where x' > L, implying the function accounts for the limit, violating the EIP." + } }, { "name": "previewMint", @@ -2120,15 +2135,20 @@ "returns": "uint256 - Amount of underlying assets required.", "output_property": "Calculates the required assets based on the shares and caps it by the maximum suppliable amount to Aave. This violates the EIP-4626 standard which requires preview functions not to account for such limits.", "events": [], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Non-compliance with EIP4626 standard - previewMint", "severity": "Informational", "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool)." }, - "rule_broken_english": "The EIP-4626 standard requires that previewMint returns the same amount of assets required for a given number of shares regardless of any minting limits. In this implementation, previewMint returns a value that depends on the maximum deposit limit, which violates the standard.", - "rule_broken_specs": "Pre-condition: The vault has a maximum deposit limit L. Operation: A user calls previewMint(y) for a number of shares y that would require more than L in assets. Expected post-condition: previewMint(y) returns the amount of assets needed to mint y shares without accounting for limits. Actual vulnerability: previewMint(y) returns the assets for y shares but capped at L, implying the function accounts for the deposit limit, violating the EIP." + "property": "MUST return as close to, and no more than, the exact amount of Vault shares that would be minted in a deposit() call in the same transaction", + "property_specification": { + "precondition": "The vault has a maximum deposit limit", + "Operation": "A user calls previewMint(y) for a number of shares y that would require more than L in assets", + "Expected post-condition": "previewMint(y) returns the amount of assets needed to mint y shares without accounting for limits", + "Actual vulnerability": "previewMint(y) returns the assets for y shares but capped at L, implying the function accounts for the deposit limit, violating the EIP." + } }, { "name": "previewWithdraw", @@ -2147,15 +2167,20 @@ "returns": "uint256 - Amount of shares that would be burned.", "output_property": "Calculates shares based on the minimum of the withdrawal amount and the available liquidity in Aave. This violates the EIP-4626 standard which requires preview functions not to account for such limits.", "events": [], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Non-compliance with EIP4626 standard - previewWithdraw", "severity": "Informational", "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool)." }, - "rule_broken_english": "The EIP-4626 standard requires that previewWithdraw returns the same number of shares for a given withdrawal amount regardless of any withdrawal limits. In this implementation, previewWithdraw returns a value that depends on the available liquidity, which violates the standard.", - "rule_broken_specs": "Pre-condition: The vault has available liquidity L. Operation: A user calls previewWithdraw(x) for an amount x > L. Expected post-condition: previewWithdraw(x) returns the number of shares needed to withdraw x assets without accounting for limits. Actual vulnerability: previewWithdraw(x) returns the number of shares for L (due to min(x, L)), implying the function accounts for the withdrawal limit, violating the EIP." + "property": "previewWithdraw() MUST NOT account for withdrawal limits like those returned from maxWithdraw()", + "property_specification": { + "precondition": "The vault has available liquidity", + "Operation": "A user calls previewWithdraw(x) for an amount x > L", + "Expected post-condition": "previewWithdraw(x) returns the number of shares needed to withdraw x assets without accounting for limits", + "Actual vulnerability": "previewWithdraw(x) returns the number of shares for L (due to min(x, L)), implying the function accounts for the withdrawal limit, violating the EIP-4626 standard" + } }, { "name": "previewRedeem", @@ -2174,15 +2199,20 @@ "returns": "uint256 - Amount of assets that would be received.", "output_property": "Calculates the assets based on the shares and caps it by the available liquidity in Aave. This violates the EIP-4626 standard which requires preview functions not to account for such limits.", "events": [], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Non-compliance with EIP4626 standard - previewRedeem", "severity": "Informational", "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool)." }, - "rule_broken_english": "The EIP-4626 standard requires that previewRedeem returns the same amount of assets for a given number of shares regardless of any redemption limits. In this implementation, previewRedeem returns a value that depends on the available liquidity, which violates the standard.", - "rule_broken_specs": "Pre-condition: The vault has available liquidity L. Operation: A user calls previewRedeem(y) for a number of shares y that would require more than L in assets. Expected post-condition: previewRedeem(y) returns the amount of assets that would be received for y shares without accounting for limits. Actual vulnerability: previewRedeem(y) returns the assets for y shares but capped at L, implying the function accounts for the withdrawal limit, violating the EIP." + "property": "previewWithdraw() MUST NOT account for withdrawal limits like those returned from maxWithdraw()", + "property_specification": { + "precondition": "The vault has available liquidity", + "Operation": "A user calls previewRedeem(y) for a number of shares y that would require more than L in assets", + "Expected post-condition": "previewRedeem(y) returns the amount of assets that would be received for y shares without accounting for limits", + "Actual vulnerability": "previewRedeem(y) returns the assets for y shares but capped at L, implying the function accounts for the withdrawal limit, violating the EIP-4626 standard" + } }, { "name": "domainSeparator", @@ -2197,8 +2227,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "setFee", @@ -2224,8 +2254,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "withdrawFees", @@ -2256,8 +2286,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "claimRewards", @@ -2282,8 +2312,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "emergencyRescue", @@ -2318,8 +2348,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "totalAssets", @@ -2332,15 +2362,20 @@ "returns": "uint256 - Total assets.", "output_property": "Returns the vault's aToken balance minus claimable fees. May revert due to arithmetic underflow if getClaimableFees() exceeds the aToken balance. This violates the EIP-4626 requirement that totalAssets must not revert.", "events": [], - "vulnerable": true, + "vulnerable": false, "vulnerability_details": { "issue": "Non-compliance with EIP4626 standard - non-reverting functions", "severity": "Informational", "description": "As per EIP4626, the functions totalAssets, maxDeposit, maxMint, maxWithdraw, and maxRedeem must not revert by any means. In the contract, however, these functions may revert due to over/underflows of arithmetical computations.", "mitigation": "Although conforming to a standard is important and even essential to a degree, there is likely little to be gained from modifying and altering the core code's functionality to adapt to the minutiae of the standard. As the vault relies inherently on the Aave Protocol, it is acceptable to revert due to Aave-specific calculations." }, - "rule_broken_english": "The EIP-4626 standard requires that totalAssets must never revert. In this implementation, totalAssets can revert if the aToken balance is less than the claimable fees, which could happen in some edge cases.", - "rule_broken_specs": "Pre-condition: The vault's state has claimable fees greater than its aToken balance. Operation: A user calls totalAssets(). Expected post-condition: The function returns a value without reverting. Actual vulnerability: The function attempts to compute ATOKEN.balanceOf(address(this)) - getClaimableFees(), which underflows and reverts, violating the EIP." + "property": "totalAssets must never revert", + "property_specification": { + "precondition": "The vault's state has claimable fees greater than its aToken balance", + "Operation": "A user calls totalAssets()", + "Expected post-condition": "The function returns a value without reverting", + "Actual vulnerability": "The function attempts to compute ATOKEN.balanceOf(address(this)) - getClaimableFees(), which underflows and reverts, violating the EIP-4626 standard" + } }, { "name": "getClaimableFees", @@ -2355,8 +2390,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getSigNonce", @@ -2377,8 +2412,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getLastVaultBalance", @@ -2393,8 +2428,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "getFee", @@ -2409,8 +2444,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_setFee", @@ -2433,8 +2468,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_accrueYield", @@ -2451,8 +2486,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_handleDeposit", @@ -2491,8 +2526,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_handleMint", @@ -2531,8 +2566,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_handleWithdraw", @@ -2576,8 +2611,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_handleRedeem", @@ -2621,8 +2656,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_maxAssetsSuppliableToAave", @@ -2637,8 +2672,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_maxAssetsWithdrawableFromAave", @@ -2653,8 +2688,8 @@ "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_baseDeposit", @@ -2697,8 +2732,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { "name": "_baseWithdraw", @@ -2746,8 +2781,8 @@ ], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null } ], "structs": [ @@ -2990,8 +3025,8 @@ "description": "When depositing an amount of underlying token that isn't a whole multiplication of the liquidity index to the vault, the contract may reach a dirty state that keeps reverting undesirably on every method that calls accrueYield(). This occurs due to an inaccurate increment of lastVaultBalance that doesn't correspond to the actual increment or decrement in the vault's assets following deposits.", "status": "Fixed", "mitigation": "Fixed in PR#82 merged in commit 385b397.", - "rule_broken_english": "When a user deposits an amount of underlying tokens, the vault's internal state variable lastVaultBalance should be updated to match the actual aToken balance. Due to rounding in aToken's balance updates, the lastVaultBalance could become mismatched, leading to a permanent revert state for any function that calls accrueYield().", - "rule_broken_specs": "Pre-condition: The vault has a current aToken balance B and lastVaultBalance = B. Operation: A user deposits an amount of underlying tokens that, after conversion to aTokens, results in a balance B' that is not exactly represented due to rayMath rounding. Expected post-condition: lastVaultBalance is updated to B'. Actual vulnerability: The deposit function updates lastVaultBalance with the exact deposit amount, while aToken uses rayMath for its balance, causing a mismatch. This mismatch can accumulate over multiple operations, leading to a state where the lastVaultBalance is less than the actual balance, causing the yield calculation to revert." + "property": "When a user deposits an amount of underlying tokens, the vault's internal state variable lastVaultBalance should be updated to match the actual aToken balance. Due to rounding in aToken's balance updates, the lastVaultBalance could become mismatched, leading to a permanent revert state for any function that calls accrueYield().", + "property_specification": "precondition: The vault has a current aToken balance B and lastVaultBalance = B. Operation: A user deposits an amount of underlying tokens that, after conversion to aTokens, results in a balance B' that is not exactly represented due to rayMath rounding. Expected post-condition: lastVaultBalance is updated to B'. Actual vulnerability: The deposit function updates lastVaultBalance with the exact deposit amount, while aToken uses rayMath for its balance, causing a mismatch. This mismatch can accumulate over multiple operations, leading to a state where the lastVaultBalance is less than the actual balance, causing the yield calculation to revert." }, { "function": "getClaimableFees", @@ -3000,8 +3035,8 @@ "description": "The storage variable _s.lastVaultBalance marks the portion of reserves for which the vault has already charged fees. In every call to accrueYield(), the vault charges fees only from the new yield accrued since the last fee charge - ATOKEN.balanceOf(Vault) - _s.lastVaultBalance. Thus, it is expected that after every accrual, _s.lastVaultBalance will be equal to ATOKEN.balanceOf(Vault). However, the system may reach a mismatch between the two values when depositing to or withdrawing from the vault due to different update mechanisms. While _s.lastVaultBalance is being updated with the exact assets amount passed to the function, aToken uses rayMath to update the ATOKEN.balanceOf(Vault). While the former is exact, the latter is subject to rounding and may differ from the passed assets amount.", "status": "Fixed", "mitigation": "Fixed in PR#86 merged in commit 385b397.", - "rule_broken_english": "After every fee accrual, the vault's internal lastVaultBalance should equal the actual aToken balance. Due to rounding differences between how the vault updates lastVaultBalance (exact) and how aToken updates its balance (rayMath), a mismatch of up to 1 unit can occur per deposit/withdraw. Over many operations, this mismatch can accumulate, causing the vault to either undercharge fees (losing fee revenue) or overcharge fees (taking more than its fair share).", - "rule_broken_specs": "Pre-condition: The vault has lastVaultBalance = aToken balance (B) and accumulatedFees = F. Operation: A user deposits or withdraws an amount that, after aToken's rayMath rounding, results in a new aToken balance B' that differs from the vault's update by Δ (where |Δ| ≤ 1). Expected post-condition: lastVaultBalance = B' and fee accrual is based on the correct new yield. Actual vulnerability: The vault updates lastVaultBalance with the exact asset amount, but the aToken balance uses rounded values. This creates a persistent difference that affects future fee calculations, leading to either loss of fees (if lastVaultBalance > actual balance) or overcharging fees (if lastVaultBalance < actual balance)." + "property": "After every fee accrual, the vault's internal lastVaultBalance should equal the actual aToken balance. Due to rounding differences between how the vault updates lastVaultBalance (exact) and how aToken updates its balance (rayMath), a mismatch of up to 1 unit can occur per deposit/withdraw. Over many operations, this mismatch can accumulate, causing the vault to either undercharge fees (losing fee revenue) or overcharge fees (taking more than its fair share).", + "property_specification": "precondition: The vault has lastVaultBalance = aToken balance (B) and accumulatedFees = F. Operation: A user deposits or withdraws an amount that, after aToken's rayMath rounding, results in a new aToken balance B' that differs from the vault's update by Δ (where |Δ| ≤ 1). Expected post-condition: lastVaultBalance = B' and fee accrual is based on the correct new yield. Actual vulnerability: The vault updates lastVaultBalance with the exact asset amount, but the aToken balance uses rounded values. This creates a persistent difference that affects future fee calculations, leading to either loss of fees (if lastVaultBalance > actual balance) or overcharging fees (if lastVaultBalance < actual balance)." }, { "function": "previewRedeem", @@ -3010,8 +3045,8 @@ "description": "Before calling previewRedeem, _accrueYield() is called, which causes a reduction in totalAssets() by feePercentage * (ATOKEN.balanceOf(vault) - lastVaultBalance).", "status": "Fixed", "mitigation": "Fixed in PR#82 merged in commit 385b397.", - "rule_broken_english": "The previewRedeem function should return the amount of assets that would be received if the redemption were executed immediately. However, because the preview function does not account for the yield accrual that would happen during the actual redemption, it can return a larger amount than what is actually redeemable.", - "rule_broken_specs": "Pre-condition: The vault has some yield that has not been accrued, and a fee is set. Operation: A user calls previewRedeem(shares) to see how many assets they would get for redeeming a certain number of shares. Expected post-condition: The function returns the exact amount of assets that would be received if redeem(shares) were called immediately. Actual vulnerability: previewRedeem does not simulate the fee deduction that would occur in _accrueYield() during the actual redeem transaction, leading to an overestimation of the assets that would be received." + "property": "The previewRedeem function should return the amount of assets that would be received if the redemption were executed immediately. However, because the preview function does not account for the yield accrual that would happen during the actual redemption, it can return a larger amount than what is actually redeemable.", + "property_specification": "precondition: The vault has some yield that has not been accrued, and a fee is set. Operation: A user calls previewRedeem(shares) to see how many assets they would get for redeeming a certain number of shares. Expected post-condition: The function returns the exact amount of assets that would be received if redeem(shares) were called immediately. Actual vulnerability: previewRedeem does not simulate the fee deduction that would occur in _accrueYield() during the actual redeem transaction, leading to an overestimation of the assets that would be received." }, { "function": "withdrawFees", @@ -3020,8 +3055,8 @@ "description": "A user can frontrun the fee withdrawal to avoid fees on newly gifted assets by triggering an accrual before the gift is made, then the gift is added, and then fees are withdrawn based on the stored accumulatedFees rather than recalculating.", "status": "Fixed", "mitigation": "Fixed in PR#82 merged in commit 385b397.", - "rule_broken_english": "When the owner withdraws fees, they should receive fees on all yield earned up to that point, including any recent gifts to the protocol. However, an attacker can frontrun the fee withdrawal to make the gift after the accrual but before the withdrawal, causing the gift to be excluded from fees.", - "rule_broken_specs": "Pre-condition: The owner intends to withdraw fees. Operation: An attacker triggers _accrueYield() (by making a small deposit) before the owner calls withdrawFees. After the accrual, the attacker gifts aTokens directly to the vault. Expected post-condition: When the owner calls withdrawFees, the fees on the gifted amount are included. Actual vulnerability: withdrawFees uses the stored accumulatedFees from before the gift, rather than recalculating fees on the new balance, allowing the gifted amount to be withdrawn by the owner without paying fees to the fee collector." + "property": "When the owner withdraws fees, they should receive fees on all yield earned up to that point, including any recent gifts to the protocol. However, an attacker can frontrun the fee withdrawal to make the gift after the accrual but before the withdrawal, causing the gift to be excluded from fees.", + "property_specification": "precondition: The owner intends to withdraw fees. Operation: An attacker triggers _accrueYield() (by making a small deposit) before the owner calls withdrawFees. After the accrual, the attacker gifts aTokens directly to the vault. Expected post-condition: When the owner calls withdrawFees, the fees on the gifted amount are included. Actual vulnerability: withdrawFees uses the stored accumulatedFees from before the gift, rather than recalculating fees on the new balance, allowing the gifted amount to be withdrawn by the owner without paying fees to the fee collector." }, { "function": "previewDeposit", @@ -3030,8 +3065,8 @@ "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "status": "Acknowledged", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool).", - "rule_broken_english": "The previewDeposit function should return the number of shares that would be minted for a given deposit amount, independent of any deposit limits. However, this implementation caps the deposit amount by the maximum deposit limit, which violates the EIP-4626 requirement.", - "rule_broken_specs": "Pre-condition: The vault has a maximum deposit limit L. Operation: A user calls previewDeposit(x) for an amount x > L. Expected post-condition: previewDeposit(x) returns the same number of shares as it would for x = L (since the function should not account for limits). Actual vulnerability: previewDeposit(x) returns the number of shares for L (due to min(x, L)), which is different from what it would return for an amount x' where x' > L, implying the function accounts for the limit, violating the EIP." + "property": "The previewDeposit function should return the number of shares that would be minted for a given deposit amount, independent of any deposit limits. However, this implementation caps the deposit amount by the maximum deposit limit, which violates the EIP-4626 requirement.", + "property_specification": "precondition: The vault has a maximum deposit limit L. Operation: A user calls previewDeposit(x) for an amount x > L. Expected post-condition: previewDeposit(x) returns the same number of shares as it would for x = L (since the function should not account for limits). Actual vulnerability: previewDeposit(x) returns the number of shares for L (due to min(x, L)), which is different from what it would return for an amount x' where x' > L, implying the function accounts for the limit, violating the EIP." }, { "function": "previewMint", @@ -3040,8 +3075,8 @@ "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "status": "Acknowledged", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool).", - "rule_broken_english": "The previewMint function should return the amount of assets needed to mint a given number of shares, independent of any minting limits. However, this implementation caps the result by the maximum deposit limit, which violates the EIP-4626 requirement.", - "rule_broken_specs": "Pre-condition: The vault has a maximum deposit limit L. Operation: A user calls previewMint(y) for a number of shares y that would require more than L in assets. Expected post-condition: previewMint(y) returns the amount of assets needed to mint y shares without accounting for limits. Actual vulnerability: previewMint(y) returns the assets for y shares but capped at L, implying the function accounts for the deposit limit, violating the EIP." + "property": "The previewMint function should return the amount of assets needed to mint a given number of shares, independent of any minting limits. However, this implementation caps the result by the maximum deposit limit, which violates the EIP-4626 requirement.", + "property_specification": "precondition: The vault has a maximum deposit limit L. Operation: A user calls previewMint(y) for a number of shares y that would require more than L in assets. Expected post-condition: previewMint(y) returns the amount of assets needed to mint y shares without accounting for limits. Actual vulnerability: previewMint(y) returns the assets for y shares but capped at L, implying the function accounts for the deposit limit, violating the EIP." }, { "function": "previewWithdraw", @@ -3050,8 +3085,8 @@ "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "status": "Acknowledged", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool).", - "rule_broken_english": "The previewWithdraw function should return the number of shares needed to withdraw a given amount of assets, independent of any withdrawal limits. However, this implementation caps the withdrawal amount by the available liquidity, which violates the EIP-4626 requirement.", - "rule_broken_specs": "Pre-condition: The vault has available liquidity L. Operation: A user calls previewWithdraw(x) for an amount x > L. Expected post-condition: previewWithdraw(x) returns the number of shares needed to withdraw x assets without accounting for limits. Actual vulnerability: previewWithdraw(x) returns the number of shares for L (due to min(x, L)), implying the function accounts for the withdrawal limit, violating the EIP." + "property": "The previewWithdraw function should return the number of shares needed to withdraw a given amount of assets, independent of any withdrawal limits. However, this implementation caps the withdrawal amount by the available liquidity, which violates the EIP-4626 requirement.", + "property_specification": "precondition: The vault has available liquidity L. Operation: A user calls previewWithdraw(x) for an amount x > L. Expected post-condition: previewWithdraw(x) returns the number of shares needed to withdraw x assets without accounting for limits. Actual vulnerability: previewWithdraw(x) returns the number of shares for L (due to min(x, L)), implying the function accounts for the withdrawal limit, violating the EIP." }, { "function": "previewRedeem", @@ -3060,8 +3095,8 @@ "description": "As per EIP4626, all the preview functions must not take into account any limitation of the system, like those returned by the max() methods. In the contract, the preview methods do take into account system limitations.", "status": "Acknowledged", "mitigation": "As per EIP4626, all the preview functions may revert due to other conditions that would also cause primary functions to revert. Relying on Aave is acceptable, given that primary functions are impacted by its limitations (e.g. users cannot withdraw if there is no available liquidity in the Aave Pool).", - "rule_broken_english": "The previewRedeem function should return the amount of assets that would be received for redeeming a given number of shares, independent of any redemption limits. However, this implementation caps the result by the available liquidity, which violates the EIP-4626 requirement.", - "rule_broken_specs": "Pre-condition: The vault has available liquidity L. Operation: A user calls previewRedeem(y) for a number of shares y that would require more than L in assets. Expected post-condition: previewRedeem(y) returns the amount of assets that would be received for y shares without accounting for limits. Actual vulnerability: previewRedeem(y) returns the assets for y shares but capped at L, implying the function accounts for the withdrawal limit, violating the EIP." + "property": "The previewRedeem function should return the amount of assets that would be received for redeeming a given number of shares, independent of any redemption limits. However, this implementation caps the result by the available liquidity, which violates the EIP-4626 requirement.", + "property_specification": "precondition: The vault has available liquidity L. Operation: A user calls previewRedeem(y) for a number of shares y that would require more than L in assets. Expected post-condition: previewRedeem(y) returns the amount of assets that would be received for y shares without accounting for limits. Actual vulnerability: previewRedeem(y) returns the assets for y shares but capped at L, implying the function accounts for the withdrawal limit, violating the EIP." }, { "function": "totalAssets", @@ -3070,8 +3105,8 @@ "description": "As per EIP4626, the functions totalAssets, maxDeposit, maxMint, maxWithdraw, and maxRedeem must not revert by any means. In the contract, however, these functions may revert due to over/underflows of arithmetical computations.", "status": "Acknowledged", "mitigation": "Although conforming to a standard is important and even essential to a degree, there is likely little to be gained from modifying and altering the core code's functionality to adapt to the minutiae of the standard. As the vault relies inherently on the Aave Protocol, it is acceptable to revert due to Aave-specific calculations.", - "rule_broken_english": "The totalAssets function should never revert. However, because it computes aToken balance minus claimable fees, it can revert if the claimable fees exceed the aToken balance due to previous mismatches.", - "rule_broken_specs": "Pre-condition: The vault's state has claimable fees greater than its aToken balance. Operation: A user calls totalAssets(). Expected post-condition: The function returns a value without reverting. Actual vulnerability: The function attempts to compute ATOKEN.balanceOf(address(this)) - getClaimableFees(), which underflows and reverts, violating the EIP." + "property": "The totalAssets function should never revert. However, because it computes aToken balance minus claimable fees, it can revert if the claimable fees exceed the aToken balance due to previous mismatches.", + "property_specification": "precondition: The vault's state has claimable fees greater than its aToken balance. Operation: A user calls totalAssets(). Expected post-condition: The function returns a value without reverting. Actual vulnerability: The function attempts to compute ATOKEN.balanceOf(address(this)) - getClaimableFees(), which underflows and reverts, violating the EIP." } ], "events": [ @@ -3124,556 +3159,934 @@ }, { "contract_name": "WithdrawalQueue", - "file_name": "WithdrawalQueue.txt", - "file_path": "https://github.com/lidofinance/core/blob/45a2a3a8368dd237851030f6858aacc8e88670ca/contracts/0.8.9/WithdrawalQueue.sol", + "file_name": "WithdrawalQueue.sol", "metadata": { "license": "GPL-3.0", "solidity_version": "0.8.9", - "description": "A dedicated contract for handling stETH withdrawal request queue", - "author": "Lido (info@lido.fi)" + "description": "A contract for handling stETH withdrawal request queue within the Lido protocol", + "author": "Lido" }, "state_variables": [ { - "name": "MIN_WITHDRAWAL", + "name": "BUNKER_MODE_SINCE_TIMESTAMP_POSITION", + "type": "bytes32", + "visibility": "internal", + "mutability": "constant", + "description": "Storage position for bunker mode activation timestamp" + }, + { + "name": "BUNKER_MODE_DISABLED_TIMESTAMP", "type": "uint256", "visibility": "public", "mutability": "constant", - "description": "minimal possible sum that is possible to withdraw. Threshold to avoid dealing with small amounts due to gas spent on oracle for each request." + "description": "Special value when bunker mode is inactive (max uint256)" }, { - "name": "OWNER", - "type": "address payable", + "name": "PAUSE_ROLE", + "type": "bytes32", "visibility": "public", - "mutability": "immutable", - "description": "Address of the protocol owner (Lido). All state-modifying calls are allowed only from this address." + "mutability": "constant", + "description": "Role identifier for pausing the contract" }, { - "name": "lockedEtherAmount", - "type": "uint128", + "name": "RESUME_ROLE", + "type": "bytes32", "visibility": "public", - "mutability": "", - "description": "amount of ETH on this contract balance that is locked for withdrawal and waiting for claim. Invariant: `lockedEtherAmount <= this.balance`" + "mutability": "constant", + "description": "Role identifier for resuming the contract" }, { - "name": "queue", - "type": "Request[]", + "name": "FINALIZE_ROLE", + "type": "bytes32", "visibility": "public", - "mutability": "", - "description": "array storing the queue of withdrawal requests" + "mutability": "constant", + "description": "Role identifier for finalizing withdrawals" + }, + { + "name": "ORACLE_ROLE", + "type": "bytes32", + "visibility": "public", + "mutability": "constant", + "description": "Role identifier for oracle reports" }, { - "name": "finalizedRequestsCounter", + "name": "MIN_STETH_WITHDRAWAL_AMOUNT", "type": "uint256", "visibility": "public", - "mutability": "", - "description": "length of the finalized part of the queue. All requests with index < finalizedRequestsCounter are considered finalized." + "mutability": "constant", + "description": "Minimum stETH amount for a withdrawal request" }, { - "name": "finalizationPrices", - "type": "Price[]", + "name": "MAX_STETH_WITHDRAWAL_AMOUNT", + "type": "uint256", "visibility": "public", - "mutability": "", - "description": "history of share prices used for finalization, each price is valid for a range of requests in the queue" + "mutability": "constant", + "description": "Maximum stETH amount for a single withdrawal request" + }, + { + "name": "STETH", + "type": "IStETH", + "visibility": "public", + "mutability": "immutable", + "description": "Lido stETH token contract" + }, + { + "name": "WSTETH", + "type": "IWstETH", + "visibility": "public", + "mutability": "immutable", + "description": "Lido wstETH token contract" } ], "functions": [ { "name": "constructor", - "signature": "constructor(address payable _owner)", - "code": "constructor(address payable _owner) {\n require(_owner != address(0), \"ZERO_OWNER\");\n OWNER = _owner;\n}", - "comment": "Sets the contract owner, which is the only address that can call `enqueue` and `finalize`.", + "signature": "constructor(IWstETH _wstETH)", + "code": "constructor(IWstETH _wstETH) {\n WSTETH = _wstETH;\n STETH = WSTETH.stETH();\n}", + "comment": "Constructor sets immutable stETH and wstETH references", "visibility": "public", "modifiers": [], "parameters": [ { - "name": "_owner", - "type": "address payable", - "description": "The address that will have the onlyOwner role." + "name": "_wstETH", + "type": "IWstETH", + "description": "Address of WstETH contract" } ], "returns": "", - "output_property": "Sets the immutable OWNER address. Reverts if the provided _owner address is the zero address.", + "output_property": "Initializes immutable variables WSTETH and STETH. No return value.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "queueLength", - "signature": "queueLength()", - "code": "function queueLength() external view returns (uint256) {\n return queue.length;\n}", - "comment": "Getter for withdrawal queue length", + "name": "initialize", + "signature": "initialize(address _admin) external", + "code": "function initialize(address _admin) external {\n if (_admin == address(0)) revert AdminZeroAddress();\n _initialize(_admin);\n}", + "comment": "Initializes the contract storage", + "visibility": "external", + "modifiers": [], + "parameters": [ + { + "name": "_admin", + "type": "address", + "description": "Admin address that can change roles" + } + ], + "returns": "", + "output_property": "Calls _initialize after checking admin not zero. Reverts if admin is zero address.", + "events": [ + "InitializedV1" + ], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "resume", + "signature": "resume() external", + "code": "function resume() external {\n _checkPaused();\n _checkRole(RESUME_ROLE, msg.sender);\n _resume();\n}", + "comment": "Resumes withdrawal requests placement and finalization", "visibility": "external", "modifiers": [], "parameters": [], - "returns": "uint256 length of the request queue", - "output_property": "Returns the total number of withdrawal requests in the queue.", + "returns": "", + "output_property": "Checks contract is paused, checks caller has RESUME_ROLE, then calls _resume(). Reverts if not paused or caller lacks role.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "enqueue", - "signature": "enqueue(address payable _recipient, uint256 _etherAmount, uint256 _sharesAmount)", - "code": "function enqueue(\n address payable _recipient,\n uint256 _etherAmount,\n uint256 _sharesAmount\n) external onlyOwner returns (uint256 requestId) {\n require(_etherAmount > MIN_WITHDRAWAL, \"WITHDRAWAL_IS_TOO_SMALL\");\n requestId = queue.length;\n\n uint128 cumulativeEther = _toUint128(_etherAmount);\n uint128 cumulativeShares = _toUint128(_sharesAmount);\n\n if (requestId > 0) {\n cumulativeEther += queue[requestId - 1].cumulativeEther;\n cumulativeShares += queue[requestId - 1].cumulativeShares;\n }\n\n queue.push(Request(\n cumulativeEther,\n cumulativeShares,\n _recipient,\n _toUint64(block.number),\n false\n ));\n}", - "comment": "put a withdrawal request in a queue and associate it with `_recipient` address. Assumes that `_ethAmount` of stETH is locked before invoking this function.", + "name": "pauseFor", + "signature": "pauseFor(uint256 _duration) external onlyRole(PAUSE_ROLE)", + "code": "function pauseFor(uint256 _duration) external onlyRole(PAUSE_ROLE) {\n _pauseFor(_duration);\n}", + "comment": "Pause withdrawal requests for a given duration", "visibility": "external", "modifiers": [ - "onlyOwner" + "onlyRole(PAUSE_ROLE)" ], "parameters": [ { - "name": "_recipient", - "type": "address payable", - "description": "payable address this request will be associated with" - }, - { - "name": "_etherAmount", - "type": "uint256", - "description": "maximum amount of ether (equal to amount of locked stETH) that will be claimed upon withdrawal" - }, - { - "name": "_sharesAmount", + "name": "_duration", "type": "uint256", - "description": "amount of stETH shares that will be burned upon withdrawal" + "description": "Pause duration in seconds" } ], - "returns": "uint256 requestId unique id to claim funds once it is available", - "output_property": "Adds a new withdrawal request to the end of the queue. Updates cumulative counters. Reverts if `_etherAmount` is less than MIN_WITHDRAWAL or if the caller is not the OWNER.", + "returns": "", + "output_property": "Calls _pauseFor with duration. Reverts if caller lacks PAUSE_ROLE or if duration is zero.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "finalize", - "signature": "finalize(uint256 _lastIdToFinalize, uint256 _etherToLock, uint256 _totalPooledEther, uint256 _totalShares)", - "code": "function finalize(\n uint256 _lastIdToFinalize,\n uint256 _etherToLock,\n uint256 _totalPooledEther,\n uint256 _totalShares\n) external payable onlyOwner {\n require(\n _lastIdToFinalize >= finalizedRequestsCounter && _lastIdToFinalize < queue.length,\n \"INVALID_FINALIZATION_ID\"\n );\n require(lockedEtherAmount + _etherToLock <= address(this).balance, \"NOT_ENOUGH_ETHER\");\n\n _updatePriceHistory(_toUint128(_totalPooledEther), _toUint128(_totalShares), _lastIdToFinalize);\n\n lockedEtherAmount = _toUint128(_etherToLock);\n finalizedRequestsCounter = _lastIdToFinalize + 1;\n}", - "comment": "Finalize the batch of requests started at `finalizedRequestsCounter` and ended at `_lastIdToFinalize` using the given price", + "name": "pauseUntil", + "signature": "pauseUntil(uint256 _pauseUntilInclusive) external onlyRole(PAUSE_ROLE)", + "code": "function pauseUntil(uint256 _pauseUntilInclusive) external onlyRole(PAUSE_ROLE) {\n _pauseUntil(_pauseUntilInclusive);\n}", + "comment": "Pause withdrawal requests until a specific timestamp", "visibility": "external", "modifiers": [ - "onlyOwner" + "onlyRole(PAUSE_ROLE)" ], "parameters": [ { - "name": "_lastIdToFinalize", + "name": "_pauseUntilInclusive", "type": "uint256", - "description": "request index in the queue that will be last finalized request in a batch" - }, + "description": "Last second to pause until (inclusive)" + } + ], + "returns": "", + "output_property": "Calls _pauseUntil with timestamp. Reverts if caller lacks PAUSE_ROLE or timestamp is in the past.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "requestWithdrawals", + "signature": "requestWithdrawals(uint256[] calldata amounts, address _owner) public returns (uint256[] memory requestIds)", + "code": "function requestWithdrawals(uint256[] calldata amounts, address _owner) public returns (uint256[] memory requestIds) {\n _checkResumed();\n if (_owner == address(0)) _owner = msg.sender;\n requestIds = new uint256[](amounts.length);\n for (uint256 i = 0; i < amounts.length; ++i) {\n _checkWithdrawalRequestAmount(amounts[i]);\n requestIds[i] = _requestWithdrawal(amounts[i], _owner);\n }\n}", + "comment": "Creates withdrawal requests for stETH amounts", + "visibility": "public", + "modifiers": [], + "parameters": [ { - "name": "_etherToLock", - "type": "uint256", - "description": "ether that should be locked for these requests" + "name": "amounts", + "type": "uint256[]", + "description": "Array of stETH amounts" }, { - "name": "_totalPooledEther", - "type": "uint256", - "description": "ether price component that will be used for this request batch finalization" + "name": "_owner", + "type": "address", + "description": "Owner of requests (zero uses msg.sender)" + } + ], + "returns": "uint256[] - Array of created request IDs", + "output_property": "Checks contract is resumed, validates each amount, transfers stETH from caller, mints request tokens. Reverts if paused, amount too small/large, or transfer fails.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "requestWithdrawalsWstETH", + "signature": "requestWithdrawalsWstETH(uint256[] calldata amounts, address _owner) public returns (uint256[] memory requestIds)", + "code": "function requestWithdrawalsWstETH(uint256[] calldata amounts, address _owner) public returns (uint256[] memory requestIds) {\n _checkResumed();\n if (_owner == address(0)) _owner = msg.sender;\n requestIds = new uint256[](amounts.length);\n for (uint256 i = 0; i < amounts.length; ++i) {\n requestIds[i] = _requestWithdrawalWstETH(amounts[i], _owner);\n }\n}", + "comment": "Creates withdrawal requests for wstETH amounts", + "visibility": "public", + "modifiers": [], + "parameters": [ + { + "name": "amounts", + "type": "uint256[]", + "description": "Array of wstETH amounts" }, { - "name": "_totalShares", - "type": "uint256", - "description": "shares price component that will be used for this request batch finalization" + "name": "_owner", + "type": "address", + "description": "Owner of requests" } ], - "returns": "", - "output_property": "Finalizes a batch of withdrawal requests. Updates the price history, locks ETH for the batch, and increments the finalized counter. Reverts if the caller is not OWNER, if the `_lastIdToFinalize` is invalid, or if the contract lacks sufficient ETH balance to lock the requested amount.", + "returns": "uint256[] - Array of request IDs", + "output_property": "Checks resumed, unwraps wstETH to stETH, validates amount, transfers wstETH from caller, creates request. Reverts if paused or transfer fails.", "events": [], - "vulnerable": true, - "vulnerability_details": { - "issue": "Discount Factor Issue (Unfair finalization within a batch)", - "severity": "Critical", - "description": "If two users in the same finalization batch entered the queue at different share rates (e.g., before and after a slashing event), the finalization logic weights the amount incorrectly. This can lead to one user losing ETH at the expense of another user within the same batch, which violates the FIFO principle of fairness.", - "mitigation": "Fixed by removing the discount factor in favor of a share rate batch-wise calculation approach (commit 336b6f3)." - }, - "rule_broken_english": "When multiple withdrawal requests are finalized in a single batch, each request should receive an amount of ETH proportional to the share rate at the time of its creation. If users in the batch have different entry share rates, a later user with a higher share rate should not have their withdrawal amount subsidized or unfairly impacted by an earlier user with a lower share rate.", - "rule_broken_specs": "Pre-condition: The withdrawal queue contains requests from User A (shareRate 1) and User B (shareRate 2) within the same batch. Operation: finalize(...) with a finalization shareRate of 1.5. Expected post-condition: User A receives 1 ETH, User B receives 2 ETH (based on their entry rates). Actual vulnerability: The batch calculation leads to User A receiving 0.933 ETH and User B receiving 1.866 ETH. This causes User A to subsidize User B's withdrawal, violating the expected outcome of fair and independent request finalization." + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null }, { - "name": "claim", - "signature": "claim(uint256 _requestId, uint256 _priceIndexHint)", - "code": "function claim(uint256 _requestId, uint256 _priceIndexHint) external returns (address recipient) {\n // request must be finalized\n require(finalizedRequestsCounter > _requestId, \"REQUEST_NOT_FINALIZED\");\n\n Request storage request = queue[_requestId];\n require(!request.claimed, \"REQUEST_ALREADY_CLAIMED\");\n\n request.claimed = true;\n\n Price memory price;\n\n if (_isPriceHintValid(_requestId, _priceIndexHint)) {\n price = finalizationPrices[_priceIndexHint];\n } else {\n // unbounded loop branch. Can fail\n price = finalizationPrices[findPriceHint(_requestId)];\n }\n\n (uint128 etherToTransfer,) = _calculateDiscountedBatch(\n _requestId,\n _requestId,\n price.totalPooledEther,\n price.totalShares\n );\n lockedEtherAmount -= etherToTransfer;\n\n _sendValue(request.recipient, etherToTransfer);\n\n return request.recipient;\n}", - "comment": "Mark `_requestId` request as claimed and transfer reserved ether to recipient", + "name": "requestWithdrawalsWithPermit", + "signature": "requestWithdrawalsWithPermit(uint256[] calldata _amounts, address _owner, PermitInput calldata _permit) external returns (uint256[] memory requestIds)", + "code": "function requestWithdrawalsWithPermit(uint256[] calldata _amounts, address _owner, PermitInput calldata _permit) external returns (uint256[] memory requestIds) {\n STETH.permit(msg.sender, address(this), _permit.value, _permit.deadline, _permit.v, _permit.r, _permit.s);\n return requestWithdrawals(_amounts, _owner);\n}", + "comment": "Request withdrawals using stETH permit for approval", "visibility": "external", "modifiers": [], "parameters": [ { - "name": "_requestId", - "type": "uint256", - "description": "request id to claim" + "name": "_amounts", + "type": "uint256[]", + "description": "Array of stETH amounts" }, { - "name": "_priceIndexHint", - "type": "uint256", - "description": "price index found offchain that should be used for claiming" + "name": "_owner", + "type": "address", + "description": "Request owner" + }, + { + "name": "_permit", + "type": "PermitInput", + "description": "Permit signature data" } ], - "returns": "address recipient", - "output_property": "Allows a user to claim the ETH for a finalized withdrawal request. It marks the request as claimed, calculates the correct ETH amount to transfer based on the price at finalization, updates the lockedEtherAmount, and sends the ETH to the recipient. Reverts if the request is not finalized, already claimed, or if the ETH transfer fails.", + "returns": "uint256[] - Request IDs", + "output_property": "Calls stETH.permit to set allowance, then calls requestWithdrawals. Reverts if permit invalid.", "events": [], - "vulnerable": true, - "vulnerability_details": { - "issue": "Potential for incorrect ETH transfer due to price calculation", - "severity": "Critical", - "description": "The `claim` function relies on the `_calculateDiscountedBatch` function, which was part of the original discount factor logic. The flaw in the `finalize` function's discount factor calculation directly impacts the amount of ETH that a user can claim. If the finalization was processed with a flawed discount factor, the user will claim an incorrect amount of ETH, either losing funds or gaining more than entitled.", - "mitigation": "The underlying `finalize` logic was changed to remove the discount factor. This change ensures that the price data stored in `finalizationPrices` is correct, and therefore the amount calculated in `claim` is accurate." - }, - "rule_broken_english": "When a user claims a finalized withdrawal request, they should receive the exact amount of ETH that was locked for their request at the time of finalization, based on the protocol's share rate at that time.", - "rule_broken_specs": "Pre-condition: A request was finalized with a flawed discount factor, causing the lockedEtherAmount for the batch to be incorrectly calculated. Operation: claim(requestId, ...). Expected post-condition: The user receives the correct amount of ETH (e.g., 1 ETH for a 1:1 share rate). Actual vulnerability: The user receives an amount calculated by the flawed discount factor (e.g., 0.933 ETH), leading to a loss of funds due to the underlying issue in the finalization logic." + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null }, { - "name": "calculateFinalizationParams", - "signature": "calculateFinalizationParams(uint256 _lastIdToFinalize, uint256 _totalPooledEther, uint256 _totalShares)", - "code": "function calculateFinalizationParams(\n uint256 _lastIdToFinalize,\n uint256 _totalPooledEther,\n uint256 _totalShares\n) external view returns (uint256 etherToLock, uint256 sharesToBurn) {\n return _calculateDiscountedBatch(\n finalizedRequestsCounter,\n _lastIdToFinalize,\n _toUint128(_totalPooledEther),\n _toUint128(_totalShares)\n );\n}", - "comment": "calculates the params to fulfill the next batch of requests in queue", + "name": "requestWithdrawalsWstETHWithPermit", + "signature": "requestWithdrawalsWstETHWithPermit(uint256[] calldata _amounts, address _owner, PermitInput calldata _permit) external returns (uint256[] memory requestIds)", + "code": "function requestWithdrawalsWstETHWithPermit(uint256[] calldata _amounts, address _owner, PermitInput calldata _permit) external returns (uint256[] memory requestIds) {\n WSTETH.permit(msg.sender, address(this), _permit.value, _permit.deadline, _permit.v, _permit.r, _permit.s);\n return requestWithdrawalsWstETH(_amounts, _owner);\n}", + "comment": "Request withdrawals using wstETH permit", "visibility": "external", "modifiers": [], "parameters": [ { - "name": "_lastIdToFinalize", - "type": "uint256", - "description": "last id in the queue to finalize upon" + "name": "_amounts", + "type": "uint256[]", + "description": "Array of wstETH amounts" }, { - "name": "_totalPooledEther", - "type": "uint256", - "description": "share price component to finalize requests" + "name": "_owner", + "type": "address", + "description": "Request owner" }, { - "name": "_totalShares", - "type": "uint256", - "description": "share price component to finalize requests" + "name": "_permit", + "type": "PermitInput", + "description": "Permit signature" } ], - "returns": "uint256 etherToLock, uint256 sharesToBurn", - "output_property": "A view function that calculates the amount of ETH required to lock and the number of shares to burn for a given batch of requests. This is a helper function for oracles to prepare the `finalize` call.", + "returns": "uint256[] - Request IDs", + "output_property": "Calls wstETH.permit, then requestWithdrawalsWstETH.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "findPriceHint", - "signature": "findPriceHint(uint256 _requestId)", - "code": "function findPriceHint(uint256 _requestId) public view returns (uint256 hint) {\n require(_requestId < finalizedRequestsCounter, \"PRICE_NOT_FOUND\");\n\n for (uint256 i = finalizationPrices.length; i > 0; i--) {\n if (_isPriceHintValid(_requestId, i - 1)){\n return i - 1;\n }\n }\n assert(false);\n}", - "comment": "Finds the correct price index for a given request ID.", - "visibility": "public", + "name": "getWithdrawalRequests", + "signature": "getWithdrawalRequests(address _owner) external view returns (uint256[] memory requestsIds)", + "code": "function getWithdrawalRequests(address _owner) external view returns (uint256[] memory requestsIds) {\n return _getRequestsByOwner()[_owner].values();\n}", + "comment": "Returns all withdrawal request IDs belonging to an owner", + "visibility": "external", "modifiers": [], "parameters": [ { - "name": "_requestId", - "type": "uint256", - "description": "The request ID to find a price hint for." + "name": "_owner", + "type": "address", + "description": "Owner address" } ], - "returns": "uint256 hint", - "output_property": "Performs a linear search (from the end) to find the price index in `finalizationPrices` that covers the given request ID. Reverts with `PRICE_NOT_FOUND` if the request is not finalized or `assert(false)` if no price is found.", + "returns": "uint256[] - Array of request IDs", + "output_property": "View function returning the set of request IDs for the owner. May be expensive if many requests.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "restake", - "signature": "restake(uint256 _amount)", - "code": "function restake(uint256 _amount) external onlyOwner {\n require(lockedEtherAmount + _amount <= address(this).balance, \"NOT_ENOUGH_ETHER\");\n\n IRestakingSink(OWNER).receiveRestake{value: _amount}();\n}", - "comment": "Allows the owner to restake excess ETH from the withdrawal queue back to the Lido protocol.", + "name": "getWithdrawalStatus", + "signature": "getWithdrawalStatus(uint256[] calldata _requestIds) external view returns (WithdrawalRequestStatus[] memory statuses)", + "code": "function getWithdrawalStatus(uint256[] calldata _requestIds) external view returns (WithdrawalRequestStatus[] memory statuses) {\n statuses = new WithdrawalRequestStatus[](_requestIds.length);\n for (uint256 i = 0; i < _requestIds.length; ++i) {\n statuses[i] = _getStatus(_requestIds[i]);\n }\n}", + "comment": "Returns statuses for an array of request IDs", "visibility": "external", - "modifiers": [ - "onlyOwner" + "modifiers": [], + "parameters": [ + { + "name": "_requestIds", + "type": "uint256[]", + "description": "Array of request IDs" + } + ], + "returns": "WithdrawalRequestStatus[] - Array of status structs", + "output_property": "Iterates over request IDs and fetches each status. Reverts if any ID is invalid.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "getClaimableEther", + "signature": "getClaimableEther(uint256[] calldata _requestIds, uint256[] calldata _hints) external view returns (uint256[] memory claimableEthValues)", + "code": "function getClaimableEther(uint256[] calldata _requestIds, uint256[] calldata _hints) external view returns (uint256[] memory claimableEthValues) {\n claimableEthValues = new uint256[](_requestIds.length);\n for (uint256 i = 0; i < _requestIds.length; ++i) {\n claimableEthValues[i] = _getClaimableEther(_requestIds[i], _hints[i]);\n }\n}", + "comment": "Returns claimable ETH amounts for withdrawal requests", + "visibility": "external", + "modifiers": [], + "parameters": [ + { + "name": "_requestIds", + "type": "uint256[]", + "description": "Array of request IDs" + }, + { + "name": "_hints", + "type": "uint256[]", + "description": "Checkpoint hints for each ID" + } ], + "returns": "uint256[] - Array of claimable ETH amounts", + "output_property": "Calls _getClaimableEther for each request. Reverts if hints are invalid or request IDs out of range.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "claimWithdrawalsTo", + "signature": "claimWithdrawalsTo(uint256[] calldata _requestIds, uint256[] calldata _hints, address _recipient) external", + "code": "function claimWithdrawalsTo(uint256[] calldata _requestIds, uint256[] calldata _hints, address _recipient) external {\n if (_recipient == address(0)) revert ZeroRecipient();\n for (uint256 i = 0; i < _requestIds.length; ++i) {\n _claim(_requestIds[i], _hints[i], _recipient);\n _emitTransfer(msg.sender, address(0), _requestIds[i]);\n }\n}", + "comment": "Claims finalized withdrawal requests and sends ETH to recipient", + "visibility": "external", + "modifiers": [], "parameters": [ { - "name": "_amount", - "type": "uint256", - "description": "The amount of ETH to restake." + "name": "_requestIds", + "type": "uint256[]", + "description": "Request IDs to claim" + }, + { + "name": "_hints", + "type": "uint256[]", + "description": "Checkpoint hints" + }, + { + "name": "_recipient", + "type": "address", + "description": "Recipient of ETH" } ], "returns": "", - "output_property": "Transfers a specified amount of ETH from this contract to the Lido contract's `receiveRestake` function. Updates the lockedEtherAmount. Reverts if caller is not OWNER or if the contract balance is insufficient to cover the locked amount plus the restaking amount.", + "output_property": "Claims each request, transfers ETH to recipient, emits transfer event. Reverts if recipient zero, any request not owned by caller, not finalized, or already claimed.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "_calculateDiscountedBatch", - "signature": "_calculateDiscountedBatch(uint256 firstId, uint256 lastId, uint128 _totalPooledEther, uint128 _totalShares)", - "code": "function _calculateDiscountedBatch(\n uint256 firstId,\n uint256 lastId,\n uint128 _totalPooledEther,\n uint128 _totalShares\n) internal view returns (uint128 eth, uint128 shares) {\n eth = queue[lastId].cumulativeEther;\n shares = queue[lastId].cumulativeShares;\n\n if (firstId > 0) {\n eth -= queue[firstId - 1].cumulativeEther;\n shares -= queue[firstId - 1].cumulativeShares;\n }\n\n eth = _min(eth, shares * _totalPooledEther / _totalShares);\n}", - "comment": "Calculates the ETH and shares for a batch, applying a discount if the current share rate is lower than the average rate of the batch.", - "visibility": "internal", + "name": "claimWithdrawals", + "signature": "claimWithdrawals(uint256[] calldata _requestIds, uint256[] calldata _hints) external", + "code": "function claimWithdrawals(uint256[] calldata _requestIds, uint256[] calldata _hints) external {\n for (uint256 i = 0; i < _requestIds.length; ++i) {\n _claim(_requestIds[i], _hints[i], msg.sender);\n _emitTransfer(msg.sender, address(0), _requestIds[i]);\n }\n}", + "comment": "Claims finalized withdrawal requests to the caller", + "visibility": "external", "modifiers": [], "parameters": [ { - "name": "firstId", - "type": "uint256", - "description": "The first request ID in the batch." + "name": "_requestIds", + "type": "uint256[]", + "description": "Request IDs to claim" }, { - "name": "lastId", + "name": "_hints", + "type": "uint256[]", + "description": "Checkpoint hints" + } + ], + "returns": "", + "output_property": "Claims each request, sends ETH to msg.sender. Same revert conditions as claimWithdrawalsTo.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "claimWithdrawal", + "signature": "claimWithdrawal(uint256 _requestId) external", + "code": "function claimWithdrawal(uint256 _requestId) external {\n _claim(_requestId, _findCheckpointHint(_requestId, 1, getLastCheckpointIndex()), msg.sender);\n _emitTransfer(msg.sender, address(0), _requestId);\n}", + "comment": "Claims a single withdrawal request using linear search for hint", + "visibility": "external", + "modifiers": [], + "parameters": [ + { + "name": "_requestId", "type": "uint256", - "description": "The last request ID in the batch." + "description": "Request ID to claim" + } + ], + "returns": "", + "output_property": "Finds checkpoint hint via linear search (may OOG for large queues), then claims. Reverts if request not finalized or not owned.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "findCheckpointHints", + "signature": "findCheckpointHints(uint256[] calldata _requestIds, uint256 _firstIndex, uint256 _lastIndex) external view returns (uint256[] memory hintIds)", + "code": "function findCheckpointHints(uint256[] calldata _requestIds, uint256 _firstIndex, uint256 _lastIndex) external view returns (uint256[] memory hintIds) {\n hintIds = new uint256[](_requestIds.length);\n uint256 prevRequestId = 0;\n for (uint256 i = 0; i < _requestIds.length; ++i) {\n if (_requestIds[i] < prevRequestId) revert RequestIdsNotSorted();\n hintIds[i] = _findCheckpointHint(_requestIds[i], _firstIndex, _lastIndex);\n _firstIndex = hintIds[i];\n prevRequestId = _requestIds[i];\n }\n}", + "comment": "Finds checkpoint hints for a sorted array of request IDs", + "visibility": "external", + "modifiers": [], + "parameters": [ + { + "name": "_requestIds", + "type": "uint256[]", + "description": "Sorted array of request IDs" }, { - "name": "_totalPooledEther", - "type": "uint128", - "description": "Total pooled ether at finalization." + "name": "_firstIndex", + "type": "uint256", + "description": "Left boundary of search range" }, { - "name": "_totalShares", - "type": "uint128", - "description": "Total shares at finalization." + "name": "_lastIndex", + "type": "uint256", + "description": "Right boundary" } ], - "returns": "uint128 eth, uint128 shares", - "output_property": "Calculates the ETH and shares for a batch of requests. The ETH amount is capped by the product of shares and the current share rate. This function implements the discount factor logic that was identified as vulnerable.", + "returns": "uint256[] - Array of hints", + "output_property": "Validates sorting, finds hint for each ID. Reverts if IDs not sorted.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "_isPriceHintValid", - "signature": "_isPriceHintValid(uint256 _requestId, uint256 hint)", - "code": "function _isPriceHintValid(uint256 _requestId, uint256 hint) internal view returns (bool isInRange) {\n uint256 hintLastId = finalizationPrices[hint].index;\n\n isInRange = _requestId <= hintLastId;\n if (hint > 0) {\n uint256 previousId = finalizationPrices[hint - 1].index;\n\n isInRange = isInRange && previousId < _requestId;\n }\n}", - "comment": "Checks if a given price index hint is valid for a request ID.", - "visibility": "internal", + "name": "finalize", + "signature": "finalize(uint256[] calldata _batches, uint256 _maxShareRate) external payable", + "code": "function finalize(uint256[] calldata _batches, uint256 _maxShareRate) external payable {\n _checkResumed();\n _checkRole(FINALIZE_ROLE, msg.sender);\n _finalize(_batches, msg.value, _maxShareRate);\n}", + "comment": "Finalizes withdrawal requests from last finalized up to specified batches", + "visibility": "external", "modifiers": [], "parameters": [ { - "name": "_requestId", + "name": "_batches", + "type": "uint256[]", + "description": "Batch data for finalization" + }, + { + "name": "_maxShareRate", + "type": "uint256", + "description": "Maximum allowed share rate" + } + ], + "returns": "", + "output_property": "Checks resumed, caller has FINALIZE_ROLE, then calls _finalize with sent ETH. Vulnerable to discount factor miscalculation (Cr-01), batch locking funds (H-05), and incomplete share rate recovery (M-04).", + "events": [], + "vulnerable": false, + "vulnerability_details": { + "issue": "Withdrawal Finalization Affected by other users in batch (DiscountFactor issue)", + "severity": "Critical", + "description": "If two users in the same batch entered with different share rates and a slash occurs, the finalized amount is weighted incorrectly, causing unjust ETH losses for some users. A user can exit before a slashing report to lose less at the expense of others.", + "mitigation": "Fixed in commit 336b6f3. The withdrawals finalization process was changed to remove the discount factor in favor of share rate batch-wise calculation." + }, + "property": "Each withdrawal request should receive ETH proportional to its share rate at finalization, independent of other requests in the same batch.", + "property_specification": { + "precondition": "User A has request with shareRate RA, User B with shareRate RB, RA < RB, both in same batch. Slashing causes final shareRate S where RA < S < RB", + "Operation": "finalize(batch)", + "Expected post-condition": "A receives amount based on RA, B receives amount based on RB", + "Actual vulnerability": "Weighted average calculation causes A to receive less than expected and B to receive more, with A subsidizing B's losses." + } + }, + { + "name": "onOracleReport", + "signature": "onOracleReport(bool _isBunkerModeNow, uint256 _sinceTimestamp, uint256 _currentReportTimestamp) external", + "code": "function onOracleReport(bool _isBunkerModeNow, uint256 _sinceTimestamp, uint256 _currentReportTimestamp) external {\n _checkRole(ORACLE_ROLE, msg.sender);\n if (_sinceTimestamp >= block.timestamp) revert InvalidReportTimestamp();\n if (_currentReportTimestamp >= block.timestamp) revert InvalidReportTimestamp();\n _setLastReportTimestamp(_currentReportTimestamp);\n bool isBunkerModeWasSetBefore = isBunkerModeActive();\n if (_isBunkerModeNow != isBunkerModeWasSetBefore) {\n if (_isBunkerModeNow) {\n BUNKER_MODE_SINCE_TIMESTAMP_POSITION.setStorageUint256(_sinceTimestamp);\n emit BunkerModeEnabled(_sinceTimestamp);\n } else {\n BUNKER_MODE_SINCE_TIMESTAMP_POSITION.setStorageUint256(BUNKER_MODE_DISABLED_TIMESTAMP);\n emit BunkerModeDisabled();\n }\n }\n}", + "comment": "Updates bunker mode state and last report timestamp, callable by oracle", + "visibility": "external", + "modifiers": [], + "parameters": [ + { + "name": "_isBunkerModeNow", + "type": "bool", + "description": "Whether bunker mode is active" + }, + { + "name": "_sinceTimestamp", "type": "uint256", - "description": "The request ID." + "description": "Bunker mode start timestamp" }, { - "name": "hint", + "name": "_currentReportTimestamp", "type": "uint256", - "description": "The price index hint." + "description": "Current report timestamp" } ], - "returns": "bool isInRange", - "output_property": "Verifies that the provided price index hint covers the specified request ID (i.e., the request ID is between the last IDs of the previous price and this price).", + "returns": "", + "output_property": "Validates timestamps not in future, sets last report timestamp, updates bunker mode if changed. Reverts if caller lacks ORACLE_ROLE or timestamps invalid.", + "events": [ + "BunkerModeEnabled", + "BunkerModeDisabled" + ], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "isBunkerModeActive", + "signature": "isBunkerModeActive() public view returns (bool)", + "code": "function isBunkerModeActive() public view returns (bool) {\n return bunkerModeSinceTimestamp() < BUNKER_MODE_DISABLED_TIMESTAMP;\n}", + "comment": "Checks if bunker mode is active", + "visibility": "public", + "modifiers": [], + "parameters": [], + "returns": "bool - True if bunker mode active", + "output_property": "View function comparing stored timestamp with disabled sentinel.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "_updatePriceHistory", - "signature": "_updatePriceHistory(uint128 _totalPooledEther, uint128 _totalShares, uint256 index)", - "code": "function _updatePriceHistory(uint128 _totalPooledEther, uint128 _totalShares, uint256 index) internal {\n if (finalizationPrices.length == 0) {\n finalizationPrices.push(Price(_totalPooledEther, _totalShares, index));\n } else {\n Price storage lastPrice = finalizationPrices[finalizationPrices.length - 1];\n\n if (_totalPooledEther/_totalShares == lastPrice.totalPooledEther/lastPrice.totalShares) {\n lastPrice.index = index;\n } else {\n finalizationPrices.push(Price(_totalPooledEther, _totalShares, index));\n }\n }\n}", - "comment": "Updates the finalization price history. If the new share rate is the same as the last, it just updates the index; otherwise, it pushes a new price entry.", + "name": "bunkerModeSinceTimestamp", + "signature": "bunkerModeSinceTimestamp() public view returns (uint256)", + "code": "function bunkerModeSinceTimestamp() public view returns (uint256) {\n return BUNKER_MODE_SINCE_TIMESTAMP_POSITION.getStorageUint256();\n}", + "comment": "Returns bunker mode activation timestamp", + "visibility": "public", + "modifiers": [], + "parameters": [], + "returns": "uint256 - Timestamp or BUNKER_MODE_DISABLED_TIMESTAMP", + "output_property": "View function reading from storage position.", + "events": [], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null + }, + { + "name": "_emitTransfer", + "signature": "_emitTransfer(address from, address to, uint256 _requestId) internal virtual", + "code": "function _emitTransfer(address from, address to, uint256 _requestId) internal virtual;", + "comment": "Virtual function to emit ERC721 Transfer event", "visibility": "internal", "modifiers": [], "parameters": [ { - "name": "_totalPooledEther", - "type": "uint128", - "description": "Total pooled ether at finalization." + "name": "from", + "type": "address", + "description": "Sender" }, { - "name": "_totalShares", - "type": "uint128", - "description": "Total shares at finalization." + "name": "to", + "type": "address", + "description": "Recipient" }, { - "name": "index", + "name": "_requestId", "type": "uint256", - "description": "The last request ID that this price applies to." + "description": "Token ID" } ], "returns": "", - "output_property": "Manages the `finalizationPrices` array, adding a new price point or extending the last one if the share rate hasn't changed.", + "output_property": "Implemented in inheriting contract to emit ERC721 Transfer. No state changes.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null + }, + { + "name": "_initialize", + "signature": "_initialize(address _admin) internal", + "code": "function _initialize(address _admin) internal {\n _initializeQueue();\n _pauseFor(PAUSE_INFINITELY);\n _initializeContractVersionTo(1);\n _grantRole(DEFAULT_ADMIN_ROLE, _admin);\n BUNKER_MODE_SINCE_TIMESTAMP_POSITION.setStorageUint256(BUNKER_MODE_DISABLED_TIMESTAMP);\n emit InitializedV1(_admin);\n}", + "comment": "Internal initialization helper", + "visibility": "internal", + "modifiers": [], + "parameters": [ + { + "name": "_admin", + "type": "address", + "description": "Admin address" + } + ], + "returns": "", + "output_property": "Initializes queue, pauses contract infinitely, sets version to 1, grants admin role, disables bunker mode, emits event. No revert conditions beyond admin zero check in caller.", + "events": [ + "InitializedV1" + ], + "vulnerable": false, + "vulnerability_details": null, + "property": null, + "property_specification": null }, { - "name": "_min", - "signature": "_min(uint128 a, uint128 b)", - "code": "function _min(uint128 a, uint128 b) internal pure returns (uint128) {\n return a < b ? a : b;\n}", - "comment": "Returns the minimum of two uint128 numbers.", + "name": "_requestWithdrawal", + "signature": "_requestWithdrawal(uint256 _amountOfStETH, address _owner) internal returns (uint256 requestId)", + "code": "function _requestWithdrawal(uint256 _amountOfStETH, address _owner) internal returns (uint256 requestId) {\n STETH.transferFrom(msg.sender, address(this), _amountOfStETH);\n uint256 amountOfShares = STETH.getSharesByPooledEth(_amountOfStETH);\n requestId = _enqueue(uint128(_amountOfStETH), uint128(amountOfShares), _owner);\n _emitTransfer(address(0), _owner, requestId);\n}", + "comment": "Internal function to create stETH withdrawal request", "visibility": "internal", "modifiers": [], "parameters": [ { - "name": "a", - "type": "uint128", - "description": "First number" + "name": "_amountOfStETH", + "type": "uint256", + "description": "Amount of stETH" }, { - "name": "b", - "type": "uint128", - "description": "Second number" + "name": "_owner", + "type": "address", + "description": "Request owner" } ], - "returns": "uint128", - "output_property": "A simple min function.", + "returns": "uint256 - New request ID", + "output_property": "Transfers stETH, calculates shares, enqueues request, emits transfer. Reverts if transfer fails or amount invalid.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "_sendValue", - "signature": "_sendValue(address payable recipient, uint256 amount)", - "code": "function _sendValue(address payable recipient, uint256 amount) internal {\n require(address(this).balance >= amount, \"Address: insufficient balance\");\n\n // solhint-disable-next-line\n (bool success, ) = recipient.call{value: amount}(\"\");\n require(success, \"Address: unable to send value, recipient may have reverted\");\n}", - "comment": "Sends ETH to a recipient address, handling call failures.", + "name": "_requestWithdrawalWstETH", + "signature": "_requestWithdrawalWstETH(uint256 _amountOfWstETH, address _owner) internal returns (uint256 requestId)", + "code": "function _requestWithdrawalWstETH(uint256 _amountOfWstETH, address _owner) internal returns (uint256 requestId) {\n WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH);\n uint256 amountOfStETH = WSTETH.unwrap(_amountOfWstETH);\n _checkWithdrawalRequestAmount(amountOfStETH);\n uint256 amountOfShares = STETH.getSharesByPooledEth(amountOfStETH);\n requestId = _enqueue(uint128(amountOfStETH), uint128(amountOfShares), _owner);\n _emitTransfer(address(0), _owner, requestId);\n}", + "comment": "Internal function to create wstETH withdrawal request", "visibility": "internal", "modifiers": [], "parameters": [ { - "name": "recipient", - "type": "address payable", - "description": "The address to send ETH to." + "name": "_amountOfWstETH", + "type": "uint256", + "description": "Amount of wstETH" }, { - "name": "amount", - "type": "uint256", - "description": "The amount of ETH to send." + "name": "_owner", + "type": "address", + "description": "Request owner" } ], - "returns": "", - "output_property": "Transfers ETH to a recipient using a low-level call. Reverts if the contract balance is insufficient or if the call fails.", + "returns": "uint256 - New request ID", + "output_property": "Transfers wstETH, unwraps to stETH, validates amount, calculates shares, enqueues, emits transfer.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "_toUint64", - "signature": "_toUint64(uint256 value)", - "code": "function _toUint64(uint256 value) internal pure returns (uint64) {\n require(value <= type(uint64).max, \"SafeCast: value doesn't fit in 96 bits\");\n return uint64(value);\n}", - "comment": "Safely casts a uint256 to uint64.", + "name": "_checkWithdrawalRequestAmount", + "signature": "_checkWithdrawalRequestAmount(uint256 _amountOfStETH) internal pure", + "code": "function _checkWithdrawalRequestAmount(uint256 _amountOfStETH) internal pure {\n if (_amountOfStETH < MIN_STETH_WITHDRAWAL_AMOUNT) {\n revert RequestAmountTooSmall(_amountOfStETH);\n }\n if (_amountOfStETH > MAX_STETH_WITHDRAWAL_AMOUNT) {\n revert RequestAmountTooLarge(_amountOfStETH);\n }\n}", + "comment": "Validates withdrawal request amount against min and max", "visibility": "internal", "modifiers": [], "parameters": [ { - "name": "value", + "name": "_amountOfStETH", "type": "uint256", - "description": "The value to cast." + "description": "Amount to validate" } ], - "returns": "uint64", - "output_property": "Casts a uint256 to uint64, reverting if the value exceeds the maximum uint64.", + "returns": "", + "output_property": "Pure function that reverts if amount < 100 or > 1000 ETH.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null }, { - "name": "_toUint128", - "signature": "_toUint128(uint256 value)", - "code": "function _toUint128(uint256 value) internal pure returns (uint128) {\n require(value <= type(uint128).max, \"SafeCast: value doesn't fit in 128 bits\");\n return uint128(value);\n}", - "comment": "Safely casts a uint256 to uint128.", + "name": "_getClaimableEther", + "signature": "_getClaimableEther(uint256 _requestId, uint256 _hint) internal view returns (uint256)", + "code": "function _getClaimableEther(uint256 _requestId, uint256 _hint) internal view returns (uint256) {\n if (_requestId == 0 || _requestId > getLastRequestId()) revert InvalidRequestId(_requestId);\n if (_requestId > getLastFinalizedRequestId()) return 0;\n WithdrawalRequest storage request = _getQueue()[_requestId];\n if (request.claimed) return 0;\n return _calculateClaimableEther(request, _requestId, _hint);\n}", + "comment": "Returns claimable ether for a request using hint", "visibility": "internal", "modifiers": [], "parameters": [ { - "name": "value", + "name": "_requestId", "type": "uint256", - "description": "The value to cast." + "description": "Request ID" + }, + { + "name": "_hint", + "type": "uint256", + "description": "Checkpoint hint" } ], - "returns": "uint128", - "output_property": "Casts a uint256 to uint128, reverting if the value exceeds the maximum uint128.", + "returns": "uint256 - Claimable ETH amount", + "output_property": "Checks request validity, finalization, claimed status, then calculates claimable amount. Returns 0 if not finalized or already claimed.", "events": [], "vulnerable": false, "vulnerability_details": null, - "rule_broken_english": null, - "rule_broken_specs": null + "property": null, + "property_specification": null } ], "structs": [ { - "name": "Request", - "definition": "struct Request {\n uint128 cumulativeEther;\n uint128 cumulativeShares;\n address payable recipient;\n uint64 requestBlockNumber;\n bool claimed;\n}", - "description": "structure representing a request for withdrawal." - }, - { - "name": "Price", - "definition": "struct Price {\n uint128 totalPooledEther;\n uint128 totalShares;\n uint256 index;\n}", - "description": "structure representing share price for some range in request queue. price is stored as a pair of value that should be divided later" + "name": "PermitInput", + "definition": "struct PermitInput { uint256 value; uint256 deadline; uint8 v; bytes32 r; bytes32 s; }", + "description": "EIP-2612 permit signature parameters" } ], "modifiers": [ { - "name": "onlyOwner", - "definition": "require(msg.sender == OWNER, \"NOT_OWNER\");", - "purpose": "Restricts function access to the contract owner (Lido)." + "name": "onlyRole", + "definition": "Inherited from AccessControl: require(hasRole(role, msg.sender), 'AccessControl:...')", + "purpose": "Restricts function access to accounts with a specific role" } ], - "inheritance": [], + "inheritance": [ + "AccessControlEnumerable", + "PausableUntil", + "WithdrawalQueueBase", + "Versioned" + ], "call_graph": { - "constructor": [], - "queueLength": [], - "enqueue": [], + "constructor": [ + "WSTETH.stETH()" + ], + "initialize": [ + "_initialize" + ], + "resume": [ + "_checkPaused", + "_checkRole", + "_resume" + ], + "pauseFor": [ + "_pauseFor" + ], + "pauseUntil": [ + "_pauseUntil" + ], + "requestWithdrawals": [ + "_checkResumed", + "_checkWithdrawalRequestAmount", + "_requestWithdrawal" + ], + "requestWithdrawalsWstETH": [ + "_checkResumed", + "_requestWithdrawalWstETH" + ], + "requestWithdrawalsWithPermit": [ + "STETH.permit", + "requestWithdrawals" + ], + "requestWithdrawalsWstETHWithPermit": [ + "WSTETH.permit", + "requestWithdrawalsWstETH" + ], + "getWithdrawalRequests": [ + "_getRequestsByOwner" + ], + "getWithdrawalStatus": [ + "_getStatus" + ], + "getClaimableEther": [ + "_getClaimableEther" + ], + "claimWithdrawalsTo": [ + "_claim", + "_emitTransfer" + ], + "claimWithdrawals": [ + "_claim", + "_emitTransfer" + ], + "claimWithdrawal": [ + "_findCheckpointHint", + "_claim", + "_emitTransfer" + ], + "findCheckpointHints": [ + "_findCheckpointHint" + ], "finalize": [ - "_toUint128", - "_updatePriceHistory", - "_toUint128" - ], - "claim": [ - "findPriceHint", - "_isPriceHintValid", - "_calculateDiscountedBatch", - "_sendValue" - ], - "calculateFinalizationParams": [ - "_calculateDiscountedBatch", - "_toUint128", - "_toUint128" - ], - "findPriceHint": [ - "_isPriceHintValid" - ], - "restake": [], - "_calculateDiscountedBatch": [ - "_min" - ], - "_isPriceHintValid": [], - "_updatePriceHistory": [], - "_min": [], - "_sendValue": [], - "_toUint64": [], - "_toUint128": [] + "_checkResumed", + "_checkRole", + "_finalize" + ], + "onOracleReport": [ + "_checkRole", + "_setLastReportTimestamp", + "isBunkerModeActive", + "bunkerModeSinceTimestamp" + ], + "isBunkerModeActive": [ + "bunkerModeSinceTimestamp" + ], + "bunkerModeSinceTimestamp": [], + "_emitTransfer": [], + "_initialize": [ + "_initializeQueue", + "_pauseFor", + "_initializeContractVersionTo", + "_grantRole" + ], + "_requestWithdrawal": [ + "STETH.transferFrom", + "STETH.getSharesByPooledEth", + "_enqueue", + "_emitTransfer" + ], + "_requestWithdrawalWstETH": [ + "WSTETH.transferFrom", + "WSTETH.unwrap", + "_checkWithdrawalRequestAmount", + "STETH.getSharesByPooledEth", + "_enqueue", + "_emitTransfer" + ], + "_checkWithdrawalRequestAmount": [], + "_getClaimableEther": [ + "getLastRequestId", + "getLastFinalizedRequestId", + "_getQueue", + "_calculateClaimableEther" + ] }, "audit_issues": [ { "function": "finalize", "issue": "Withdrawal Finalization Affected by other users in batch (DiscountFactor issue)", "severity": "Critical", - "description": "If two users in the same batch entered the queue with different share rates, and later there is a slash (the finalization share rate has decreased for one of them, but still above the others), the finalized amount will be weighted incorrectly. This could unjustifiably lead to ETH losses for users.", + "description": "If two users in the same batch entered with different share rates and a slash occurs, the finalized amount is weighted incorrectly, causing unjust ETH losses for some users. A user can exit before a slashing report to lose less at the expense of others.", "status": "Fixed", - "mitigation": "Fixed in commit 336b6f3. The withdrawals finalization process was changed profoundly to remove the discount factor in favor of the share rate batch-wise calculation approach.", - "rule_broken_english": "When multiple withdrawal requests are finalized in a single batch, each request should receive an amount of ETH proportional to the share rate at the time of its creation. If users in the batch have different entry share rates, a later user with a higher share rate should not have their withdrawal amount subsidized or unfairly impacted by an earlier user with a lower share rate.", - "rule_broken_specs": "Pre-condition: The withdrawal queue contains requests from User A (shareRate 1) and User B (shareRate 2) within the same batch. Operation: finalize(...) with a finalization shareRate of 1.5. Expected post-condition: User A receives 1 ETH, User B receives 2 ETH (based on their entry rates). Actual vulnerability: The batch calculation leads to User A receiving 0.933 ETH and User B receiving 1.866 ETH. This causes User A to subsidize User B's withdrawal, violating the expected outcome of fair and independent request finalization." + "mitigation": "Fixed in commit 336b6f3. The withdrawals finalization process was changed to remove the discount factor in favor of share rate batch-wise calculation approach.", + "property": "Each withdrawal request should receive ETH proportional to its share rate at finalization, independent of other requests in the same batch.", + "property_specification": "precondition: User A has request with shareRate RA, User B with shareRate RB, RA < RB, both in same batch. Slashing causes final shareRate S where RA < S < RB. Operation: finalize(batch). Expected post-condition: A receives amount based on RA, B receives amount based on RB. Actual vulnerability: Weighted average calculation causes A to receive less than expected and B to receive more, with A subsidizing B's losses." }, { - "function": "claim", - "issue": "Potential for incorrect ETH transfer due to price calculation", - "severity": "Critical", - "description": "The `claim` function relies on the `_calculateDiscountedBatch` function, which was part of the original discount factor logic. The flaw in the `finalize` function's discount factor calculation directly impacts the amount of ETH that a user can claim. If the finalization was processed with a flawed discount factor, the user will claim an incorrect amount of ETH, either losing funds or gaining more than entitled.", - "status": "Fixed", - "mitigation": "The underlying `finalize` logic was changed to remove the discount factor. This change ensures that the price data stored in `finalizationPrices` is correct, and therefore the amount calculated in `claim` is accurate.", - "rule_broken_english": "When a user claims a finalized withdrawal request, they should receive the exact amount of ETH that was locked for their request at the time of finalization, based on the protocol's share rate at that time.", - "rule_broken_specs": "Pre-condition: A request was finalized with a flawed discount factor, causing the lockedEtherAmount for the batch to be incorrectly calculated. Operation: claim(requestId, ...). Expected post-condition: The user receives the correct amount of ETH (e.g., 1 ETH for a 1:1 share rate). Actual vulnerability: The user receives an amount calculated by the flawed discount factor (e.g., 0.933 ETH), leading to a loss of funds due to the underlying issue in the finalization logic." + "function": "finalize", + "issue": "Wrong batching in the oracle report could permanently lock funds in the withdrawalQueue contract", + "severity": "High", + "description": "If oracles put requests in the same batch that should not be batched together, the finalization may send more ETH than needed, locking excess funds permanently in the withdrawalQueue contract.", + "status": "Acknowledged", + "mitigation": "The contract is upgradeable; locked funds can be recovered via DAO vote by upgrading the implementation. Also strict on-chain batch validation was considered too complex.", + "property": "The total ETH sent to the withdrawalQueue for finalization must exactly match the sum of claimable amounts for all requests in the batch.", + "property_specification": "precondition: Two requests with different share rates are placed in the same batch. Operation: finalize(batch) with ETH amount calculated based on incorrect batching. Expected post-condition: All claimable amounts sum to the ETH sent. Actual vulnerability: More ETH is sent than can be claimed, leaving residual ETH permanently locked in the contract." + }, + { + "function": "finalize", + "issue": "Finalization shareRate recovery wouldn't fully recover", + "severity": "Medium", + "description": "When a recovery occurs due to users in the queue with lower rates, the recovering user gets a slightly lower rate than if calculated in separate batches. This contradicts documentation that users fully recover with the protocol.", + "status": "Acknowledged", + "mitigation": "Accepted as intended behavior; edge cases are rare and deviations are tiny in real scenarios. Complex iterative approach would introduce more risks.", + "property": "Users who entered the queue with higher share rates should fully recover when the protocol's share rate recovers, regardless of batching.", + "property_specification": "precondition: User A (shareRate 1.0) and User B (shareRate 2.0) are in same batch. Final shareRate after recovery is 1.5. Operation: finalize(batch). Expected post-condition: User B receives 1.666 ETH (as if finalized separately). Actual vulnerability: User B receives only 1.5 ETH, losing value compared to separate batch finalization." } ], - "events": [] + "events": [ + { + "name": "InitializedV1", + "parameters": "address _admin", + "description": "Emitted when the contract is initialized" + }, + { + "name": "BunkerModeEnabled", + "parameters": "uint256 _sinceTimestamp", + "description": "Emitted when bunker mode is enabled" + }, + { + "name": "BunkerModeDisabled", + "parameters": "", + "description": "Emitted when bunker mode is disabled" + } + ] } ] \ No newline at end of file