Platform: Code4rena
Start Date: 12/04/2023
Pot Size: $60,500 USDC
Total HM: 21
Participants: 199
Period: 7 days
Judge: hansfriese
Total Solo HM: 5
Id: 231
League: ETH
Rank: 3/199
Findings: 8
Award: $4,388.24
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: carrotsmuggler
Also found by: Ace-30, KIntern_NA, Nyx, bin2chen, cccz, juancito, mahdikarimi, mov, nobody2018
201.1223 USDC - $201.12
A challenge mechanism exists in frankcoin, whereby a challenger can use collateral to launch a challenge when it observes that the price in a position is too high (i.e., the position may overstate the price).
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
If the bidder's bid is greater than or equal to the current price, the bidder gets the challenger's collateral and the challenger gets the bidder's bid.
function bid(uint256 _challengeNumber, uint256 _bidAmountZCHF, uint256 expectedSize) external { Challenge storage challenge = challenges[_challengeNumber]; if (block.timestamp >= challenge.end) revert TooLate(); if (expectedSize != challenge.size) revert UnexpectedSize(); if (challenge.bid > 0) { zchf.transfer(challenge.bidder, challenge.bid); // return old bid } emit NewBid(_challengeNumber, _bidAmountZCHF, msg.sender); // ask position if the bid was high enough to avert the challenge if (challenge.position.tryAvertChallenge(challenge.size, _bidAmountZCHF)) { // bid was high enough, let bidder buy collateral from challenger zchf.transferFrom(msg.sender, challenge.challenger, _bidAmountZCHF); challenge.position.collateral().transfer(msg.sender, challenge.size); emit ChallengeAverted(address(challenge.position), _challengeNumber); delete challenges[_challengeNumber];
There is a issue here, if the owner of the position adjusts the price before the challenger launches the challenge, it may cause the challenger to lose assets.
function adjustPrice(uint256 newPrice) public onlyOwner noChallenge { if (newPrice > price) { restrictMinting(3 days); } else { checkCollateral(collateralBalance(), newPrice); } price = newPrice; emitUpdate(); }
Consider the current WETH : ZCHF = 2000:1
function tryAvertChallenge(uint256 _collateralAmount, uint256 _bidAmountZCHF) external onlyHub returns (bool) { if (block.timestamp >= expiration){ return false; // position expired, let every challenge succeed } else if (_bidAmountZCHF * ONE_DEC18 >= price * _collateralAmount){ // challenge averted, bid is high enough challengedAmount -= _collateralAmount; // Don't allow minter to close the position immediately so challenge can be repeated before // the owner has a chance to mint more on an undercollateralized position restrictMinting(1 days); return true; } else { return false; } }
This vulnerability will break the entire challenge mechanism, as the challenger will be unwilling to launch a challenge for fear of being attacked, leading to a prevalence of overstating prices in the position, so I consider this a high risk vulnerability.
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L159-L167 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L140-L148 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L199-L213 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L304-L317
None
Consider adding an expectedPrice
parameter in launchChallenge, when the price in the position does not match expectedPrice
, revert the transaction
- function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { + function launchChallenge(address _positionAddr, uint256 _collateralAmount, uint256 expectedPrice) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); + require(expectedPrice == position.price(),"price not match"); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
#0 - c4-pre-sort
2023-04-22T18:55:55Z
0xA5DF marked the issue as duplicate of #945
#1 - c4-judge
2023-05-18T14:50:11Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: cccz
Also found by: mahdikarimi
3036.9204 USDC - $3,036.92
When the challenge is successful, internalWithdrawCollateral will be called to transfer the collateral in the position. Note that the cooldown period of the position will be extended until the position expires only if the collateral in the position is less than minimumCollateral, if the user sends collateral to the position in advance, then the cool down period of the position will not be extended.
function internalWithdrawCollateral(address target, uint256 amount) internal returns (uint256) { IERC20(collateral).transfer(target, amount); uint256 balance = collateralBalance(); if (balance < minimumCollateral){ cooldown = expiration; } emitUpdate(); return balance; }
I will use the following example to illustrate the severity of the issue.
Consider WETH:ZCHF=2000:1, the position has a challenge period of 3 days and the minimum amount of collateral is 1 WETH.
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L268-L276 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329-L354 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L252-L276
None
Consider extending the cooldown period of the position even if the challenge is successful
#0 - c4-pre-sort
2023-04-24T20:04:55Z
0xA5DF marked the issue as primary issue
#1 - luziusmeisser
2023-04-30T01:22:20Z
Excellent finding! Will implement 1 day cooldown on successful challenges.
#2 - c4-sponsor
2023-04-30T01:22:31Z
luziusmeisser marked the issue as sponsor confirmed
#3 - c4-judge
2023-05-17T18:16:34Z
hansfriese marked the issue as selected for report
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
35.0635 USDC - $35.06
When the challenge is successful, the challenger will receive the challenge reward. In MintingHub.end, the challenge reward is 2% of the volume returned by notifyChallengeSucceeded.
(address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size); if (effectiveBid < challenge.bid) { // overbid, return excess amount IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid); } uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000; uint256 fundsNeeded = reward + repayment; if (effectiveBid > fundsNeeded){ zchf.transfer(owner, effectiveBid - fundsNeeded); } else if (effectiveBid < fundsNeeded){ zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything } zchf.transfer(challenge.challenger, reward); // pay out the challenger reward
In notifyChallengeSucceeded, volumeZCHF == price * bid.size
function notifyChallengeSucceeded(address _bidder, uint256 _bid, uint256 _size) external onlyHub returns (address, uint256, uint256, uint256, uint32) { challengedAmount -= _size; uint256 colBal = collateralBalance(); if (_size > colBal){ // Challenge is larger than the position. This can for example happen if there are multiple concurrent // challenges that exceed the collateral balance in size. In this case, we need to redimension the bid and // tell the caller that a part of the bid needs to be returned to the bidder. _bid = _divD18(_mulD18(_bid, colBal), _size); _size = colBal; } // Note that thanks to the collateral invariant, we know that // colBal * price >= minted * ONE_DEC18 // and that therefore // price >= minted / colbal * E18 // such that // volumeZCHF = price * size / E18 >= minted * size / colbal // So the owner cannot maliciously decrease the price to make volume fall below the proportionate repayment. uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral // The owner does not have to repay (and burn) more than the owner actually minted. uint256 repayment = minted < volumeZCHF ? minted : volumeZCHF; // how much must be burned to make things even notifyRepaidInternal(repayment); // we assume the caller takes care of the actual repayment internalWithdrawCollateral(_bidder, _size); // transfer collateral to the bidder and emit update return (owner, _bid, volumeZCHF, repayment, reserveContribution); }
where price can be adjusted to any value by the owner of the position, which results in an unlimited challenge reward for the challenger.
function adjustPrice(uint256 newPrice) public onlyOwner noChallenge { if (newPrice > price) { restrictMinting(3 days); } else { checkCollateral(collateralBalance(), newPrice); } price = newPrice; emitUpdate(); }
Consider WETH:ZCHF = 2000:1 alice clones the position, offering 1 WETH to mint 2000 ZCHF. alice adjusts the price to 50000 * 2000. bob challenges the position with 1 WETH and charlie bids 2000 ZCHF. After the challenge expires, bob calls the end function, volumeZCHF = 50000 * 2000, and the challenge reward is 2000000 zchf. when the zchf reserves are insufficient, new zchf will be minted
uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000; uint256 fundsNeeded = reward + repayment; if (effectiveBid > fundsNeeded){ zchf.transfer(owner, effectiveBid - fundsNeeded); } else if (effectiveBid < fundsNeeded){ zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything } ... function notifyLoss(uint256 _amount) override external minterOnly { uint256 reserveLeft = balanceOf(address(reserve)); if (reserveLeft >= _amount){ _transfer(address(reserve), msg.sender, _amount); } else { _transfer(address(reserve), msg.sender, reserveLeft); _mint(msg.sender, _amount - reserveLeft); } }
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L260-L265 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329-L354
None
Consider capping challenge rewards, or use effectiveBid instead of volume to determine challenge rewards
(address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size); if (effectiveBid < challenge.bid) { // overbid, return excess amount IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid); } - uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000; + uint256 reward = (effectiveBid * CHALLENGER_REWARD) / 1000_000; uint256 fundsNeeded = reward + repayment; if (effectiveBid > fundsNeeded){ zchf.transfer(owner, effectiveBid - fundsNeeded); } else if (effectiveBid < fundsNeeded){ zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything }
#0 - c4-pre-sort
2023-04-22T18:57:22Z
0xA5DF marked the issue as duplicate of #973
#1 - c4-pre-sort
2023-04-24T18:44:27Z
0xA5DF marked the issue as duplicate of #458
#2 - c4-judge
2023-05-18T14:44:09Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
35.0635 USDC - $35.06
When a challenge is launched on a position, it sets challenge.end == block.timestamp + position.challengePeriod()
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
and when bidding on the challenge, it requires block.timestamp < challenge.end
function bid(uint256 _challengeNumber, uint256 _bidAmountZCHF, uint256 expectedSize) external { Challenge storage challenge = challenges[_challengeNumber]; if (block.timestamp >= challenge.end) revert TooLate(); if (expectedSize != challenge.size) revert UnexpectedSize(); if (challenge.bid > 0) { zchf.transfer(challenge.bidder, challenge.bid); // return old bid }
If the position has challengePeriod == 0 (set by the user when opening the position and not denied by the major shareholder), then challenge.end == block.timestamp, i.e. it will end when the challenge is launched and users will not be able to bid on the challenge, i.e. the position will not be affected by challenges.
function openPosition( address _collateralAddress, uint256 _minCollateral, uint256 _initialCollateral, uint256 _mintingMaximum, uint256 _initPeriodSeconds, uint256 _expirationSeconds, uint256 _challengeSeconds, uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reservePPM) public returns (address) { IPosition pos = IPosition( POSITION_FACTORY.createNewPosition( msg.sender, address(zchf), _collateralAddress, _minCollateral, _mintingMaximum, _initPeriodSeconds, _expirationSeconds, _challengeSeconds, _mintingFeePPM, _liqPrice, _reservePPM ) ); zchf.registerPosition(address(pos)); zchf.transferFrom(msg.sender, address(zchf.reserve()), OPENING_FEE); require(_initialCollateral >= _minCollateral, "must start with min col"); IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral); return address(pos); }
A position that cannot be challenged can set any price to mint a large number of zchf
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L140-L148 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L199-L205 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L88-L113
None
Consider requiring challengePeriod > 0 when opening positions
#0 - c4-pre-sort
2023-04-21T09:24:49Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-04-21T09:26:30Z
0xA5DF marked the issue as duplicate of #830
#2 - c4-pre-sort
2023-04-24T18:48:53Z
0xA5DF marked the issue as duplicate of #458
#3 - c4-judge
2023-05-18T14:38:10Z
hansfriese changed the severity to 3 (High Risk)
#4 - c4-judge
2023-05-18T14:40:27Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: decade
Also found by: 0x3b, 0xDACA, 0xWaitress, 0xWeiss, 0xkaju, Arz, Aymen0909, BPZ, EloiManuel, HaCk0, J4de, Jerry0x, Jiamin, John, Juntao, Kek, Lalanda, MiloTruck, Mukund, PNS, RedTiger, Ruhum, Satyam_Sharma, ToonVH, Tricko, Udsen, ak1, anodaram, bin2chen, carrotsmuggler, cccz, circlelooper, deadrxsezzz, giovannidisiena, jasonxiale, joestakey, juancito, karanctf, kenta, kodyvim, ladboy233, lil_eth, lukino, markus_ether, marwen, mrpathfindr, nobody2018, parlayan_yildizlar_takimi, peakbolt, ravikiranweb3, rbserver, rvierdiiev, silviaxyz, volodya, zhuXKET, zzebra83
0.0748 USDC - $0.07
When the system reserve drops below 1000 due to some reasons, the structureCapTable function allows major shareholders to burn the FPS of some users. However, restructureCapTable will only repeatedly burn the FPS of addressesToWipe[0]. In extreme cases, major shareholders may think that reconstructionCapTable is successful execute and fund the system so that users who have not burned FPS split funds.
function restructureCapTable(address[] calldata helpers, address[] calldata addressesToWipe) public { require(zchf.equity() < MINIMUM_EQUITY); checkQualified(msg.sender, helpers); for (uint256 i = 0; i<addressesToWipe.length; i++){ address current = addressesToWipe[0]; _burn(current, balanceOf(current)); } }
None
Change to
function restructureCapTable(address[] calldata helpers, address[] calldata addressesToWipe) public { require(zchf.equity() < MINIMUM_EQUITY); checkQualified(msg.sender, helpers); for (uint256 i = 0; i<addressesToWipe.length; i++){ - address current = addressesToWipe[0]; + address current = addressesToWipe[i]; _burn(current, balanceOf(current)); } }
#0 - c4-pre-sort
2023-04-20T14:21:58Z
0xA5DF marked the issue as duplicate of #941
#1 - c4-judge
2023-05-18T14:27:05Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: __141345__
Also found by: Emmanuel, KIntern_NA, SaeedAlipoor01988, bin2chen, cccz, joestakey, ladboy233, peanuts, said
60.3367 USDC - $60.34
When the challenge is successful, internalWithdrawCollateral will be called to send the collateral in the position to the bidder,
function internalWithdrawCollateral(address target, uint256 amount) internal returns (uint256) { IERC20(collateral).transfer(target, amount); uint256 balance = collateralBalance(); if (balance < minimumCollateral){ cooldown = expiration; } emitUpdate(); return balance; }
and the challenger’s collateral will be refunded, and the challenge reward will be sent to the challenger.
function end(uint256 _challengeNumber, bool postponeCollateralReturn) public { Challenge storage challenge = challenges[_challengeNumber]; require(challenge.challenger != address(0x0)); require(block.timestamp >= challenge.end, "period has not ended"); // challenge must have been successful, because otherwise it would have immediately ended on placing the winning bid returnCollateral(challenge, postponeCollateralReturn); // notify the position that will send the collateral to the bidder. If there is no bid, send the collateral to msg.sender address recipient = challenge.bidder == address(0x0) ? msg.sender : challenge.bidder; (address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size); if (effectiveBid < challenge.bid) { // overbid, return excess amount IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid); } uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000; uint256 fundsNeeded = reward + repayment; if (effectiveBid > fundsNeeded){ zchf.transfer(owner, effectiveBid - fundsNeeded); } else if (effectiveBid < fundsNeeded){ zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything } zchf.transfer(challenge.challenger, reward); // pay out the challenger reward
If the collateral is an ERC777 token, the bidder can revert the transaction in the callback when receiving the collateral, preventing the challenger from receiving collateral refunds and challenge rewards.
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L268-L276 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L351-L353 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L252-L260
None
Consider saving the bidder's collateral in pendingReturns when postpone is true in the end function
#0 - c4-pre-sort
2023-04-22T15:55:59Z
0xA5DF marked the issue as duplicate of #680
#1 - c4-judge
2023-05-18T13:25:18Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: cccz
Also found by: DishWasher, KIntern_NA, SolidityATL, ToonVH, giovannidisiena, joestakey, santipu_
121.0458 USDC - $121.05
When minting and redeeming FPS in Equity, there is no slippage control. Since the price of FPS will change with the zchf reserve in the contract, users may suffer from sandwich attacks.
Consider the current contract has a zchf reserve of 1000 and a total supply of 1000.
Alice considers using 4000 zchf to mint FPS. Under normal circumstances, the contract reserve will rise to 5000 zchf, and the total supply will rise to (5000/1000)**(1/3)*1000 = 1710, that is, alice will get 1710 - 1000 = 710 FPS.
bob holds 400 FPS, and bob observes alice's transaction in MemPool, bob uses MEV to preemptively use 4000 zchf to mint 710 FPS.
When alice's transaction is executed, the contract reserve will increase from 5000 to 9000 zchf, and the total supply will increase from 1710 to (9000/5000)**(1/3)*1710 = 2080, that is, alice gets 2080-1710 = 370FPS.
Then bob will redeem 400 FPS, the total supply will drop from 2080 to 1680, and the contract reserve will drop from 9000 to (1689/2080)**3*9000 = 4742, that is, bob gets 9000-4742 = 4258 zchf.
bob's total profit is 310 FPS and 258 zchf.
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L241-L255 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L266-L270 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L275-L282 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L290-L297
None
Consider setting minFPSout and minZCHFout parameters to allow slippage control when minting and redeeming FPS
#0 - c4-pre-sort
2023-04-20T13:10:35Z
0xA5DF marked the issue as primary issue
#1 - c4-sponsor
2023-05-03T06:12:12Z
luziusmeisser marked the issue as sponsor confirmed
#2 - c4-judge
2023-05-18T05:00:32Z
hansfriese marked issue #976 as primary and marked this issue as a duplicate of 976
#3 - c4-judge
2023-05-18T05:20:05Z
hansfriese marked the issue as not a duplicate
#4 - c4-judge
2023-05-18T05:20:19Z
hansfriese marked the issue as primary issue
#5 - c4-judge
2023-05-18T05:22:39Z
hansfriese marked the issue as selected for report
🌟 Selected for report: cccz
Also found by: RaymondFam
911.0761 USDC - $911.08
When bidders bid, if the expiration time of the challenge is less than 30 minutes, the expiration time will be extended.
uint256 earliestEnd = block.timestamp + 30 minutes; if (earliestEnd >= challenge.end) { // bump remaining time like ebay does when last minute bids come in // An attacker trying to postpone the challenge forever must increase the bid by 0.5% // every 30 minutes, or double it every three days, making the attack hard to sustain // for a prolonged period of time. challenge.end = earliestEnd; }
However, extending the expiration time will break the order of the challenges, so that the later challenges will succeed before the previous ones, thus affecting the challenger's reward expectations.
Consider the following scenario There is a collateral of 2 WETH in a position, and as the actual price of WETH drops, challengers are attracted to challenge it. In block 1, alice uses 2 WETH to challenge the position, and the expiration time is block 7201 At block 2, bob challenges the position with 2 WETH, expiring at block 7202 The bidder then bids 4000 ZCHF each for alice's and bob's challenges. In block 7200, bob finds that if alice's challenge is successful, then bob will not be able to get the challenge reward, so bob bids 4200 ZCHF to alice's challenge. Alice's challenge expiration time is extended to block 7351. At block 7201, alice cannot call end to make the challenge successful because the expiration time is extended At block 7202, bob successfully calls end to make his challenge successful and gets the challenge reward. In block 7351, alice calls the end function. Since the collateral in the position is 0 at this time, alice will not be able to get the challenge reward, and bob's 4200 zchf will be returned.
function notifyChallengeSucceeded(address _bidder, uint256 _bid, uint256 _size) external onlyHub returns (address, uint256, uint256, uint256, uint32) { challengedAmount -= _size; uint256 colBal = collateralBalance(); if (_size > colBal){ // Challenge is larger than the position. This can for example happen if there are multiple concurrent // challenges that exceed the collateral balance in size. In this case, we need to redimension the bid and // tell the caller that a part of the bid needs to be returned to the bidder. _bid = _divD18(_mulD18(_bid, colBal), _size); _size = colBal; } // Note that thanks to the collateral invariant, we know that // colBal * price >= minted * ONE_DEC18 // and that therefore // price >= minted / colbal * E18 // such that // volumeZCHF = price * size / E18 >= minted * size / colbal // So the owner cannot maliciously decrease the price to make volume fall below the proportionate repayment. uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral // The owner does not have to repay (and burn) more than the owner actually minted. uint256 repayment = minted < volumeZCHF ? minted : volumeZCHF; // how much must be burned to make things even
https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L217-L224 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329-L350
None
Consider implementing a challenge queue that allows the end function to be called on subsequent challenges only after previous challenges have ended
#0 - c4-pre-sort
2023-04-24T20:13:47Z
0xA5DF marked the issue as primary issue
#1 - luziusmeisser
2023-05-03T06:16:10Z
Bids must at least be 0.5% higher than the previous bid, so pro-longing the challenge four times already costs as much as the whole challenger reward of 2%, making this attack not very attractive under normal circumstances.
--> Not worth to add any complexity to change this.
#2 - c4-sponsor
2023-05-03T06:16:39Z
luziusmeisser marked the issue as sponsor acknowledged
#3 - c4-judge
2023-05-18T05:26:03Z
hansfriese marked the issue as selected for report