Platform: Code4rena
Start Date: 02/08/2023
Pot Size: $42,000 USDC
Total HM: 13
Participants: 45
Period: 5 days
Judge: hickuphh3
Total Solo HM: 5
Id: 271
League: ETH
Rank: 1/45
Findings: 5
Award: $11,374.29
🌟 Selected for report: 3
🚀 Solo Findings: 2
🌟 Selected for report: dirk_y
7639.9579 USDC - $7,639.96
https://github.com/GenerationSoftware/pt-v5-draw-auction/blob/f1c6d14a1772d6609de1870f8713fb79977d51c1/src/RngRelayAuction.sol#L178-L184 https://github.com/GenerationSoftware/pt-v5-draw-auction/blob/f1c6d14a1772d6609de1870f8713fb79977d51c1/src/RngRelayAuction.sol#L154-L157 https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/26557afa439934afc080eca6165fe3ce5d4b63cd/src/PrizePool.sol#L366 https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/26557afa439934afc080eca6165fe3ce5d4b63cd/src/abstract/TieredLiquidityDistributor.sol#L374
A relayer completes a prize pool draw by calling rngComplete
in RngRelayAuction.sol
. This method closes the prize pool draw with the relayed random number and distributes the rewards to the RNG auction recipient and the RNG relay auction recipient. These rewards are calculated based on a fraction of the prize pool reserve rather than an actual value.
However, the current reward calculation mistakenly includes an extra reserveForOpenDraw
amount just after the draw has been closed. Therefore the fraction over which the rewards are being calculated includes tokens that have not been added to the reserve and will actually only be added to the reserve when the next draw is finalised. As a result, the reward recipients are rewarded too many tokens.
Before deciding whether or not to relay an auction result, a bot can call computeRewards
to calculate how many rewards they'll be getting based on the size of the reserve, the state of the auction and the reward fraction of the RNG auction recipient:
function computeRewards(AuctionResult[] calldata __auctionResults) external returns (uint256[] memory) { uint256 totalReserve = prizePool.reserve() + prizePool.reserveForOpenDraw(); return _computeRewards(__auctionResults, totalReserve); }
Here, the total reserve is calculated as the sum of the current reserve and and amount of new tokens that will be added to the reserve once the currently open draw is closed. This method is correct and correctly calculates how many rewards should be distributed when a draw is closed.
A bot can choose to close the draw by calling rngComplete
(via a relayer), at which point the rewards are calculated and distributed. Below is the interesting part of this method:
uint32 drawId = prizePool.closeDraw(_randomNumber); uint256 futureReserve = prizePool.reserve() + prizePool.reserveForOpenDraw(); uint256[] memory _rewards = RewardLib.rewards(auctionResults, futureReserve);
As you can see, the draw is first closed and then the future reserve is used to calculate the rewards that should be distributed. However, when closeDraw
is called on the pool, the reserveForOpenDraw
for the previously open draw is added to the existing reserves. So reserve()
is now equal to the totalReserve
value in the earlier call to computeRewards
. By including reserveForOpenDraw()
when computing the actual reward to be distributed we've accidentally counted the tokens that are only going to be added in when the next draw is closed. So now the rewards distribution calculation includes the pending reserves for 2 draws rather than 1.
Manual review
When distributing rewards in the call to rngComplete
, the rewards should not be calculated with the new value of reserveForOpenDraw
because the previous reserveForOpenDraw
value has already been added to the reserves when closeDraw
is called on the prize pool. Below is a suggested diff:
diff --git a/src/RngRelayAuction.sol b/src/RngRelayAuction.sol index 8085169..cf3c210 100644 --- a/src/RngRelayAuction.sol +++ b/src/RngRelayAuction.sol @@ -153,8 +153,8 @@ contract RngRelayAuction is IRngAuctionRelayListener, IAuction { uint32 drawId = prizePool.closeDraw(_randomNumber); - uint256 futureReserve = prizePool.reserve() + prizePool.reserveForOpenDraw(); - uint256[] memory _rewards = RewardLib.rewards(auctionResults, futureReserve); + uint256 reserve = prizePool.reserve(); + uint256[] memory _rewards = RewardLib.rewards(auctionResults, reserve); emit RngSequenceCompleted( _sequenceId,
Math
#0 - c4-pre-sort
2023-08-08T04:20:39Z
raymondfam marked the issue as primary issue
#1 - c4-pre-sort
2023-08-08T05:27:09Z
raymondfam marked the issue as high quality report
#2 - asselstine
2023-08-10T19:49:58Z
Nice catch!
#3 - c4-sponsor
2023-08-10T19:50:04Z
asselstine marked the issue as sponsor confirmed
#4 - HickupHH3
2023-08-14T07:25:04Z
Great find!
#5 - c4-judge
2023-08-14T07:25:09Z
HickupHH3 marked the issue as selected for report
🌟 Selected for report: Aymen0909
Also found by: 0xStalin, 0xbepresent, Arz, D_Auditor, Jorgect, T1MOH, bin2chen, dirk_y, josephdara, ptsanev, rvierdiiev, seerether, shirochan, trachev
89.6296 USDC - $89.63
https://github.com/GenerationSoftware/pt-v5-draw-auction/blob/f1c6d14a1772d6609de1870f8713fb79977d51c1/src/RngRelayAuction.sol#L131-L173 https://github.com/GenerationSoftware/pt-v5-draw-auction/blob/f1c6d14a1772d6609de1870f8713fb79977d51c1/src/RngRelayAuction.sol#L241-L243
The RngRelayAuction
contract deployed on each chain has a rngComplete
method that is supposed to be called by the relayer in order to close/complete a prize draw.
However this method doesn't have any access control and can therefore be called by anyone with arbitrary random numbers or sequence ids. And arbitrary random number can be supplied to ensure that the caller always wins a prize. And the maximum _sequenceId
value can be supplied to perpetually brick the relay auction contract.
This vulnerability exists due to lack of access control:
function rngComplete( uint256 _randomNumber, uint256 _rngCompletedAt, address _rewardRecipient, uint32 _sequenceId, AuctionResult calldata _rngAuctionResult ) external returns (bytes32) { if (_sequenceHasCompleted(_sequenceId)) revert SequenceAlreadyCompleted(); uint64 _auctionElapsedSeconds = uint64(block.timestamp < _rngCompletedAt ? 0 : block.timestamp - _rngCompletedAt); if (_auctionElapsedSeconds > (_auctionDurationSeconds-1)) revert AuctionExpired(); // Calculate the reward fraction and set the draw auction results UD2x18 rewardFraction = _fractionalReward(_auctionElapsedSeconds); _auctionResults.rewardFraction = rewardFraction; _auctionResults.recipient = _rewardRecipient; _lastSequenceId = _sequenceId; AuctionResult[] memory auctionResults = new AuctionResult[](2); auctionResults[0] = _rngAuctionResult; auctionResults[1] = AuctionResult({ rewardFraction: rewardFraction, recipient: _rewardRecipient });
Supplying an arbitrary _randomNumber
value is clearly an issue since the number is now not random and doesn't originate from an on-chain random number generator. As stated above, a malicious user can ensure they win every round by supplying a random number that always places them in the winning zone.
In terms of bricking the contract, this can be achieved due to the call to _sequenceHasCompleted
on the first line of the above method:
function _sequenceHasCompleted(uint32 _sequenceId) internal view returns (bool) { return _lastSequenceId >= _sequenceId; }
This method checks that the supplied _sequenceId
is greater than the last sequence id. Due to the lack of access control a malicious user can call rngComplete
with _sequenceId = type(uint32).max
to permanently brick the contract going forwards.
Manual review
The rngComplete
method should only be able to be called by the auction relayer on the respective chain. Below is a suggested diff:
diff --git a/src/RngRelayAuction.sol b/src/RngRelayAuction.sol index 8085169..56d794a 100644 --- a/src/RngRelayAuction.sol +++ b/src/RngRelayAuction.sol @@ -135,6 +135,7 @@ contract RngRelayAuction is IRngAuctionRelayListener, IAuction { uint32 _sequenceId, AuctionResult calldata _rngAuctionResult ) external returns (bytes32) { + require(msg.sender == rngAuctionRelayer, "Not authorized"); if (_sequenceHasCompleted(_sequenceId)) revert SequenceAlreadyCompleted(); uint64 _auctionElapsedSeconds = uint64(block.timestamp < _rngCompletedAt ? 0 : block.timestamp - _rngCompletedAt); if (_auctionElapsedSeconds > (_auctionDurationSeconds-1)) revert AuctionExpired();
Access Control
#0 - c4-pre-sort
2023-08-08T04:07:34Z
raymondfam marked the issue as duplicate of #82
#1 - c4-judge
2023-08-14T02:48:40Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: dirk_y
2291.9874 USDC - $2,291.99
https://github.com/GenerationSoftware/remote-owner/blob/9c093dbd36c1f18ab7083549d10ac601d91630df/src/RemoteOwner.sol#L58 https://github.com/GenerationSoftware/remote-owner/blob/9c093dbd36c1f18ab7083549d10ac601d91630df/src/RemoteOwner.sol#L120 https://github.com/GenerationSoftware/remote-owner/blob/9c093dbd36c1f18ab7083549d10ac601d91630df/src/RemoteOwner.sol#L96-L99 https://github.com/GenerationSoftware/pt-v5-draw-auction/blob/f1c6d14a1772d6609de1870f8713fb79977d51c1/src/RngAuctionRelayerRemoteOwner.sol#L47 https://github.com/GenerationSoftware/pt-v5-draw-auction/blob/f1c6d14a1772d6609de1870f8713fb79977d51c1/src/RngAuctionRelayerRemoteOwner.sol#L64
The RemoteOwner.sol
contract has a security measure that ensures the sender from the remote/origin chain was the origin chain owner (i.e. a RngAuctionRelayerRemoteOwner.sol
deployment), and this address is set at deployment time in the constructor. The RngAuctionRelayerRemoteOwner
contract also has a security measure to ensure that messages are only dispatched across chain to the RemoteOwner
contract deployed in the destination chain, and this address is set at deployment time in the constructor.
Clearly there is a circular dependency here that means the deployment phase will fail. There is a setOriginChainOwner
method on the RemoteOwner
contract, however this can only be called by the address on the origin chain specified in the constructor. This method is never called from the origin chain either. In summary, the circular dependency prevents the contracts from being deployed and ever initialised properly.
It is possible that there is an intermediary __originChainOwner
used in the constructor when deploying RemoteOwner
, but since I couldn't find any deployment scripts to verify this I have assumed that this is an unintended bug. The severity of this report depends on whether or not this was intended.
In the RemoteOwner.sol
contract, the origin chain owner is set in the constructor:
constructor( uint256 originChainId_, address executor_, address __originChainOwner ) ExecutorAware(executor_) { if (originChainId_ == 0) revert OriginChainIdZero(); _originChainId = originChainId_; _setOriginChainOwner(__originChainOwner); }
Any calls to the RemoteOwner
contract are protected by the _checkSender
view:
function _checkSender() internal view { if (!isTrustedExecutor(msg.sender)) revert LocalSenderNotExecutor(msg.sender); if (_fromChainId() != _originChainId) revert OriginChainIdUnsupported(_fromChainId()); if (_msgSender() != address(_originChainOwner)) revert OriginSenderNotOwner(_msgSender()); }
Now, if we have a look at the RngAuctionRelayerRemoteOwner.sol
contract, we can see that the remote owner address is also specified in the constructor:
constructor( RngAuction _rngAuction, ISingleMessageDispatcher _messageDispatcher, RemoteOwner _remoteOwner, uint256 _toChainId ) RngAuctionRelayer(_rngAuction) { messageDispatcher = _messageDispatcher; account = _remoteOwner; toChainId = _toChainId; }
This account
address is now hard-coded and used with any calls to relay
:
function relay( IRngAuctionRelayListener _remoteRngAuctionRelayListener, address rewardRecipient ) external returns (bytes32) { bytes memory listenerCalldata = encodeCalldata(rewardRecipient); bytes32 messageId = messageDispatcher.dispatchMessage( toChainId, address(account), RemoteOwnerCallEncoder.encodeCalldata(address(_remoteRngAuctionRelayListener), 0, listenerCalldata) ); emit RelayedToDispatcher(rewardRecipient, messageId); return messageId; }
There is a circular dependency here due to the reliance on specifying the relevant addresses in the constructor.
Manual review
To remove the circular dependency and reliance on a very specific deployment pipeline that requires a specific call from a remote chain address, I would make the following change to the RemoteOwner
contract:
diff --git a/src/RemoteOwner.sol b/src/RemoteOwner.sol index 7c1de6d..a6cb8f1 100644 --- a/src/RemoteOwner.sol +++ b/src/RemoteOwner.sol @@ -55,7 +55,6 @@ contract RemoteOwner is ExecutorAware { ) ExecutorAware(executor_) { if (originChainId_ == 0) revert OriginChainIdZero(); _originChainId = originChainId_; - _setOriginChainOwner(__originChainOwner); } /* ============ External Functions ============ */ @@ -94,7 +93,7 @@ contract RemoteOwner is ExecutorAware { * If the transaction get front-run at deployment, we can always re-deploy the contract. */ function setOriginChainOwner(address _newOriginChainOwner) external { - _checkSender(); + require(_originChainOwner == address(0), "Already initialized"); _setOriginChainOwner(_newOriginChainOwner); }
However I can understand how the current deployment pipeline functionality would make it harder to frontrun setOriginChainOwner
if this was done deliberately, so alternatively you could keep the functionality the same but just provide better comments.
Other
#0 - c4-pre-sort
2023-08-08T04:22:09Z
raymondfam marked the issue as primary issue
#1 - c4-pre-sort
2023-08-08T05:25:34Z
raymondfam marked the issue as high quality report
#2 - asselstine
2023-08-10T19:55:52Z
Interesting. We haven't deployed the RemoteOwner yet so we didn't run into this issue.
It looks like the simplest solution is to pass the RemoteOwner
as an argument to the RngAuctionRelayerRemoteOwner#relay
call.
This also makes deploying to new L2s more convenient, because we won't need to redeploy a RngAuctionRelayerRemoteOwner contract.
#3 - c4-sponsor
2023-08-10T19:55:57Z
asselstine marked the issue as sponsor confirmed
#4 - c4-judge
2023-08-12T09:09:04Z
HickupHH3 marked the issue as selected for report
🌟 Selected for report: bin2chen
Also found by: Angry_Mustache_Man, Giorgio, dirk_y
321.319 USDC - $321.32
https://github.com/GenerationSoftware/pt-v5-cgda-liquidator/blob/7f95bcacd4a566c2becb98d55c1886cadbaa8897/src/LiquidationPair.sol#L266-L270 https://github.com/GenerationSoftware/pt-v5-vault-boost/blob/9d640051ab61a0fdbcc9500814b7f8242db9aec2/src/VaultBooster.sol#L201-L203 https://github.com/GenerationSoftware/pt-v5-vault-boost/blob/9d640051ab61a0fdbcc9500814b7f8242db9aec2/src/VaultBooster.sol#L249-L257 https://github.com/GenerationSoftware/pt-v5-vault-boost/blob/9d640051ab61a0fdbcc9500814b7f8242db9aec2/src/VaultBooster.sol#L276-L279
When a user/bot wants to perform a liquidation for a liquidation pair, they can call view methods like maxAmountOut
to see what balance of the output token they are able to liquidate at the current time. This makes an underlying call to liquidatableBalanceOf
that reports the maximum balance of output token that is held by the Vault or VaultBooster.
However the VaultBooster contract has an issue where the return value of liquidatableBalanceOf
can actually be higher than the balance of output token held by the contract. As a result, a user/bot might try to perform a liquidation for the liquidatableBalanceOf
of the VaultBooster, but this call will revert because the resulting token transfer will revert due to insufficient balance.
A liquidator is able to compute the profitability of a liquidation before performing the action by calling view methods like maxAmountOut
, maxAmountIn
, computeExactAmountIn
and estimateAmountOut
. All of these view methods make an underlying call to _maxAmountOut
:
/// @notice Computes the maximum amount of output tokens that can be purchased /// @return Maximum amount of output tokens function _maxAmountOut() internal returns (uint256) { uint emissions = uint(convert(_emissionRate.mul(_getElapsedTime()))); uint liquidatable = source.liquidatableBalanceOf(tokenOut); return emissions > liquidatable ? liquidatable : emissions; }
This method returns the maximum amount of output tokens that can be purchased based on the emission rate and the liquidatable balance of the source (i.e. the Vault or VaultBooster). In the case where the emissions have exceeded the liquidatable balance of the source, the max amount that can be purchased is equal to the liquidatable balance of the source.
For the VaultBooster contract, liquidatableBalanceOf
looks like:
function liquidatableBalanceOf(address _tokenOut) external override returns (uint256) { return _accrue(IERC20(_tokenOut)); }
where the underlying call to _accrue
looks like:
function _accrue(IERC20 _tokenOut) internal returns (uint256) { uint256 available = _computeAvailable(_tokenOut); _boosts[_tokenOut].available = available.toUint144(); _boosts[_tokenOut].lastAccruedAt = uint48(block.timestamp); emit BoostAccrued(_tokenOut, available); return available; }
As you can see, the liquidatableBalanceOf
of the VaultBooster contract is equal to the return value of _computeAvailable
. The last 3 lines of this method have an issue:
uint256 availableBalance = _tokenOut.balanceOf(address(this)); deltaAmount = availableBalance > deltaAmount ? deltaAmount : availableBalance; return boost.available + deltaAmount;
Here the deltaAmount
is capped by the balance of the token, however boost.available
can also be non-zero (and often will be). As a result, the return value can actually be greater than the token balance of the contract.
Now, when anyone calls liquidatableBalanceOf
they will see a larger value than is actually held by the VaultBooster contract and therefore any liquidations that are triggered based on this return value will revert. Since most callers will be sophisticated bots, this is quite a critical issue to fix to ensure the bots are performing as expected.
Manual review
The return value of _computeAvailable
should be updated to cap the whole boost balance by the token balance of the contract, rather than just the delta. Below is a suggested diff:
diff --git a/src/VaultBooster.sol b/src/VaultBooster.sol index 951c58a..8cc9cdd 100644 --- a/src/VaultBooster.sol +++ b/src/VaultBooster.sol @@ -263,6 +263,7 @@ contract VaultBooster is Ownable, ILiquidationSource { Boost memory boost = _boosts[_tokenOut]; uint256 deltaTime = block.timestamp - boost.lastAccruedAt; uint256 deltaAmount; + uint256 available; if (deltaTime == 0) { return boost.available; } @@ -274,8 +275,8 @@ contract VaultBooster is Ownable, ILiquidationSource { deltaAmount += convert(boost.multiplierOfTotalSupplyPerSecond.intoUD60x18().mul(convert(deltaTime)).mul(convert(totalSupply))); } uint256 availableBalance = _tokenOut.balanceOf(address(this)); - deltaAmount = availableBalance > deltaAmount ? deltaAmount : availableBalance; - return boost.available + deltaAmount; + available = availableBalance > boost.available + deltaAmount ? boost.available + deltaAmount : availableBalance; + return available; } /// @notice Requires the given token to be the prize token
Invalid Validation
#0 - c4-pre-sort
2023-08-08T23:28:15Z
raymondfam marked the issue as duplicate of #90
#1 - c4-judge
2023-08-14T07:09:00Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: dirk_y
Also found by: Angry_Mustache_Man
1031.3943 USDC - $1,031.39
https://github.com/GenerationSoftware/pt-v5-cgda-liquidator/blob/7f95bcacd4a566c2becb98d55c1886cadbaa8897/src/libraries/ContinuousGDA.sol#L39-L41 https://github.com/GenerationSoftware/pt-v5-cgda-liquidator/blob/7f95bcacd4a566c2becb98d55c1886cadbaa8897/src/libraries/ContinuousGDA.sol#L65 https://github.com/GenerationSoftware/pt-v5-cgda-liquidator/blob/7f95bcacd4a566c2becb98d55c1886cadbaa8897/src/libraries/ContinuousGDA.sol#L86
The LiquidationPair
contract facilitates Periodic Continuous Gradual Dutch Auctions for yield. This uses the underlying ContinuousGDA.sol
library in order to correctly price the auctions.
However this library incorrectly implements the formula, using the emission rate in a few places where it should use the decay constant. Since the decay constant is usually less than the emission rate (as can also be seen from the test suite), this means that the purchasePrice
calculation is lower than it should be, meaning that liquidations are over-incentivised.
This is difficult to demonstrate given the issue is basically just that the formula in https://www.paradigm.xyz/2022/04/gda has been wrongly implemented. However I'll point out a few issues in the code:
function purchasePrice( SD59x18 _amount, SD59x18 _emissionRate, SD59x18 _k, SD59x18 _decayConstant, SD59x18 _timeSinceLastAuctionStart ) internal pure returns (SD59x18) { if (_amount.unwrap() == 0) { return SD59x18.wrap(0); } SD59x18 topE = _decayConstant.mul(_amount).div(_emissionRate); topE = topE.exp().sub(ONE); SD59x18 bottomE = _decayConstant.mul(_timeSinceLastAuctionStart); bottomE = bottomE.exp(); SD59x18 result; if (_emissionRate.unwrap() > 1e18) { result = _k.div(_emissionRate).mul(topE).div(bottomE); } else { result = _k.mul(topE.div(_emissionRate.mul(bottomE))); } return result; }
In the result
calculation you can see that _k
is divided by _emissionRate
. However, according to the proper formula, _k
should be divided by _decayConstant
.
Another issue occurs in purchaseAmount
where _k
is added to lnParam
instead of ONE and price
is multiplied by _emissionRate
instead of _decayConstant
:
function purchaseAmount( SD59x18 _price, SD59x18 _emissionRate, SD59x18 _k, SD59x18 _decayConstant, SD59x18 _timeSinceLastAuctionStart ) internal pure returns (SD59x18) { if (_price.unwrap() == 0) { return SD59x18.wrap(0); } SD59x18 exp = _decayConstant.mul(_timeSinceLastAuctionStart).exp(); SD59x18 lnParam = _k.add(_price.mul(_emissionRate).mul(exp)).div(_k); SD59x18 numerator = _emissionRate.mul(lnParam.ln()); SD59x18 amount = numerator.div(_decayConstant); return amount; }
I would suggest double checking the formula and the derivation of the complementary formulas to calculate amount/k. The correct implementation is show in the diff below.
Manual review
The implementation should be updated to correctly calculate the price for a continuous GDA. I have made the required fixes in the diff below:
diff --git a/src/libraries/ContinuousGDA.sol b/src/libraries/ContinuousGDA.sol index 721d626..7e2bb61 100644 --- a/src/libraries/ContinuousGDA.sol +++ b/src/libraries/ContinuousGDA.sol @@ -36,9 +36,9 @@ library ContinuousGDA { bottomE = bottomE.exp(); SD59x18 result; if (_emissionRate.unwrap() > 1e18) { - result = _k.div(_emissionRate).mul(topE).div(bottomE); + result = _k.div(_decayConstant).mul(topE).div(bottomE); } else { - result = _k.mul(topE.div(_emissionRate.mul(bottomE))); + result = _k.mul(topE.div(_decayConstant.mul(bottomE))); } return result; } @@ -62,7 +62,7 @@ library ContinuousGDA { return SD59x18.wrap(0); } SD59x18 exp = _decayConstant.mul(_timeSinceLastAuctionStart).exp(); - SD59x18 lnParam = _k.add(_price.mul(_emissionRate).mul(exp)).div(_k); + SD59x18 lnParam = ONE.add(_price.mul(_decayConstant).mul(exp)).div(_k); SD59x18 numerator = _emissionRate.mul(lnParam.ln()); SD59x18 amount = numerator.div(_decayConstant); return amount; @@ -83,7 +83,7 @@ library ContinuousGDA { ) internal pure returns (SD59x18) { SD59x18 exponent = _decayConstant.mul(_targetFirstSaleTime); SD59x18 eValue = exponent.exp(); - SD59x18 multiplier = _emissionRate.mul(_price); + SD59x18 multiplier = _decayConstant.mul(_price); SD59x18 denominator = (_decayConstant.mul(_purchaseAmount).div(_emissionRate)).exp().sub(ONE); SD59x18 result = eValue.div(denominator); return result.mul(multiplier);
Math
#0 - c4-pre-sort
2023-08-08T02:11:15Z
raymondfam marked the issue as low quality report
#1 - raymondfam
2023-08-08T02:13:43Z
The formula in the doc is just one of many different price functions the protocol can work on.
#2 - c4-pre-sort
2023-08-09T00:15:54Z
raymondfam marked the issue as remove high or low quality report
#3 - c4-pre-sort
2023-08-09T00:15:58Z
raymondfam marked the issue as primary issue
#4 - c4-pre-sort
2023-08-09T00:16:08Z
raymondfam marked the issue as high quality report
#5 - c4-sponsor
2023-08-09T19:14:27Z
asselstine marked the issue as sponsor confirmed
#6 - HickupHH3
2023-08-14T03:52:26Z
I agree that the pricing formula is up to the project to figure out and implement; it doesn't necessarily have to be the one Paradigm laid out in their blog post.
Nevertheless, the sponsor confirmed the issue, so I will leave it as that.
IMO the issue's severity can be raised to H, but I'd have liked to see further proof of price deviations to justify the higher severity =)
I suggest explicitly stating out what the intended pricing formula is, so that wardens can verify the implementation's correctness. Right now, it's guesswork.
#7 - c4-judge
2023-08-14T03:52:31Z
HickupHH3 marked the issue as selected for report