Platform: Code4rena
Start Date: 18/05/2023
Pot Size: $24,500 USDC
Total HM: 3
Participants: 72
Period: 4 days
Judge: LSDan
Id: 237
League: ETH
Rank: 5/72
Findings: 3
Award: $968.37
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0xRobocop, 0xnacho, HHK, SpicyMeatball, max10afternoon, rbserver
630.4612 USDC - $630.46
https://github.com/code-423n4/2023-05-juicebox/blob/main/juice-buyback/contracts/JBXBuybackDelegate.sol#L144-L171 https://github.com/code-423n4/2023-05-juicebox/blob/main/juice-buyback/contracts/JBXBuybackDelegate.sol#L183-L209 https://github.com/code-423n4/2023-05-juicebox/blob/main/juice-buyback/contracts/JBXBuybackDelegate.sol#L334-L353
Calling the following JBPayoutRedemptionPaymentTerminal3_1._pay
function executes (_fundingCycle, _tokenCount, _delegateAllocations, _memo) = store.recordPaymentFrom(_payer, _bundledAmount, _projectId, baseWeightCurrency, _beneficiary, _memo, _metadata)
.
File: node_modules\@jbx-protocol\juice-contracts-v3\contracts\abstract\JBPayoutRedemptionPaymentTerminal3_1.sol 1444: function _pay( 1445: uint256 _amount, 1446: address _payer, 1447: uint256 _projectId, 1448: address _beneficiary, 1449: uint256 _minReturnedTokens, 1450: bool _preferClaimedTokens, 1451: string memory _memo, 1452: bytes memory _metadata 1453: ) internal returns (uint256 beneficiaryTokenCount) { ... 1469: // Record the payment. 1470: (_fundingCycle, _tokenCount, _delegateAllocations, _memo) = store.recordPaymentFrom( 1471: _payer, 1472: _bundledAmount, 1473: _projectId, 1474: baseWeightCurrency, 1475: _beneficiary, 1476: _memo, 1477: _metadata 1478: ); 1479: 1480: // Mint the tokens if needed. 1481: if (_tokenCount > 0) 1482: // Set token count to be the number of tokens minted for the beneficiary instead of the total amount. 1483: beneficiaryTokenCount = IJBController(directory.controllerOf(_projectId)).mintTokensOf( 1484: _projectId, 1485: _tokenCount, 1486: _beneficiary, 1487: '', 1488: _preferClaimedTokens, 1489: true 1490: ); 1491: 1492: // The token count for the beneficiary must be greater than or equal to the minimum expected. 1493: if (beneficiaryTokenCount < _minReturnedTokens) revert INADEQUATE_TOKEN_COUNT(); 1494: 1495: // If delegate allocations were specified by the data source, fulfill them. 1496: if (_delegateAllocations.length != 0) { ... 1513: // Get a reference to the number of delegates to allocate to. 1514: uint256 _numDelegates = _delegateAllocations.length; 1515: 1516: for (uint256 _i; _i < _numDelegates; ) { ... 1523: // Keep track of the msg.value to use in the delegate call 1524: uint256 _payableValue; 1525: 1526: // If this terminal's token is ETH, send it in msg.value. 1527: if (token == JBTokens.ETH) _payableValue = _delegateAllocation.amount; ... 1532: _delegateAllocation.delegate.didPay{value: _payableValue}(_data); ... 1541: unchecked { 1542: ++_i; 1543: } 1544: } 1545: } 1546: } ... 1560: }
Then, calling the following JBSingleTokenPaymentTerminalStore3_1.recordPaymentFrom
function executes (_weight, memo, delegateAllocations) = IJBFundingCycleDataSource(fundingCycle.dataSource()).payParams(_data)
.
File: node_modules\@jbx-protocol\juice-contracts-v3\contracts\JBSingleTokenPaymentTerminalStore3_1.sol 323: function recordPaymentFrom( 324: address _payer, 325: JBTokenAmount calldata _amount, 326: uint256 _projectId, 327: uint256 _baseWeightCurrency, 328: address _beneficiary, 329: string calldata _memo, 330: bytes memory _metadata 331: ) 332: external 333: override 334: nonReentrant 335: returns ( 336: JBFundingCycle memory fundingCycle, 337: uint256 tokenCount, 338: JBPayDelegateAllocation[] memory delegateAllocations, 339: string memory memo 340: ) 341: { ... 351: // The weight according to which new token supply is to be minted, as a fixed point number with 18 decimals. 352: uint256 _weight; 353: 354: // If the funding cycle has configured a data source, use it to derive a weight and memo. 355: if (fundingCycle.useDataSourceForPay() && fundingCycle.dataSource() != address(0)) { 356: // Create the params that'll be sent to the data source. 357: JBPayParamsData memory _data = JBPayParamsData( 358: IJBSingleTokenPaymentTerminal(msg.sender), 359: _payer, 360: _amount, 361: _projectId, 362: fundingCycle.configuration, 363: _beneficiary, 364: fundingCycle.weight, 365: fundingCycle.reservedRate(), 366: _memo, 367: _metadata 368: ); 369: (_weight, memo, delegateAllocations) = IJBFundingCycleDataSource(fundingCycle.dataSource()) 370: .payParams(_data); 371: } 372: // Otherwise use the funding cycle's weight 373: else { ... 376: } ... 414: // If there's no weight, token count must be 0 so there's nothing left to do. 415: if (_weight == 0) return (fundingCycle, 0, delegateAllocations, memo); ... 428: }
If _tokenCount < _quote - (_quote * _slippage / SLIPPAGE_DENOMINATOR)
is true, calling the following JBXBuybackDelegate.payParams
function would return a 0 weight
. When _weight == 0
is true in the JBSingleTokenPaymentTerminalStore3_1.recordPaymentFrom
function, it would return a 0 tokenCount
. Back in the JBPayoutRedemptionPaymentTerminal3_1._pay
function, when _tokenCount
is 0, beneficiaryTokenCount
would remain 0 that is its default value, and executing if (beneficiaryTokenCount < _minReturnedTokens) revert INADEQUATE_TOKEN_COUNT()
can revert when the _minReturnedTokens
input is positive. Thus, in order ot make such transaction to go through, the user who calls the JBPayoutRedemptionPaymentTerminal3_1.pay
function that further calls the JBPayoutRedemptionPaymentTerminal3_1._pay
function must input _minReturnedTokens
as 0 in this situation.
function payParams(JBPayParamsData calldata _data) external override returns (uint256 weight, string memory memo, JBPayDelegateAllocation[] memory delegateAllocations) { // Find the total number of tokens to mint, as a fixed point number with 18 decimals uint256 _tokenCount = PRBMath.mulDiv(_data.amount.value, _data.weight, 10 ** 18); // Unpack the quote from the pool, given by the frontend (,, uint256 _quote, uint256 _slippage) = abi.decode(_data.metadata, (bytes32, bytes32, uint256, uint256)); // If the amount swapped is bigger than the lowest received when minting, use the swap pathway if (_tokenCount < _quote - (_quote * _slippage / SLIPPAGE_DENOMINATOR)) { // Pass the quote and reserve rate via a mutex mintedAmount = _tokenCount; reservedRate = _data.reservedRate; // Return this delegate as the one to use, and do not mint from the terminal delegateAllocations = new JBPayDelegateAllocation[](1); delegateAllocations[0] = JBPayDelegateAllocation({delegate: IJBPayDelegate(this), amount: _data.amount.value}); return (0, _data.memo, delegateAllocations); } ... }
Furthermore, when the JBPayoutRedemptionPaymentTerminal3_1._pay
function executes _delegateAllocation.delegate.didPay{value: _payableValue}(_data)
, the following JBXBuybackDelegate.didPay
function can execute _mint(_data, _tokenCount)
if the swap fails or _data.preferClaimedTokens
is false. However, when calling the JBXBuybackDelegate._mint
function below, the return value of the controller.mintTokensOf
function call is not used to check against a value that is similar to _minReturnedTokens
, which is unlike if (beneficiaryTokenCount < _minReturnedTokens) revert INADEQUATE_TOKEN_COUNT()
, where beneficiaryTokenCount
is the return value of the IJBController(directory.controllerOf(_projectId)).mintTokensOf
function call, in the JBPayoutRedemptionPaymentTerminal3_1._pay
function. As a result, the amount of project token minted to the corresponding beneficiary is not checked against an expected minimum number of project tokens to be minted to such beneficiary. Because of such lack of slippage control, the actual amount of project token minted to the beneficiary can be lower than expected, such as when reservedRate
is unexpectedly changed for the current funding cycle.
function didPay(JBDidPayData calldata _data) external payable override { ... // Retrieve the number of token created if minting and reset the mutex (not exposed in JBDidPayData) uint256 _tokenCount = mintedAmount; mintedAmount = 1; // Retrieve the fc reserved rate and reset the mutex uint256 _reservedRate = reservedRate; reservedRate = 1; // The minimum amount of token received if swapping (,, uint256 _quote, uint256 _slippage) = abi.decode(_data.metadata, (bytes32, bytes32, uint256, uint256)); uint256 _minimumReceivedFromSwap = _quote - (_quote * _slippage / SLIPPAGE_DENOMINATOR); // Pick the appropriate pathway (swap vs mint), use mint if non-claimed prefered if (_data.preferClaimedTokens) { // Try swapping uint256 _amountReceived = _swap(_data, _minimumReceivedFromSwap, _reservedRate); // If swap failed, mint instead, with the original weight + add to balance the token in if (_amountReceived == 0) _mint(_data, _tokenCount); } else { _mint(_data, _tokenCount); } }
function _mint(JBDidPayData calldata _data, uint256 _amount) internal { IJBController controller = IJBController(jbxTerminal.directory().controllerOf(_data.projectId)); // Mint to the beneficiary with the fc reserve rate controller.mintTokensOf({ _projectId: _data.projectId, _tokenCount: _amount, _beneficiary: _data.beneficiary, _memo: _data.memo, _preferClaimedTokens: _data.preferClaimedTokens, _useReservedRate: true }); ... }
The following steps can occur for the described scenario.
JBPayoutRedemptionPaymentTerminal3_1.pay
function, which further calls the JBPayoutRedemptionPaymentTerminal3_1._pay
function, to pay some ETH._tokenCount < _quote - (_quote * _slippage / SLIPPAGE_DENOMINATOR)
in the JBXBuybackDelegate.payParams
function would be true for her transaction, the swap pathway could be used._minReturnedTokens
input for the JBPayoutRedemptionPaymentTerminal3_1.pay
function call.JBXBuybackDelegate._mint
function is called._minReturnedTokens
input is 0 when calling the JBPayoutRedemptionPaymentTerminal3_1.pay
function and the amount of project token minted to her beneficiary by the JBXBuybackDelegate._mint
function is not checked against an expected minimum number of project tokens to be minted to such beneficiary, an unexpected change of reservedRate
for the current funding cycle can cause the actual amount of project token minted to her beneficiary to be lower than expected.VSCode
The JBPayoutRedemptionPaymentTerminal3_1._pay
function can be updated to execute if (beneficiaryTokenCount < _minReturnedTokens) revert INADEQUATE_TOKEN_COUNT()
only when _tokenCount > 0
is true and pass _minReturnedTokens
to the JBXBuybackDelegate.didPay
function. Then, the JBXBuybackDelegate.didPay
and JBXBuybackDelegate._mint
functions can be updated to be able to receive _minReturnedTokens
. Moreover, the JBXBuybackDelegate._mint
function can be updated to revert if the return value of the controller.mintTokensOf
function call is less than _minReturnedTokens
.
Invalid Validation
#0 - c4-pre-sort
2023-05-25T13:47:50Z
dmvt marked the issue as duplicate of #36
#1 - c4-judge
2023-06-02T14:22:58Z
dmvt marked the issue as satisfactory
🌟 Selected for report: minhquanym
Also found by: 0xStalin, BLACK-PANDA-REACH, Madalad, T1MOH, Udsen, adriro, max10afternoon, rbserver, sces60107
321.7244 USDC - $321.72
https://github.com/code-423n4/2023-05-juicebox/blob/main/juice-buyback/contracts/JBXBuybackDelegate.sol#L183-L209 https://github.com/code-423n4/2023-05-juicebox/blob/main/juice-buyback/contracts/JBXBuybackDelegate.sol#L258-L326 https://github.com/code-423n4/2023-05-juicebox/blob/main/juice-buyback/contracts/JBXBuybackDelegate.sol#L216-L233
When calling the following JBPayoutRedemptionPaymentTerminal3_1._pay
function, if _delegateAllocations.length != 0
is true, _delegateAllocation.delegate.didPay{value: _payableValue}(_data)
can be executed, which sends _delegateAllocation.amount
as msg.value
to the JBXBuybackDelegate.didPay
function below when the terminal's token is ETH.
File: node_modules\@jbx-protocol\juice-contracts-v3\contracts\abstract\JBPayoutRedemptionPaymentTerminal3_1.sol 1444: function _pay( 1445: uint256 _amount, 1446: address _payer, 1447: uint256 _projectId, 1448: address _beneficiary, 1449: uint256 _minReturnedTokens, 1450: bool _preferClaimedTokens, 1451: string memory _memo, 1452: bytes memory _metadata 1453: ) internal returns (uint256 beneficiaryTokenCount) { ... 1495: // If delegate allocations were specified by the data source, fulfill them. 1496: if (_delegateAllocations.length != 0) { ... 1513: // Get a reference to the number of delegates to allocate to. 1514: uint256 _numDelegates = _delegateAllocations.length; 1515: 1516: for (uint256 _i; _i < _numDelegates; ) { 1517: // Get a reference to the delegate being iterated on. 1518: JBPayDelegateAllocation memory _delegateAllocation = _delegateAllocations[_i]; 1519: 1520: // Trigger any inherited pre-transfer logic. 1521: _beforeTransferTo(address(_delegateAllocation.delegate), _delegateAllocation.amount); 1522: 1523: // Keep track of the msg.value to use in the delegate call 1524: uint256 _payableValue; 1525: 1526: // If this terminal's token is ETH, send it in msg.value. 1527: if (token == JBTokens.ETH) _payableValue = _delegateAllocation.amount; ... 1532: _delegateAllocation.delegate.didPay{value: _payableValue}(_data); ... 1541: unchecked { 1542: ++_i; 1543: } 1544: } 1545: } 1546: } ... 1560: }
Then, calling the following JBXBuybackDelegate.didPay
function calls the JBXBuybackDelegate._swap
function, which further calls the JBXBuybackDelegate.uniswapV3SwapCallback
function below when calling pool.swap(...)
.
function didPay(JBDidPayData calldata _data) external payable override { ... // Pick the appropriate pathway (swap vs mint), use mint if non-claimed prefered if (_data.preferClaimedTokens) { // Try swapping uint256 _amountReceived = _swap(_data, _minimumReceivedFromSwap, _reservedRate); ... } else { ... } }
function _swap(JBDidPayData calldata _data, uint256 _minimumReceivedFromSwap, uint256 _reservedRate) internal returns (uint256 _amountReceived) { // Pass the token and min amount to receive as extra data try pool.swap({ recipient: address(this), zeroForOne: !_projectTokenIsZero, amountSpecified: int256(_data.amount.value), sqrtPriceLimitX96: _projectTokenIsZero ? TickMath.MAX_SQRT_RATIO - 1 : TickMath.MIN_SQRT_RATIO + 1, data: abi.encode(_minimumReceivedFromSwap) }) returns (int256 amount0, int256 amount1) { // Swap succeded, take note of the amount of projectToken received (negative as it is an exact input) _amountReceived = uint256(-(_projectTokenIsZero ? amount0 : amount1)); } catch { // implies _amountReceived = 0 -> will later mint when back in didPay return _amountReceived; } ... }
In the following the JBXBuybackDelegate.uniswapV3SwapCallback
function, weth.deposit{value: _amountToSend}()
is executed, where _amountToSend
is uint256(_projectTokenIsZero ? amount1Delta : amount0Delta)
. When the liquidity of the corresponding pool is not sufficient enough, it is possible that partial fill occurs; in this case, _amountToSend
, which is amount1Delta
or amount0Delta
provided by the UniswapV3Pool.swap
function, would be smaller than int256(_data.amount.value)
that is used as amountSpecified
for calling pool.swap(...)
in the JBXBuybackDelegate._swap
function. Since _data.amount.value
is same as _delegateAllocation.amount
that is used as msg.value
for calling the JBXBuybackDelegate.didPay
function in the JBPayoutRedemptionPaymentTerminal3_1._pay
function, the difference between _delegateAllocation.amount
and _amountToSend
is not sent to the corresponding pool and is locked in the JBXBuybackDelegate
contract; hence, the user who pays _delegateAllocation.amount
ETH loses such difference. As shown in https://info.uniswap.org/#/pools/0x48598ff1cee7b4d31f8f9050c2bbae98e17e6b17 and https://info.uniswap.org/#/pools/0x632881d9f6231bee100cf7d060cc27d86b2a5cdb, the TVLs for the JBX / ETH 1% and 0.3% pools are small so these pools' liquidity can be insufficient and the described situation can happen.
function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external override { // Check if this is really a callback if (msg.sender != address(pool)) revert JuiceBuyback_Unauthorized(); // Unpack the data (uint256 _minimumAmountReceived) = abi.decode(data, (uint256)); // Assign 0 and 1 accordingly uint256 _amountReceived = uint256(-(_projectTokenIsZero ? amount0Delta : amount1Delta)); uint256 _amountToSend = uint256(_projectTokenIsZero ? amount1Delta : amount0Delta); // Revert if slippage is too high if (_amountReceived < _minimumAmountReceived) revert JuiceBuyback_MaximumSlippage(); // Wrap and transfer the weth to the pool weth.deposit{value: _amountToSend}(); weth.transfer(address(pool), _amountToSend); }
https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L596-L788
function swap( address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data ) external override noDelegateCall returns (int256 amount0, int256 amount1) { ... (amount0, amount1) = zeroForOne == exactInput ? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated) : (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining); // do the transfers and collect payment if (zeroForOne) { if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1)); uint256 balance0Before = balance0(); IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data); require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA'); } else { if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0)); uint256 balance1Before = balance1(); IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data); require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA'); } ... }
The following steps can occur for the described scenario.
JBPayoutRedemptionPaymentTerminal3_1.pay
function, which further calls the JBPayoutRedemptionPaymentTerminal3_1._pay
function, to pay 0.5e18
wei ETH.JBXBuybackDelegate.uniswapV3SwapCallback
function is eventually called, _amountToSend
can be 0.3e18
because the corresponding pool's liquidity is not sufficient and partial fill occurs.0.5e18 - 0.3e18 = 0.2e18
wei ETH are not used for the swap. Since such 0.2e18
wei ETH are now locked in the JBXBuybackDelegate
contract, Alice loses them.VSCode
The JBXBuybackDelegate.didPay
function can be updated to refund the extra ETH payment amount that is not used for swapping back to the user who calls the JBPayoutRedemptionPaymentTerminal3_1.pay
function or send such extra ETH amount to the corresponding beneficiary.
Other
#0 - c4-pre-sort
2023-05-25T13:46:30Z
dmvt marked the issue as duplicate of #42
#1 - c4-judge
2023-06-02T14:45:53Z
dmvt marked the issue as satisfactory
🌟 Selected for report: ABA
Also found by: 0x4non, 0xHati, 0xMosh, 0xSmartContract, 0xWaitress, 0xhacksmithh, 0xnev, 0xprinc, Arabadzhiev, BLACK-PANDA-REACH, Deekshith99, Dimagu, KKat7531, Kose, LosPollosHermanos, MohammedRizwan, QiuhaoLi, RaymondFam, Rickard, Rolezn, SAAJ, Sathish9098, Shubham, SmartGooofy, Tripathi, Udsen, V1235816, adriro, arpit, ayden, bigtone, codeVolcan, d3e4, dwward3n, fatherOfBlocks, favelanky, jovemjeune, kutugu, lfzkoala, lukris02, matrix_0wl, minhquanym, ni8mare, parsely, pxng0lin, radev_sw, ravikiranweb3, rbserver, sces60107, souilos, tnevler, turvy_fuzz, yellowBirdy
16.1907 USDC - $16.19
Issue | |
---|---|
[01] | projectToken.transfer(_data.projectId, _data.beneficiary, _nonReservedToken) CAN BE EXECUTED INSTEAD OF projectToken.transfer(_data.beneficiary, _nonReservedToken) IN JBXBuybackDelegate._swap FUNCTION |
[02] | JBXBuybackDelegate CONTRACT'S CONSTRUCTOR CAN BE UPDATED TO CHECK IF _pool IS FOR _projectToken |
[03] | THERE IS NO SWAP DEADLINE FOR CALLING JBXBuybackDelegate._swap FUNCTION |
[04] | pool IS NOT UPDATABLE IN JBXBuybackDelegate CONTRACT |
[05] | MISSING address(0) CHECKS FOR CRITICAL CONSTRUCTOR INPUTS |
[06] | _nonReservedToken CAN BE USED, INSTEAD OF _nonReservedTokenInContract , AS _tokenCount FOR CALLING controller.burnTokensOf IN JBXBuybackDelegate._swap FUNCTION |
[07] | IJBFundingCycleBallot INTERFACE IS NOT USED IN JBXBuybackDelegate CONTRACT |
[08] | SLIPPAGE_DENOMINATOR CAN BE ADDED TO AND IMPORTED FROM JBConstants LIBRARY |
[09] | UNDERSCORE CAN BE ADDED FOR SLIPPAGE_DENOMINATOR |
[10] | REDUNDANT NAMED RETURN FOR delegateAllocations |
[11] | control IS MISTYPED AS controle |
[12] | SOLIDITY VERSION 0.8.20 CAN BE USED |
projectToken.transfer(_data.projectId, _data.beneficiary, _nonReservedToken)
CAN BE EXECUTED INSTEAD OF projectToken.transfer(_data.beneficiary, _nonReservedToken)
IN JBXBuybackDelegate._swap
FUNCTIONWhen calling the following JBXBuybackDelegate._swap
function, if (_nonReservedToken != 0) projectToken.transfer(_data.beneficiary, _nonReservedToken)
is executed. In the JBXBuybackDelegate
contract, if projectToken
is set to a JBToken
contract, which has projectId
that is not _data.projectId
used for calling the JBXBuybackDelegate._swap
function, and pool
is set to a pool for swapping such projectToken
due to an intentional or unintentional misconfiguration, then calling the JBXBuybackDelegate._swap
function can transfer _nonReservedToken
amount of such projectToken
to _data.beneficiary
. No matter if such misconfiguration is intentional and malicious or unintentional and accidental, the beneficiary specified by the user can receive projectToken
that is not related to the corresponding project and can be worthless after such user pays the project. To safeguard against such misconfiguration, please consider executing projectToken.transfer(_data.projectId, _data.beneficiary, _nonReservedToken)
instead of projectToken.transfer(_data.beneficiary, _nonReservedToken)
in the JBXBuybackDelegate._swap
function because the JBToken.transfer(uint256 _projectId, address _to, uint256 _amount)
function would execute if (projectId != 0 && _projectId != projectId) revert BAD_PROJECT()
.
function _swap(JBDidPayData calldata _data, uint256 _minimumReceivedFromSwap, uint256 _reservedRate) internal returns (uint256 _amountReceived) { ... // Send the non-reserved token to the beneficiary (if any / reserved rate is not max) if (_nonReservedToken != 0) projectToken.transfer(_data.beneficiary, _nonReservedToken); ... }
File: node_modules\@jbx-protocol\juice-contracts-v3\contracts\JBToken.sol 182: function transfer( 183: uint256 _projectId, 184: address _to, 185: uint256 _amount 186: ) external override { 187: // Can't transfer for a wrong project. 188: if (projectId != 0 && _projectId != projectId) revert BAD_PROJECT(); 189: 190: transfer(_to, _amount); 191: }
JBXBuybackDelegate
CONTRACT'S CONSTRUCTOR CAN BE UPDATED TO CHECK IF _pool
IS FOR _projectToken
Calling the following constructor in the JBXBuybackDelegate
contract would set projectToken
and pool
. Yet, if not being careful enough, it is possible that pool
can be set to a pool that is not for projectToken
. If this occurs, calling the JBXBuybackDelegate._swap
function can transfer amount of a token that is not projectToken
to the JBXBuybackDelegate
contract but revert when executing projectToken.transfer(_data.beneficiary, _nonReservedToken)
since the JBXBuybackDelegate
contract does not own any projectToken
. To prevent this unexpected behavior from happening, please consider checking if _pool
is for _projectToken
in the JBXBuybackDelegate
contract's constructor and reverting such constructor if _pool
is not for _projectToken
.
constructor( IERC20 _projectToken, IWETH9 _weth, IUniswapV3Pool _pool, IJBPayoutRedemptionPaymentTerminal3_1 _jbxTerminal ) { projectToken = _projectToken; pool = _pool; ... }
function _swap(JBDidPayData calldata _data, uint256 _minimumReceivedFromSwap, uint256 _reservedRate) internal returns (uint256 _amountReceived) { // Pass the token and min amount to receive as extra data try pool.swap({ recipient: address(this), zeroForOne: !_projectTokenIsZero, amountSpecified: int256(_data.amount.value), sqrtPriceLimitX96: _projectTokenIsZero ? TickMath.MAX_SQRT_RATIO - 1 : TickMath.MIN_SQRT_RATIO + 1, data: abi.encode(_minimumReceivedFromSwap) }) returns (int256 amount0, int256 amount1) { // Swap succeded, take note of the amount of projectToken received (negative as it is an exact input) _amountReceived = uint256(-(_projectTokenIsZero ? amount0 : amount1)); } catch { // implies _amountReceived = 0 -> will later mint when back in didPay return _amountReceived; } // The amount to send to the beneficiary uint256 _nonReservedToken = PRBMath.mulDiv( _amountReceived, JBConstants.MAX_RESERVED_RATE - _reservedRate, JBConstants.MAX_RESERVED_RATE ); ... // Send the non-reserved token to the beneficiary (if any / reserved rate is not max) if (_nonReservedToken != 0) projectToken.transfer(_data.beneficiary, _nonReservedToken); ... }
JBXBuybackDelegate._swap
FUNCTIONCalling the following JBXBuybackDelegate._swap
function makes the pool.swap
function call, which does not enforce a deadline for the swap. It is possible that the transaction for the swap remains pending in the mempool for a long time, such as due to the accompanying transaction fee being too low. Later, when a miner finally includes such transaction, the amount of the project token being swapped out can be much less than that if such transaction could be executed when it was just sent, such as because of the MEV bots' operations for attempting to manipulate the pool's price. Although the JBXBuybackDelegate.uniswapV3SwapCallback
function does execute if (_amountReceived < _minimumAmountReceived) revert JuiceBuyback_MaximumSlippage()
, it can be less optimal for the user without enforcing the swap deadline since the amount of the project token being swapped out at the time when the transaction is finally executed can be equal to or just higher than _minimumAmountReceived
while such token amount being swapped out could be much higher than _minimumAmountReceived
if such transaction could be executed when it was just sent. Hence, to optimize the swap functionality and prevent the MEV bots and other malicious actors from taking advantages of the transaction that calls the JBXBuybackDelegate._swap
function, _data.metadata
for the JBXBuybackDelegate.didPay
function can be updated to allow the user to encode a specified deadline for the swap, and the JBXBuybackDelegate.uniswapV3SwapCallback
function can be updated to revert if block.timestamp
has passed such swap deadline.
function _swap(JBDidPayData calldata _data, uint256 _minimumReceivedFromSwap, uint256 _reservedRate) internal returns (uint256 _amountReceived) { // Pass the token and min amount to receive as extra data try pool.swap({ recipient: address(this), zeroForOne: !_projectTokenIsZero, amountSpecified: int256(_data.amount.value), sqrtPriceLimitX96: _projectTokenIsZero ? TickMath.MAX_SQRT_RATIO - 1 : TickMath.MIN_SQRT_RATIO + 1, data: abi.encode(_minimumReceivedFromSwap) }) returns (int256 amount0, int256 amount1) { // Swap succeded, take note of the amount of projectToken received (negative as it is an exact input) _amountReceived = uint256(-(_projectTokenIsZero ? amount0 : amount1)); } catch { // implies _amountReceived = 0 -> will later mint when back in didPay return _amountReceived; } ... }
pool
IS NOT UPDATABLE IN JBXBuybackDelegate
CONTRACTAs shown below, pool
is an immutable, which is assigned after calling the JBXBuybackDelegate
contract's constructor. It is possible that the liquidity of the assigned pool becomes much smaller in the future. However, when this happens, pool
cannot be updated to a relevant pool that has a higher liquidity. To avoid continuing using a pool that has a small liquidity, please consider changing pool
to not be an immutable and adding a function, which should be only callable by the corresponding project's admin, for updating pool
.
IUniswapV3Pool public immutable pool;
constructor( IERC20 _projectToken, IWETH9 _weth, IUniswapV3Pool _pool, IJBPayoutRedemptionPaymentTerminal3_1 _jbxTerminal ) { ... pool = _pool; ... }
address(0)
CHECKS FOR CRITICAL CONSTRUCTOR INPUTSTo prevent unintended behaviors, critical constructor inputs should be checked against address(0)
. address(0)
checks are missing for _projectToken
, _weth
, _pool
, and _jbxTerminal
in the following constructor. Please consider checking them.
constructor( IERC20 _projectToken, IWETH9 _weth, IUniswapV3Pool _pool, IJBPayoutRedemptionPaymentTerminal3_1 _jbxTerminal ) { projectToken = _projectToken; pool = _pool; jbxTerminal = _jbxTerminal; _projectTokenIsZero = address(_projectToken) < address(_weth); weth = _weth; }
_nonReservedToken
CAN BE USED, INSTEAD OF _nonReservedTokenInContract
, AS _tokenCount
FOR CALLING controller.burnTokensOf
IN JBXBuybackDelegate._swap
FUNCTIONIn the following JBXBuybackDelegate._swap
function, uint256 _reservedToken = _amountReceived - _nonReservedToken
is executed before executing uint256 _nonReservedTokenInContract = _amountReceived - _reservedToken
. Since _amountReceived
, _nonReservedToken
, and _reservedToken
are not changed between these two operations, _nonReservedTokenInContract
is basically same as _nonReservedToken
. Hence, to make the code more efficient, please consider using _nonReservedToken
, instead of calculating and using _nonReservedTokenInContract
, as _tokenCount
for calling controller.burnTokensOf
.
function _swap(JBDidPayData calldata _data, uint256 _minimumReceivedFromSwap, uint256 _reservedRate) internal returns (uint256 _amountReceived) { ... // The amount to add to the reserved token uint256 _reservedToken = _amountReceived - _nonReservedToken; ... // If there are reserved token, add them to the reserve if (_reservedToken != 0) { ... uint256 _nonReservedTokenInContract = _amountReceived - _reservedToken; if (_nonReservedTokenInContract != 0) { controller.burnTokensOf({ _holder: address(this), _projectId: _data.projectId, _tokenCount: _nonReservedTokenInContract, _memo: "", _preferClaimedTokens: false }); } } ... }
IJBFundingCycleBallot
INTERFACE IS NOT USED IN JBXBuybackDelegate
CONTRACTThe IJBFundingCycleBallot
interface is not used in the JBXBuybackDelegate
contract, please consider removing the import statement for it for better code efficiency.
import "@jbx-protocol/juice-contracts-v3/contracts/interfaces/IJBFundingCycleBallot.sol";
SLIPPAGE_DENOMINATOR
CAN BE ADDED TO AND IMPORTED FROM JBConstants
LIBRARYFor a better code organization, the following SLIPPAGE_DENOMINATOR
constant can be added to and imported from the JBConstants
library.
uint256 private constant SLIPPAGE_DENOMINATOR = 10000;
SLIPPAGE_DENOMINATOR
It is a common practice to separate each 3 digits in a number by an underscore to improve code readability. The following SLIPPAGE_DENOMINATOR
constant does not use any underscores; please consider adding an underscore for it.
uint256 private constant SLIPPAGE_DENOMINATOR = 10000;
delegateAllocations
( Please note that the following instance is not found in https://gist.github.com/itsmetechjay/2efc963de59bcad62e69de48171d10ca#n05-adding-a-return-statement-when-the-function-defines-a-named-return-variable-is-redundant. )
When a function has unused named returns and used return statement, these named returns become redundant. To improve maintainability, the named return for delegateAllocations
can be removed while a temporary variable for delegateAllocations
can be used in the corresponding return statement in the following JBXBuybackDelegate.payParams
function.
function payParams(JBPayParamsData calldata _data) external override returns (uint256 weight, string memory memo, JBPayDelegateAllocation[] memory delegateAllocations) { ... // If the amount swapped is bigger than the lowest received when minting, use the swap pathway if (_tokenCount < _quote - (_quote * _slippage / SLIPPAGE_DENOMINATOR)) { ... // Return this delegate as the one to use, and do not mint from the terminal delegateAllocations = new JBPayDelegateAllocation[](1); delegateAllocations[0] = JBPayDelegateAllocation({delegate: IJBPayDelegate(this), amount: _data.amount.value}); return (0, _data.memo, delegateAllocations); } // If minting, do not use this as delegate return (_data.weight, _data.memo, new JBPayDelegateAllocation[](0)); }
control
IS MISTYPED AS controle
( Please note that the following instance is not found in https://gist.github.com/itsmetechjay/2efc963de59bcad62e69de48171d10ca#n13-typos. )
In the following comment, control
is mistyped as controle
. Please change controle
to control
.
* @dev Slippage controle is achieved here
0.8.20
CAN BE USEDUsing the more updated version of Solidity can add new features and enhance security. As described in https://github.com/ethereum/solidity/releases, Version 0.8.20
is the latest version of Solidity, which includes support for Shanghai. To be more secured and more future-proofed, please consider using Version 0.8.20
for the JBXBuybackDelegate
contract.
pragma solidity ^0.8.16;
#0 - c4-judge
2023-06-02T11:05:36Z
dmvt marked the issue as grade-b